【Flutter】iOS14の新機能、WidgetKitを操作する

【Flutter】iOS14の新機能、WidgetKitを操作する 技術メモ

iOS14での新機能である『WidgetKit』をFlutterから操作する方法をまとめました。

今回つくるもの

アプリからウィジェットに値を渡す仕組み(AppGroups)

まず、イメージとして、データを提供するアプリと、WidgetKitアプリがあり、それぞれ独立している。と考えるとわかりやすいです。

では、独立したアプリ同士でどうやって値を渡すかというと、実は、iOSアプリには『App Groups』という機能があり、UserDefaults経由でアプリ間のデータ共有が可能です。

App Groupsの設定方法は後述しますが、以下のように、suiteName付きのUserDefaultsを使用することでアプリ間で同じ保存領域を使うことができます。

let userDefaults = UserDefaults(suiteName: "hogeGroup")
userDefaults?.set("hoge", forKey: "hogeKey")
let userDefaults = UserDefaults(suiteName: "hogeGroup")
let date = userDefaults?.get("hoge", forKey: "hogeKey")

// dateには"hoge"が入る

つまり、以下のような仕組みでアプリのデータをウィジェットに表示させます。

アプリからウィジェットに値を渡す仕組み
  • 作成したアプリで、ウィジェットに表示したいデータをsuiteName付きのUserDefaultsに保存
  • WidgetKitアプリで、ウィジェットに表示したいデータをsuiteName付きのUserDefaultsから取得

FlutterからsuiteName付きUserDefaultにデータを保存する

まずは、ウィジェットに表示するデータをsuiteName付きのUserDefaultsに保存する部分を考えます。

実は、shared_preferencesを使用することで、FlutterからUserDefaultsに保存することが可能。がしかし、

残念ですが、suiteName付きのUserDefaultsではなく、StandardなUserDefaultsに保存されてしまいます。
※suiteName付きに保存する方法があれば教えて下さい。

そのため、やり方は後述しますが、FlutterのMethodChannelを使って、Swiftに処理を依頼して、suiteName付きのUserDefaultsにデータを保存してもらう必要があります。

WidgetKitアプリでデータを取得して表示する

次に、ウィジェットに表示するデータをsuiteName付きのUserDefaultsから取得する部分を考えます。

WidgetKitアプリはSwiftで記述されているため簡単で、然るべき箇所で取得するだけです。

然るべき箇所と、コードは後述します。

ここまでの話をまとめると

Flutterでウィジェットにデータを表示する流れは、以下のようになります。

ウィジェットにデータを表示する流れ
  1. Flutterでshared_preferencesを使って、StandardなUserDefaultsにデータを保存
  2. FlutterがSwiftにsuiteName付きのUserDefaultsへデータを保存するよう依頼
  3. WidgetKitアプリがsuiteName付きのUserDefaultsからデータを取得して表示

ここからは、アプリの設定などとともに、具体的な実装方法を紹介します。

ソースコードとアプリの設定を画像付きで紹介

紹介すること
  1. Flutterでカウンターの値を保存
  2. FlutterからSwiftへ処理を依頼する
  3. App Groupsの設定をする
  4. SwiftでsuiteName付きのUserDefaultsにカウンターの値を保存
  5. WidgetKitアプリを追加して、App Groupsを設定する
  6. WidgetKitアプリでカウンターの値を取得して、表示

Flutterでカウンターの値を保存

このページを見ている人なら、解説は不要だと思いますので、ソースコードをべったり貼っておきます。

