Flutterアプリのデータをホーム画面ウィジェットに出したいとき、多くの記事はhome_widgetパッケージを勧めます。ただ「ウィジェットの中身が動く」ものを作ろうとすると、パッケージ任せでは限界が来ます

この記事では、ドット絵ペットがホーム画面のウィジェットの中で歩き回るアプリを作ったときに使った構成を、実際のコードに沿ってまとめます。ポイントは3つ——App Groupでのデータ共有MethodChannelでの更新通知、そしてタイムラインを使って「動かす」ときの設計です。

とくに3つ目は情報が少なく、素直に実装すると必ず破綻します。コマ番号でアニメーションさせてはいけないという話が、この記事のいちばん伝えたいところです。

全体の構成

結論:Flutter側は「データを渡して再読込を頼む」だけ。描画はすべてSwift(WidgetKit)で行います。

▼データの流れ

① Flutter が MethodChannel でネイティブへデータを渡す

② AppDelegate が App Group の UserDefaults に JSON で保存する

WidgetCenter.shared.reloadAllTimelines() でウィジェットに再読込を促す

④ Widget Extension が App Group から読み出して描画する

アプリ本体とWidget Extensionは別プロセスです。メモリを共有できないので、間に「App Group」という共有領域を挟みます。ここを理解しておくと、以降の設計判断がすべて素直に納得できます。

Widget Extensionの入り口

WidgetBundleに、ホーム画面ウィジェットとLive Activity(ダイナミックアイランド)を両方登録しています。

import SwiftUI
import WidgetKit

@main
struct MyWidgetBundle: WidgetBundle {
  var body: some Widget {
    MyLiveActivity()
    MyHomeWidget()
  }
}

1つのExtensionに複数のWidgetを同居させられます。ターゲットを分ける必要はありません。

App Groupでデータを共有する

結論:アプリとExtensionの両方に同じApp Group IDを設定し、UserDefaults(suiteName:)で読み書きします。

Xcodeで、アプリ本体とWidget Extensionの両方のターゲットに「App Groups」のCapabilityを追加し、同じ識別子を登録します。片方だけだと、書いても読めません。

// アプリ側(AppDelegate.swift)
let appGroupId = "group.com.example.myapp"

// ウィジェット側(Widget Extension)
private enum WidgetShared {
  static let appGroupId = "group.com.example.myapp"
  static let itemsKey = "items"
}

この文字列がずれているのが、動かないときの原因で最も多いパターンです。定数として片方に書いてコピーするのではなく、両側にコメントで「相手側と一致させること」と明記しておくのを勧めます。

読み出し側は必ず失敗を想定する

UserDefaults(suiteName:)App Groupが未構成だとnilを返します。Extension側でここを握りつぶすと、原因不明の空表示になります。

guard let defaults = UserDefaults(suiteName: WidgetShared.appGroupId),
      let data = defaults.data(forKey: WidgetShared.itemsKey)
else { return [] }

アプリ側も同様に、App Groupが取れないときはfalseを返してアプリ自体は通常動作させる——という作りにしてあります。ウィジェットが使えないだけでアプリが落ちるのは、割に合いません。

MethodChannelで更新を通知する

結論:Flutterからデータを渡したら、その場でreloadAllTimelines()を呼びます。呼ばないと、ウィジェットはOSの気まぐれなタイミングまで更新されません。

private func registerWidgetChannel(messenger: FlutterBinaryMessenger) {
  let channel = FlutterMethodChannel(
    name: "com.example.myapp/widget", binaryMessenger: messenger)
  channel.setMethodCallHandler { call, result in
    switch call.method {
    case "updateItems":
      guard let defaults = UserDefaults(suiteName: appGroupId) else {
        result(false)  // App Group 未構成
        return
      }
      let args = call.arguments as? [String: Any] ?? [:]
      let items = args["items"] as? [[String: Any]] ?? []
      if let data = try? JSONSerialization.data(withJSONObject: items) {
        defaults.set(data, forKey: itemsKey)
      }
      if #available(iOS 14.0, *) {
        WidgetCenter.shared.reloadAllTimelines()
      }
      result(true)
    // ...
    }
  }
}

