FlutterでSwiftUI(iOS14WidgetKit)に対応する


はじめに🧟‍🧟‍♂️

iOS14での新機能であるウィジェット(WidgetKit)が追加されました。
私は貯金メモというアプリをFlutterで開発しています。今回はFlutter環境でのウィジェット対応を行う際にどんなことを行ったか備忘録を兼ねて紹介していきます。

今回のアプローチ

UserDefaultsでデータを読み取れればできるという想定の元実装しました。
ウィジェットで表示したいデータをUserDefaults経由で保存・読取を行います。
割と強引なやり方かもしれませんのでベストプラクティスがあれば教えてくださると幸いです。

※Firestore等は利用していないので注意してください。

  1. Flutter: SharedPreferencesでデータを保存(MethodChannel呼び出し)
  2. Swift: MethodChannel経由でSharedPreferencesのデータを取得
  3. Swift: AppGroupを利用してSwiftUI(WidgetKit)側へデータを共有する
  4. SwiftUI: AppGroupで共有されたデータを読み取る

2.3で一度取得して再度共有し直してる背景としてはSharedPreferences側でAppGroupを利用する際に必要なsuiteNameが指定できないことが原因でした。
(現在issueで議論、PRも出ている状態なので将来的にはサポートされるかもしれません)

実装手順🚀

Flutter: SharedPreferencesでデータを保存

ウィジェットで表示したいデータを保存します。
ここでは特に言うことはありませんが、保存時のキーはSwift側でも利用するので間違えないように。

final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setInt('money_total_key', 10000);
await prefs.setString('goal_title_key', '旅行に行く');

MethodChannel経由でSharedPreferencesのデータを取得

Flutter側からSwiftのコードを利用するにはMethodChannelを利用します。
MethodChannel名は任意ですが公式ドキュメントでの実装は「パッケージ名/メソッド名」にしていたのでそれに従うのが無難だと思います。

// パッケージ名とかにするのが慣習ぽい?
static const methodChannel = const MethodChannel('your_app_package_name.flutter/sample');

Swift側(ネイティブ)へ通知する。
invokeMethodで呼び出したいメソッド名を記載しましょう。今回は'setMoneyDataForWidgetKit'と言うメソッド名にしています。

try {
    final bool result = await methodChannel.invokeMethod('setMoneyDataForWidgetKit');
    print('SET setUserDefaultsForAppGroup: $result');
} on PlatformException catch (e) {
    print('ERROR setUserDefaultsData: ${e.message}');
}

AppDelegate.swiftでMethodChannelの呼び出しを検知する処理です。
setMethodCallHandlerの部分が呼び出されるので先ほどFlutter側で登録したメソッド名をチェックして呼び出すことができます。