floatingActionButtonに配置したボタンをタップすると、カウンターの値をインクリメントして、都度shared_preferencesで保存するだけです。

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var _counter = 0;

  Future<void> _incrementCounter() async {
    // カウンターをインクリメントする
    setState(() {
      _counter++;
    });

    // 更新後のカウンターをshared_preferencesで保存
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt('counter', _counter);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('WidgetKitで遊ぶ'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text('ここの数字がウィジェットに表示されます'),
            Text('$_counter', style: TextStyle(fontSize: 100),),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

FlutterからSwiftへ処理を依頼する

FlutterからSwiftへ処理を依頼するときには、MethodChannelを使います。

他のアプリとはかぶらないように、引数には「”パッケージ名/チャネル名”」とするのが慣例です。

class _MyHomePageState extends State<MyHomePage> {
  static const _methodChannel = MethodChannel('work.hondakenya.flutterWidgetkit/sample');
  var _counter = 0;

次に、invokeMethodメソッドを用いて、Swift側のコードを呼び出します。第一引数のメソッド名は任意の文字列を指定。

カウンター値をshared_preferencesで保存した後に、invokeMethodします。
※ここではinvokeするメソッド名を”setCounterForWidgetKit”としました。

  Future<void> _incrementCounter() async {
    // カウンターをインクリメントする
    setState(() {
      _counter++;
    });

    // 更新後のカウンターをshared_preferencesで保存
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt('counter', _counter);

    // Swiftのメソッド実行を依頼(通知)する(counterの値を保存する)
    try {
      final result = await _methodChannel.invokeMethod('setCounterForWidgetKit');
      print(result);
    } on PlatformException catch (e) {
      print('${e.message}');
    }

Swift側では、まず、MethodChannelを作成したときの文字列と、FlutterViewControllerを用いて、methodChannel(ソースではchannel)を作成します。

次に、Flutter側でinbokeMethodしたものを受け取るために、methodChannelにMethodCallHandlerを設定しておきます。

@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let channel = FlutterMethodChannel(name: "work.hondakenya.flutterWidgetkit/sample",
                                       binaryMessenger: controller.binaryMessenger)
    channel.setMethodCallHandler({
        (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
        // ★channelにメソッドがinvokeされてきたら、呼ばれるようになる
    })
    
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

※AppDelegate.swiftは、プロジェクトルート>ios>Runner配下にあります。

App Groupsの設定をする

さて、WidgetKitアプリにデータを共有するために、App Groupsの設定が必要でした。以下のように簡単に設定できます。

まずは、Runner.xcworkspaceをXcodeで開きます。

Runner>Signing & Capabillities>+Capabillity>App Groupsと選択して、App Groupsを追加します。

App-Groupsの追加1
App-Groupsの追加2

SwiftでsuiteName付きのUserDefaultsにカウンターの値を保存

まず、FlutterからInvokeされてきた「setCounterForWidgetKit」を検知して、Swift側のsetUserDefaultsForAppGroupメソッドを呼ぶように修正します。

そのsetUserDefaultsForAppGroupメソッド内で、Flutter側で保存したデータを取り出して、suiteName付きのUserDefaultsに保存し直す処理を行います。

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 channel = FlutterMethodChannel(name: "work.hondakenya.flutterWidgetkit/sample",
                                       binaryMessenger: controller.binaryMessenger)
    channel.setMethodCallHandler({
        (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
        
        // Invokeされたメソッドが「setCounterForWidgetKit」の場合
        if call.method == "setCounterForWidgetKit" {
            self.setUserDefaultsForAppGroup(result: result)
        }
        
        result(FlutterMethodNotImplemented)
        return
    })
    
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
    
    private func setUserDefaultsForAppGroup(result: FlutterResult) {

        // FlutterのSharedPreferencesから取得
        let defaults = UserDefaults.standard
        let counter = defaults.integer(forKey: "flutter.counter") // flutterで指定したキーの先頭に「flutter.」が付与されている
        
        // 2: 1の結果をAppGroupのDefaultsに保存
        guard let userDefaults = UserDefaults(suiteName: "group.counter.hondakenya.sample") else {
            return result(FlutterError(code: "UNAVAILABLE",
                                       message: "setUserDefaultsForAppGroup Failed",
                                       details: nil))
        }
        userDefaults.setValue(counter, forKey: "counter")
        
        // WidgetKitを更新させる
        if #available(iOS 14.0, *) {
            WidgetCenter.shared.reloadAllTimelines()
        }
        
        result(userDefaults.integer(forKey: "counter"))
    }
}
ちなみに

強制的にウィジェットを更新するためには、WidgetCenter.shared.reloadAllTimelines()を呼ぶだけでOKです。

WidgetKitアプリを追加して、App Groupsを設定する

まず、File>New>Targetから、Widget Extensionを選択して追加。

WidgetExtensionを追加1
WidgetExtensionを追加2

次に、追加したWidget Extensionアプリにも先ほど設定したものと同じApp Groupsを設定します。

追加したWidgetExtensionにApp Groupsを設定する

WidgetKitアプリでカウンターの値を取得して、表示

あとは、Widget Extensionでデータを取り出して表示するのみです。

まず、Runner>追加したWidget Extensionm名のフォルダ>Xxx.swiftを開きます。

ここからやることは大きく5つ。大変そうですが、それぞれ軽いので、もうちょい頑張りましょう。

やること
  • Widgetに表示するデータを表す構造体にプロパティを追加
  • Widgetが初めて表示されるときにレンダリングするためのplaceholder()を編集
  • スマホからウィジェットを追加するときに見られる「ウィジェットギャラリー」に表示するときに呼ばれるgetSnapshot()を修正
  • ウィジェット追加後、定期的に更新されるときに呼ばれるgetTimeline()を修正
  • ウィジェットに表示するデータを修正

修正後のソースを紹介します。

Widgetに表示するデータを表す構造体にプロパティを追加

struct SimpleEntry: TimelineEntry {
    let date: Date
    let counter: Int // ★追加した
    let configuration: ConfigurationIntent
}

Widgetが初めて表示されるときにレンダリングするためのplaceholder()を編集

    // WidgetKitが初めてレンダリングする時/特定のデータ保護資格の条件に合致したときに表示される
    // (ユーザデータとは関係ない、デフォルト値を設定することが多い)
    // https://developer.apple.com/documentation/widgetkit/timelineprovider/placeholder(in:)-6ypjs
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), counter: 0, configuration: ConfigurationIntent())
    }

Widgetに表示するデータを表す構造体にプロパティを追加スマホからウィジェットを追加するときに見られる「ウィジェットギャラリー」に表示するときに呼ばれるgetSnapshot()を修正

    // ウィジェットギャラリーに表示される
    func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        // AppGroupのUserDefaultからカウンターの値を取得
        let userDefaults = UserDefaults(suiteName: "group.counter.hondakenya.sample")
        let counter = userDefaults?.value(forKey: "counter") as? Int
        
        let entry = SimpleEntry(date: Date(), counter: counter ?? 0, configuration: configuration)
        completion(entry)
    }

ウィジェット追加後、定期的に更新されるときに呼ばれるgetTimeline()を修正

    // 実際のウィジェットに表示される
    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        
        // AppGroupのUserDefaultからカウンターの値を取得
        let userDefaults = UserDefaults(suiteName: "group.counter.hondakenya.sample")
        let counter = userDefaults?.integer(forKey: "counter") ?? 0
        
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, counter: counter, configuration: configuration)
            entries.append(entry)
        }

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

ウィジェットに表示するデータを修正

struct counterViewEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(String(entry.counter)) // ★ここを修正
    }
}

あとは、ビルドすれば、OKです。

全ソースコードはこちら

まとめ

これ。もし、他にいい方法があれば教えて下さい。

ウィジェットにデータを表示する流れ
  1. Flutterでshared_preferencesを使って、StandardなUserDefaultsにデータを保存
  2. FlutterがSwiftにsuiteName付きのUserDefaultsへデータを保存するよう依頼
  3. WidgetKitアプリがsuiteName付きのUserDefaultsからデータを取得して表示

参考

コメント

タイトルとURLをコピーしました