在Flutter專案中開發IOS桌面元件(WidgetExtension)

寒江c發表於2021-07-14

在Flutter專案中開發IOS桌面元件(WidgetExtension)

具體的WidgetExtension的開發流程這裡就不細說了,可以參考文末的連結。

在Flutter專案開發IOSWidget的過程中,主要的問題有:

  • App和Widget的資料共享
  • 點選Widget跳轉App的指定介面
  • 在App介面編輯並更新Widget資料
App和Widget資料共享

資料共享使用的是UserDefaults,前提是需要為WidgetExtension和Runner新增相同的AppGroup。新增AppGroup的方法為:

Runner -> Target -> Runner -> Signing&Capabilities -> AppGroups -> +

這裡如果沒有AppGroups可以XCode點選右上角的+號來新增AppGroups。

AppGroup.jpg WidgetExtension新增方法同上,其中AppGroup要和Runner的相同。

UserDefaults的使用

這裡以實際的例子為大家展示UserDefaults的使用。為了方便演示,在App啟動時儲存相關資料,以供小元件進行讀取。

// 以下程式碼在AppDelegate.swift中的Application方法中
// suitName: 為上面新增的AppGroup
let userDefaults = UserDefaults.init(suiteName: "group.com.cc.ToDo")
userDefaults!.setValue("defaultID", forKey: "id")
userDefaults!.setValue("defauleName", forKey: "name")
複製程式碼

在小元件中讀取

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
        print("start getTimeline")
        let userDefaults = UserDefaults(suiteName: "group.com.cc.ToDo")
        let id = userDefaults?.string(forKey: "id")
        let name = userDefaults?.string(forKey: "name")
        print("timeline:  \(id!) \(name!)")
        // ... 這裡省略了後續的completion
}
複製程式碼
點選Widget跳轉App的指定介面

在小元件點選跳轉App這塊,本次使用的技術為widgetURL以及URL Schemes。

在小元件中處理點選跳轉主要有兩種方法:

  • widgetURL:作用於整個小元件,且一個小元件只能有一個
  • Link:作用於Link包裹的元件的大小,在小尺寸[systemSmall]元件中無法使用Link

可以根據實際情況選擇合適的元件。

URL Schemes

URL Schemes主要負責處理跳轉邏輯,通過配置URL Schemes,在App中捕獲對應的url和引數來實現跳轉指定頁。 註冊URL Schemes主要包含以下幾步:

Runner -> Info -> URL Types -> 新增+ -> 編輯URL Schemes

URLSchemes.jpg

完成之後可以再widgetURL中新增url(以上述配置的URL Schemes開頭),程式碼如下:

var body: some View{
    VStack{
        Text("ToDoList")
        Text(entry.userid)
        Text(entry.author)
    }
    // URL以配置的URL Schemes開頭,可以拼接引數
    .widgetURL(URL(string: "dynamictheme://user?userid=\(entry.userid)&author=\(entry.author)"))
}
// 在Link中配置的URL同此
複製程式碼

Flutter端則使用uni_links庫來進行連結捕獲和跳轉,具體實現如下:


import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:uni_links/uni_links.dart';
import 'pages/UserPage.dart';

GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  StreamSubscription<String> _sub;
  @override
  void initState() {
    super.initState();
    initPlatformStateForStringUniLinks();
  }

  Future<void> initPlatformStateForStringUniLinks() async {
    String initialLink;
    // App未開啟的狀態在這個地方捕獲scheme
    try {
      initialLink = await getInitialLink();
      print('跳轉地址: $initialLink');
      if (initialLink != null) {
        print('跳轉地址不為null --$initialLink');
        //  跳轉到指定頁面
        schemeJump(context, initialLink);
      }
    } on PlatformException {
      initialLink = 'Failed to get initial link.';
    } on FormatException {
      initialLink = 'Failed to parse the initial link as Uri.';
    }
    // App開啟的狀態監聽scheme
    _sub = getLinksStream().listen((String link) {
      if (!mounted || link == null) return;
      print('link--$link');
      //  跳轉到指定頁面
      schemeJump(context, link);
    }, onError: (Object err) {
      if (!mounted) return;
    });
  }

  void schemeJump(BuildContext context, String schemeUrl) {
    final Uri _jumpUri = Uri.parse(schemeUrl.replaceFirst(
      'dynamictheme://',
      'http://path/',
    ));
    switch (_jumpUri.path) {
      case '/user':
        print("接收到的引數為:");
        String userid = _jumpUri.queryParameters["userid"];
        print(userid);
        String author = _jumpUri.queryParameters["author"];
        print(author);

        Navigator.of(navigatorKey.currentContext).push(CupertinoPageRoute(
            builder: (context) => UserPage(
                  userid: userid,
                  author: author,
                )));
        break;
      default:
        break;
    }
  }

  @override
  void dispose() {
    super.dispose();
    _sub.cancel();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: navigatorKey,
      title: 'Flutter 與 IOS',
      theme:
      ThemeData(primarySwatch: Colors.blue, platform: TargetPlatform.iOS),
      home: HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("HomePage")
      ),
      body: Center(
        child: Text("Home page"),
      ),
    );
  }
}
複製程式碼
在App介面編輯並更新Widget資料