override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {

    // ここを書いてね
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let channel = FlutterMethodChannel(name: "your_app_package_name.flutter/sample",
                                              binaryMessenger: controller.binaryMessenger)
    channel.setMethodCallHandler({
        (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in

        guard call.method == "setUserDefaultsForAppGroup" else {
            result(FlutterMethodNotImplemented)
            return
        }
	// 任意のメソッドを呼び出す
        self.setUserDefaultsForAppGroup(result: result)
    })
    // ここまで
    
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

MethodChannelではFlutter側へ成功や失敗等の結果を返すことができます。
(ここでは成功時はtrue、失敗時は適当なメッセージを返すだけに留めています。)

private func setUserDefaultsForAppGroup(result: FlutterResult) {

    // return result(FlutterError(code: "UNAVAILABLE",
    //                               message: "setUserDefaultsForAppGroup Failed",
    //                               details: nil))
    // result(true)
}

※先ほどinvokeMethodで実装したresultはこの結果を受け取ることができます

try {
    final bool result = await methodChannel.invokeMethod('setMoneyDataForWidgetKit');
    print('SET setUserDefaultsForAppGroup: $result');
} on PlatformException catch (e) {
    print('ERROR setUserDefaultsData: ${e.message}');
}

AppGroupを利用してSwiftUI(WidgetKit)側へデータを共有する

ではMethodChannel経由でSwiftのコードを呼び出せるようになりました。
主にUserDefaults関連の処理になります。
アプリ間でのデータ共有にはAppGroupを利用しますが、細かい設定方法は割愛します。

実際の設定等はこちらの記事がわかりやすかったです。

Flutter側でSharedPreferencesを利用して保存したデータはiOSの場合、UserDefaultsに保存されることになります。その際、Dartで記載したKey名に自動でPrefixが付与されます。

final SharedPreferences prefs = await SharedPreferences.getInstance();
// 実際は'flutter.money_total_key'のKeyで保存される
await prefs.setInt('money_total_key', 10000);
// 実際は'flutter.goal_title_key'のKeyで保存される
await prefs.setString('goal_title_key', '旅行に行く');

Keyさえ間違えなければあとは一度取得したデータをAppGroup用のUserDefaultsへデータを保存するだけです。

private func setUserDefaultsForAppGroup(result: FlutterResult) {

    guard let appGroupUD = UserDefaults(suiteName: "group.savingmoney") else {
        return result(FlutterError(code: "UNAVAILABLE",
                                   message: "setUserDefaultsForAppGroup Failed",
                                   details: nil))
    }

    // 1: FlutterのSharedPreferencesから取得
    let defaults = UserDefaults.standard
    let totalMoney = defaults.value(forKey: "flutter.money_total_key") as? String
    let goalTitle = defaults.value(forKey: "flutter.goal_title_key") as? Int

    // 2: 1の結果をAppGroupのDefaultsに保存
    userDefaults.setValue(totalMoney, forKey: "money_total_key")
    userDefaults.setValue(goalTitle, forKey: "goal_title_key")
    result(true)
}

AppGroupで共有されたデータを読み取る

やっとここまできて本題のウィジェット(WidgetKit)部分の実装になります。
表示したいデータのEntryを定義します。

デフォルトだとこんな感じ↓

struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationIntent
}

先ほどAppGroup経由で保存したデータを利用するので↓の様に書き換えます

struct SimpleEntry: TimelineEntry {
    let date: Date
    let totalMoney: Int
    let goalTItle: String
    let configuration: ConfigurationIntent
}

ウィジェットはTimelineProviderを利用して時間ごとに用意することができます。
(デフォルトだと1時間おきに5回)
他にもSnapshotでのViewの定義も同様になります。(割愛)

func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .minute, value: hourOffset, to: currentDate)!

            let userDefaults = UserDefaults(suiteName: "group.savingmoney")
            let totalMoney = userDefaults?.value(forKey: "goal_title_key") as? String
            let goalTitle = userDefaults?.value(forKey: "goal_value_key") as? Int
            entries.append(SimpleEntry(date: Date(),
	                               totalMoney: totalMoney ?? 0,
                                       goalTitle: goalTitle ?? "",
                                       configuration: configuration))
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

Flutter側からウィジェットのデータを更新する

TimelineProviderでは時間ごとにデータが更新されますが、
保存したタイミングでウィジェットを更新したい場合には明示的にWidgetCenter.shared.reloadAllTimelines()を呼び出すと更新することができます。

import WidgetKit

private func setUserDefaultsForAppGroup(result: FlutterResult) {
    ~~~~~~~~~~~
    if #available(iOS 14.0, *) {
        WidgetCenter.shared.reloadAllTimelines()
    }
}

Sample

終わりに

今回は限定的な場面になりますがFlutter開発でのウィジェット対応をする際に役に立てば幸いです。
もっと上手いやり方などありましたらコメントなどで教えてください!

開発時のTwitter👀

取り掛かりは2020年10月末です。開発期間は2週間程度ですがキモとなってくる連携部分だけであれば1日かければ充分終わる内容になっています。

利用したパッケージや参考にした技術記事について