美文网首页
Flutter桌面小工具 -- 灵动岛【Windows+Andr

Flutter桌面小工具 -- 灵动岛【Windows+Andr

作者: 我爱田Hebe | 来源:发表于2022-10-20 10:06 被阅读0次

    通过此篇文章,你将了解到:

    1. Flutter动画实现灵动岛;
    2. Flutter如何开发一个置顶可自由拖拽的小工具;
    3. 分享一些关于灵动岛的想法。

    ⚠️本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

    前言

    Flutter开发Windows应用已经见怪不怪了,我觉得可以尝试做一些小工具。恰逢近期最近苹果iphone 14系列推出“灵动岛”,这个酷炫的组件瞬间引起很多关注;而且看到一些前端博客用css实现灵动岛的效果;作为Flutter的忠实拥护者,前端能写的Flutter必须能写!

    灵动岛效果实现

    • 小药丸放大
      小药丸放大的效果可以拆分为两步:横向放大+惯性缩放回弹。需要两个动画和控制器,当放大动画执行完毕的时候,执行缩放动画,从1.04到1.0。
    // 初始化变量
    late Animation<Size> _animation;
    late AnimationController _animationController;
    AnimationStatus status = AnimationStatus.forward;
    
    late Animation<double> _scaleAnimation;
    late AnimationController _scaleAnimationController;
    
    void initState() {
      super.initState();
    
      _animationController = AnimationController(
        duration: const Duration(milliseconds: 500),
        vsync: this,
      );
      _animation = Tween<Size>(
        begin: Size(104.w, EnvConfig.relHeight),
        end: Size(168.w, EnvConfig.relHeight),
      ).animate(_animationController);
    
      _scaleAnimationController = AnimationController(
        duration: const Duration(milliseconds: 300),
        vsync: this,
      );
      _scaleAnimation = Tween<double>(
        begin: 1,
        end: 1,
      ).animate(_scaleAnimationController);
    
    // 放大动画执行完毕,开始缩放动画,从1.04到1.0
    _animationController.addStatusListener((status) {
      this.status = status;
      if (status == AnimationStatus.completed) {
        _scaleAnimation = Tween<double>(
          begin: count == 3 ? 1.04 : 1.06,
          end: 1,
        ).animate(_scaleAnimationController);
        _scaleAnimationController.forward(from: 0);
      }
    });
    }
    

    布局上使用AnimatedBuilder监听animate值的变化,设置小药丸的宽高以达到放大和缩放效果。

    AnimatedBuilder(
      animation: _scaleAnimation,
      builder: (context, _) => AnimatedBuilder(
        animation: _animation,
        builder: (context, _) => Container(
          width: _animation.value.width * _scaleAnimation.value,
          height: _animation.value.height * _scaleAnimation.value,
          clipBehavior: Clip.antiAliasWithSaveLayer,
          decoration: BoxDecoration(
            color: Colors.black,
            borderRadius: BorderRadius.all(
              Radius.circular(15.h),
            ),
          ),
        ),
      ),
    ),
    
    • i形分离效果 在小药丸后面要分离出一个小圆圈,从而实现i形的效果;这里也需要一个动画控制器,在布局上我们选择StackPositioned。分离过程就是小圆圈的右边距一直往负的方向放大,实现向右移出和向左缩回。
    late Animation<double> _ballAnimation;
    late AnimationController _ballAnimationController;
    
    _ballAnimationController = AnimationController(
      duration: const Duration(milliseconds: 600),
      vsync: this,
    );
    _ballAnimation = Tween<double>(begin: 0, end: -EnvConfig.relHeight - 5)
        .chain(CurveTween(curve: Curves.easeInOut))
        .animate(_ballAnimationController);
    
    // 当药丸缩回的过程中,执行分离动画
    _animationController.addListener(() {
      if (count == 2 &&
          status == AnimationStatus.reverse &&
          _animationController.value > 0.25 &&
          _animationController.value < 0.3) {
        _ballAnimationController.forward(from: 0);
      }
    });
    

    上面是动画的过程,我们再看下布局的代码:

    AnimatedBuilder(
      animation: _ballAnimation,
      builder: (context, _) => Stack(clipBehavior: Clip.none, children: [
        AnimatedBuilder(
          // .... 小药丸 ....
        ),
        Positioned(
          top: 0,
          right: _ballAnimation.value,
          child: Container(
            width: EnvConfig.relHeight,
            height: EnvConfig.relHeight,
            decoration: const BoxDecoration(
              shape: BoxShape.circle,
              color: Colors.black,
            ),
          ),
        ),
      ]),
    ),
    

    动画其实非常简单,也没啥好讲的,重点在分享如何作为一个小工具。具体源码见文末仓库。

    将应用配置为小工具【Windows端】

    这里的前提是基于上一篇文章:做好屏幕的适配。
    在windows上,小工具就是一个普通应用【这跟Android window_manager的机制是不一样的】。不过我们需要把宽高、位置设置好;同时还需要保证小工具置顶、没有状态栏图标
    这里我们依然用到了window_manager的插件,每个步骤都有对应注释。

    static late double relHeight;
    
    static initWindow(List<String> args, {Size? screenSize}) async {
      // 注释:获取屏幕真实大小
      Display primaryDisplay = await screenRetriever.getPrimaryDisplay();
      relHeight = primaryDisplay.size.height * 0.04;
      double relWidth = relHeight * 8;
      final displaySize = Size(relWidth, relHeight * 1.06);
      await setSingleInstance(args);
      WindowManager w = WindowManager.instance;
      await w.ensureInitialized();
      WindowOptions windowOptions = WindowOptions(
        size: displaySize,
        minimumSize: displaySize,
        alwaysOnTop: true, // 注释:设置置顶
        titleBarStyle: TitleBarStyle.hidden, // 注释:去除窗口标题栏
        skipTaskbar: true // 注释:去除状态栏图标
      );
      w.waitUntilReadyToShow(windowOptions, () async {
        double w1 = (primaryDisplay.size.width - relWidth) / 2;
        await w.setBackgroundColor(Colors.transparent);
        await w.setPosition(Offset(w1, 10)); // 注释:设置居中
        await w.show();
        await w.focus();
        await w.setAsFrameless();
      });
    }
    

    这样我们就可以得到一个very good的小组件啦!

    将应用配置为小工具【Android端】

    Android小组件与Windows可是大有不同。由于Google基于安全的限制,Android应用必须是全屏且不允许穿透点击,因此Android的小组件一般都是依附于悬浮窗来开发的,即windows_manager
    Flutter只是一个UI框架,自然也不能脱离Android本身的机制,因此我们需要在原生层创建一个悬浮窗,然后创建一个Flutter engine来吸附Flutter的UI。

    • 创建后台服务
    <!-- 权限配置 -->
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE " />
    
    <service
        android:name=".WindowsService"
        android:enabled="true"
        android:exported="true">
    </service>
    
    • 创建一个悬浮窗,实现步骤注意看其中的注释
    package com.karl.open.desktop_app
    
    import android.annotation.SuppressLint
    import android.app.Service
    import android.content.Intent
    import android.graphics.PixelFormat
    import android.os.IBinder
    import android.util.DisplayMetrics
    import android.view.LayoutInflater
    import android.view.MotionEvent
    import android.view.ViewGroup
    import android.view.WindowManager
    import android.widget.FrameLayout
    import com.karl.open.desktop_app.utils.Utils
    import io.flutter.embedding.android.FlutterSurfaceView
    import io.flutter.embedding.android.FlutterView
    import io.flutter.embedding.engine.FlutterEngine
    import io.flutter.embedding.engine.FlutterEngineGroup
    import io.flutter.embedding.engine.dart.DartExecutor
    import io.flutter.view.FlutterMain.findAppBundlePath
    
    class WindowsService : Service() {
        // Flutter引擎组,可以自动管理引擎的生命周期
        private lateinit var engineGroup: FlutterEngineGroup
    
        private lateinit var engine: FlutterEngine
    
        private lateinit var flutterView: FlutterView
        private lateinit var windowManager: WindowManager
    
        private val metrics = DisplayMetrics()
        private lateinit var inflater: LayoutInflater
    
        @SuppressLint("InflateParams")
        private lateinit var rootView: ViewGroup
    
        private lateinit var layoutParams: WindowManager.LayoutParams
    
        override fun onCreate() {
            super.onCreate()
            layoutParams = WindowManager.LayoutParams(
                Utils.dip2px(this, 168.toFloat()),
                Utils.dip2px(this, 30.toFloat()),
                WindowManager.LayoutParams.TYPE_SYSTEM_ALERT,
                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
                PixelFormat.TRANSLUCENT
            )
    
            // 初始化变量
            windowManager = this.getSystemService(Service.WINDOW_SERVICE) as WindowManager
            inflater =
                this.getSystemService(Service.LAYOUT_INFLATER_SERVICE) as LayoutInflater
            rootView = inflater.inflate(R.layout.floating, null, false) as ViewGroup
            engineGroup = FlutterEngineGroup(this)
    
            // 创建Flutter Engine
            val dartEntrypoint = DartExecutor.DartEntrypoint(findAppBundlePath(), "main")
            val option =
                FlutterEngineGroup.Options(this).setDartEntrypoint(dartEntrypoint)
            engine = engineGroup.createAndRunEngine(option)
    
            // 设置悬浮窗的位置
            @Suppress("Deprecation")
            windowManager.defaultDisplay.getMetrics(metrics)
            setPosition()
            @Suppress("ClickableViewAccessibility")
            rootView.setOnTouchListener { _, event ->
                when (event.action) {
                    MotionEvent.ACTION_DOWN -> {
                        layoutParams.flags =
                            layoutParams.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                        windowManager.updateViewLayout(rootView, layoutParams)
                        true
                    }
                    else -> false
                }
            }
    
            engine.lifecycleChannel.appIsResumed()
    
            // 为悬浮窗加入布局
            rootView.findViewById<FrameLayout>(R.id.floating_window)
                .addView(
                    flutterView,
                    ViewGroup.LayoutParams(
                        ViewGroup.LayoutParams.MATCH_PARENT,
                        ViewGroup.LayoutParams.MATCH_PARENT
                    )
                )
            windowManager.updateViewLayout(rootView, layoutParams)
        }
    
        private fun setPosition() {
            // 设置位置
            val screenWidth = metrics.widthPixels
            val screenHeight = metrics.heightPixels
            layoutParams.x = (screenWidth - layoutParams.width) / 2
            layoutParams.y = (screenHeight - layoutParams.height) / 2
    
            windowManager.addView(rootView, layoutParams)
            flutterView = FlutterView(inflater.context, FlutterSurfaceView(inflater.context, true))
            flutterView.attachToFlutterEngine(engine)
        }
    }
    
    • 唤起悬浮窗组件
      直接通过adb指令唤起即可

    adb shell am start-foreground-service -n com.karl.open.desktop_app/com.karl.open.desktop_app.WindowsService

    • 注意
    1. 通过服务唤起悬浮窗,Android要求必须是系统应用,因此大家在使用的时候还需要配置下系统签名
    2. Flutter engine必须使用FlutterEngineGroup进行托管,否则静置一段时间后,engine就会被系统回收!!!

    关于灵动岛的一些思考

    windows版本的灵动岛组件,实现起来其实是比较简单的。但是我在思考,假设我作为一个OS开发者,我该怎么看待iPhone的这个软件创新?

    1. iPhone这个灵动岛的问世,其实把用户对状态栏的认知颠覆了:原来平时用来看时间电量的地方还能这么玩;这个创新能否带动整个移动端、甚至桌面端状态栏等工具的改革
    2. 虽说创新,但目前从各种测评来看,这个工具很少有应用接入,连iOS自己的软件都很多没有接入。着实是有点鸡肋的,而且用户还要去学习如何使用这个灵动岛,当应用更多的接入进来,用户的教育成本会变得更高,降低使用体验。所以iPhone为啥敢开拓创新做这个至少目前很鸡肋的工具呢?
    3. iPhone官方如何去推广灵动岛,让更多用户接受

    上面这几个问题,也是我一直在思考的。但其实是环环相扣的,首先能否引领新的交互改革,这个取决于市场的接受度。而市场的接受度,除了果粉引以为傲的“别人没有而我有”,还要做到真正的实用:iOS自身更多的软件接入,让灵动岛功能更完善。
    用户习惯了用这个工具,大量软件就必须为了用户而作
    同时按照iPhone的营销手段,会大量利用iPhone的用户心理,不断放大这个灵动岛的格调,很多软件为了俘获用户,甚至会专门为灵动岛做一些扩充的功能,从而吸引很多用户。【目前已有一些软件在做这个事情了】

    而假设我是OS开发者,如果我要去做这个工具,首先我的用户基数要足够大,同时让工具提供简单且实用的功能,真正把投入产出比做好,而且真正得服务于用户。酷炫与否交给营销去推广,真正对用户有用的东西,才是底子所在!

    写在最后

    灵动岛组件的实现,分windows和android系统。
    项目源码仓库:github.com/WxqKb/open_…

    作者:Karl_wei
    链接:https://juejin.cn/post/7154420798059446309

    相关文章

      网友评论

          本文标题:Flutter桌面小工具 -- 灵动岛【Windows+Andr

          本文链接:https://www.haomeiwen.com/subject/yofuzrtx.html