在App編輯資料並更新widget功能中,通過MenthodChannel來實現。當編輯完資料,需要更新時,通過MethodChannel來呼叫原生方法,在原生方法中更新UserDefaults的資料,並返回結果給Flutter端。

資料更新完成並不會重新整理Widget,因為Widget中使用的是前一Timeline的快照,在下一個Timeline之前並不會重新整理資料,因此需要主動呼叫相關方法來更新資料。

在原生端想要主動來更新小元件的Timeline,主要有兩種方法:

  • WidgetCenter.shared.reloadAllTimelines(): 跟新App下所有元件的Timelines
  • WidgetCenter.shared.reloadTimelines(ofKind: kind): 更新指定kind型別元件的Timelines

具體的實現如下可參考以下程式碼

Flutter端程式碼如下:

MethodChannel channel = MethodChannel("com.cc.ToDo.widgets");
var res = await channel.invokeMethod("updateWidgetData", {
    "userid":idController.text,
    "author":nameController.text
});

print(res);
print(res.runtimeType);
複製程式碼

Swift端程式碼如下:

import UIKit
import Flutter
import WidgetKit

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    
    let controller:FlutterViewController = window?.rootViewController as! FlutterViewController
    
    let userDefaults = UserDefaults.init(suiteName: "group.com.cc.ToDo")
    userDefaults!.setValue("defaultID", forKey: "userid")
    userDefaults!.setValue("defauleName", forKey: "author")
    // 初始化MethodChannel,設定監聽
    WidgetMenthod.init(messger: controller.binaryMessenger)
    GeneratedPluginRegistrant.register(with: self)

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}
複製程式碼
// 處理Flutter呼叫
class WidgetMenthod{
    init(messger:FlutterBinaryMessenger){
        let channel = FlutterMethodChannel(name: "com.cc.ToDo.widgets", binaryMessenger: messger)
        channel.setMethodCallHandler{(call:FlutterMethodCall, result: @escaping FlutterResult) in
            // 通過call.method來判斷要呼叫的方法
            if(call.method == "updateWidgetData"){
            // 通過call.arguments來獲取引數
                if let dict = call.arguments as? Dictionary<String,Any>{
                    let userid = dict["userid"] as? String
                    let author = dict["author"] as? String
                    print("\(userid) ==== \(author)")
                    let userDefaults = UserDefaults.init(suiteName: "group.com.cc.ToDo")
                    userDefaults!.setValue(userid, forKey: "userid")
                    userDefaults!.setValue(author, forKey: "author")
                    if #available(iOS 14.0, *) {
                        print("reload timelines")
                        WidgetCenter.shared.reloadTimelines(ofKind: "todo_list")
                        print("reload complete!")
                        result(["code":1,"msg":"success"])
                    } else {
                        result(["code":0,"msg":"系統版本過低"])
                    }
                }else{
                    result(["code":0,"msg":"引數異常"])
                }
            }
        }
    }
}
複製程式碼

至此,在Flutter專案中開發IOS桌面元件就全部完成了。

完整案例原始碼點此下載

參考文章

網易雲音樂 iOS 14 小元件實戰手冊

【Flutter 混合開發】與原生通訊-MethodChannel

相關文章