Dart側はMethodChannel('com.example.myapp/widget')を持つサービスクラスにまとめておきます。

ウィジェットが設置されているかを調べる

ホーム画面にウィジェットが1つも置かれていないなら、更新処理を走らせる意味がありません。WidgetCenter.shared.getCurrentConfigurationsで設置状況を取れます。

ここで1つ注意点があります。このAPIの完了ハンドラは、メインスレッドで呼ばれる保証がありません。他のケースが同期的にresultを返しているなら、揃えて明示的にメインスレッドへ戻してから返すべきです。

WidgetCenter.shared.getCurrentConfigurations { configResult in
  DispatchQueue.main.async {
    // ここで result(...) を呼ぶ
  }
}

Flutterのプラットフォームチャネルは、結果をメインスレッドから返すのが前提です。ここを外すと、再現しづらいクラッシュの原因になります。

ウィジェットを「動かす」ときの設計

結論:アニメーションの見た目は、コマ番号ではなくTimelineEntrydateから直接計算してください。これが最大の落とし穴です。

WidgetKitのウィジェットは、常時描画されているわけではありません。あらかじめ「この時刻にはこの見た目」というエントリの配列(タイムライン)を渡しておき、OSが適当なタイミングで切り替えるという仕組みです。

やってはいけない実装

素直に考えると、こう書きたくなります。

// ❌ コマ番号でアニメーションさせる
for i in 0..<300 {
  entries.append(Entry(date: now + i * 5, frame: i))
}
// 描画側で frame % 4 の値からポーズを決める……

これは破綻します。OSがタイムラインをどのペースで消化するかは、こちらから制御できないからです。省電力状態や表示状況によって、エントリが飛ばされたり、想定より遅く進んだりします。結果として「コマ番号の順番どおりに動く」という前提が崩れ、カクついたり止まって見えたりします。

正しい実装

そのエントリ自身のdateから、見た目を決定論的に計算します。

func getTimeline(in context: Context,
                 completion: @escaping (Timeline<MyEntry>) -> Void) {
  let items = loadItems()
  let now = Date()
  let step: TimeInterval = 5   // 5秒ごとに1エントリ
  let entryCount = 300         // ≒25分ぶん

  var entries: [MyEntry] = []
  entries.reserveCapacity(entryCount)
  for i in 0..<entryCount {
    let date = now.addingTimeInterval(Double(i) * step)
    entries.append(MyEntry(date: date, items: items,
                           isNight: isNight(at: date)))
  }
  let refresh = now.addingTimeInterval(Double(entryCount) * step)
  completion(Timeline(entries: entries, policy: .after(refresh)))
}

そして描画側では、entry.dateを時刻として受け取り、そこから位置や姿勢を計算します

▼この方式の利点

▶ OSがどのエントリをいつ表示しても、その瞬間に正しい位置になる

▶ エントリが飛ばされても、動きが破綻しない

▶ 再読込のタイミングに依存しないので、タイムラインの継ぎ目が目立たない

乱数を使わない

もうひとつ重要なのが、描画に乱数を使わないことです。同じエントリが再描画されるたびに見た目が変わってしまうと、ちらつきます。

このアプリでは当初、「ジャンプ×笑顔」の離散的な4ポーズを切り替える方式にしていました。しかし4通りしかないため、5〜6体を表示すると必ず動きが重複し、「全員が同じ動きをしている」ように見えてしまいます

そこで、キャラクターごとに位相と速度の違う連続パラメータに変えました。三角関数の位相を個体インデックスからずらすだけで、何体いても全員バラバラに動きます。乱数を使わずに多様性を出す、という発想です。

エントリ数と間隔の決め方

5秒×300エントリで約25分。この数字は省電力とのバランスで決めています。

エントリを細かくすれば滑らかになりますが、1回のタイムラインに詰め込める量には実質的な上限がありますし、更新予算も消費します。「滑らかさ」と「再読込の頻度」はトレードオフなので、アプリの性質に合わせて決めることになります。

Live Activityとの違い

結論:Live ActivityはActivityKitの制約で、渡せる状態が約4KBまでです。画像は載せられません。

同じExtensionに同居していても、ホーム画面ウィジェットとLive Activityは制約が違います。

