Flutter桌面小工具 -- 靈動島【Windows+Android版本】

語言: CN / TW / HK

theme: vuepress

通過此篇文章,你將瞭解到: 1. Flutter動畫實現靈動島; 2. Flutter如何開發一個置頂可自由拖拽的小工具; 3. 分享一些關於靈動島的想法。

⚠️本文為稀土掘金技術社群首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!

前言

Flutter開發Windows應用已經見怪不怪了,我覺得可以嘗試做一些小工具。恰逢近期最近蘋果iphone 14系列推出“靈動島”,這個酷炫的元件瞬間引起很多關注;而且看到一些前端部落格用css實現靈動島的效果;作為Flutter的忠實擁護者,前端能寫的Flutter必須能寫!

靈動島效果實現

be68a364-4d80-4547-a130-0246c5bbe94f.gif - 小藥丸放大
小藥丸放大的效果可以拆分為兩步:橫向放大+慣性縮放回彈。需要兩個動畫和控制器,當放大動畫執行完畢的時候,執行縮放動畫,從1.04到1.0。 ``` dart // 初始化變數 late Animation _animation; late AnimationController _animationController; AnimationStatus status = AnimationStatus.forward;

late Animation _scaleAnimation; late AnimationController _scaleAnimationController; dart void initState() { super.initState();

_animationController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); _animation = Tween( 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( begin: 1, end: 1, ).animate(_scaleAnimationController);

// 放大動畫執行完畢,開始縮放動畫,從1.04到1.0 animationController.addStatusListener((status) { this.status = status; if (status == AnimationStatus.completed) { _scaleAnimation = Tween( begin: count == 3 ? 1.04 : 1.06, end: 1, ).animate(_scaleAnimationController); _scaleAnimationController.forward(from: 0); } }); } 佈局上使用`AnimatedBuilder`監聽animate值的變化,設定小藥丸的寬高以達到放大和縮放效果。 dart 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形的效果;這裡也需要一個動畫控制器,在佈局上我們選擇`Stack`和`Positioned`。分離過程就是小圓圈的右邊距一直往負的方向放大,實現向右移出和向左縮回。dart late Animation _ballAnimation; late AnimationController _ballAnimationController; dart _ballAnimationController = AnimationController( duration: const Duration(milliseconds: 600), vsync: this, ); _ballAnimation = Tween(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的外掛,每個步驟都有對應註釋。 ``` dart static late double relHeight;

static initWindow(List 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的小元件啦! image.png

將應用配置為小工具【Android端】

Android小元件與Windows可是大有不同。由於Google基於安全的限制,Android應用必須是全屏且不允許穿透點選,因此Android的小元件一般都是依附於懸浮窗來開發的,即windows_manager
Flutter只是一個UI框架,自然也不能脫離Android本身的機制,因此我們需要在原生層建立一個懸浮窗,然後建立一個Flutter engine來吸附Flutter的UI。 - 建立後臺服務 ``` manifest

- 建立一個懸浮窗,實現步驟注意看其中的註釋 kotlin 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系統。
專案原始碼倉庫:https://github.com/WxqKb/open_flutter_desktop.git