ホーム画面ウィジェット Live Activity
データの渡し方 App Group(UserDefaults) ActivityKit の ContentState
状態のサイズ 実質的な制限は緩い 約4KBまで
画像 Assets から読める 載せられない(アセット参照で対応)
更新 reloadAllTimelines() Activity の update()

Live Activityに渡すのは「状態を表す短い値」だけにしてください。渡すのは名前と段階を表すキー、それに数値をいくつかまで。絵は段階のキーからウィジェット側でアセットを引く形にします。

/// MethodChannel 引数。画像は載せない(ActivityKit の 4KB 制約)。
Map<String, dynamic> toArgs() => {
      'name': name,
      'stage': stage,
      'valueA': valueA,
      'valueB': valueB,
      'valueC': valueC,
      // ...
    };

実装でつまずいた点

▼実際に起きた不具合

ウィジェットだけ設定が反映されない:アプリの「おやすみ時間」を変えてもウィジェットが固定時刻で寝てしまう。設定値をApp Groupに送っていなかったのが原因

全員が同じ動きに見える:離散ポーズが4通りしかなく重複していた。連続パラメータに変更して解決

左右反転が効かない:スプライトを中心線で対称に生成していたため、反転しても見た目が変わらなかった

①はウィジェット開発でいちばんありがちな種類の不具合です。アプリ側で設定を変えても、App Groupに書き出していなければウィジェットには何も伝わりません。「ウィジェットに見せたい状態は、すべて明示的に送る」——別プロセスであることを忘れると、ここを落とします。

③は少し特殊ですが、教訓としては「描画の前提(スプライトが左右対称かどうか)を、絵の生成側と描画側で共有しておく」ということです。このアプリではスプライトをPythonスクリプトで生成していて、左半分を鏡像化する作りになっていました。

home_widgetパッケージを使わなかった理由

結論:ウィジェットの中で継続的にアニメーションさせる要件があったからです。単に値を表示するだけなら、パッケージで十分です。

home_widgetは、App Groupへの書き込みとreloadAllTimelines()の呼び出しをラップしてくれる便利なパッケージです。「今日の歩数」「残りタスク数」といった値を出すだけなら、自前で書く理由はありません。

一方、今回のようにタイムラインの組み立て方そのものを設計する必要がある場合は、結局TimelineProviderをSwiftで書くことになります。それならデータの受け渡しも素で書いたほうが、依存が減って見通しがよくなる——という判断でした。

▼選び分けの目安

値を表示するだけ → home_widget で十分

ウィジェットが動く/時刻で表示が変わる → TimelineProvider を自分で書く

Live Activityも使う → どのみちSwiftを書くので、まとめて素で書くほうが楽

関連する記事

ピクレット – Pixel Islet…この記事の実装を使っているアプリ。App Storeで配信中(無料・アプリ内課金なし)

Rin Blog Apps…公開しているアプリの一覧

 

まとめ

FlutterアプリのiOSウィジェットをSwiftで書くときの要点は、次のとおりです。

▼要点

① アプリとExtensionは別プロセス。App Groupを挟んでデータを共有する

② データを渡したらreloadAllTimelines()を明示的に呼ぶ

③ 動くウィジェットは、コマ番号ではなくentry.dateから見た目を計算する

④ 描画に乱数を使わない。多様性は位相と速度のずらしで作る

⑤ Live Activityの状態は約4KBまで。画像は載せずアセット参照にする

いちばん時間を取られたのは③でした。「タイムラインは自分のペースで消化される」という前提を捨てられるかどうかが、動くウィジェットを作れるかの分かれ目だと思います。コマ番号ではなく時刻を渡す——文にすると一行ですが、ここに気づくまでに設計を一度作り直しました。

※iOS 14以降のWidgetKitを対象にした内容です。実装は執筆時点のものです。

ABOUT ME
りん
このブログでは、Web開発やプログラミングに関する情報を中心に、私が日々感じたことや学んだことをシェアしています。技術と生活の両方を楽しめるブログを目指して、日常で触れた出来事や本、グルメの話題も取り入れています。気軽に覗いて、少しでも役立つ情報や楽しいひとときを見つけてもらえたら嬉しいです。