📌

Flutter × iOS WidgetKit × Android AppWidget

2021/12/02に公開約20,700字

Flutter と Widget を使ったアプリケーションを作る機会があったのでその備忘録として残します。今回 Flutter から使っていますが、現状は Widget はネイティブコードで書く必要があり、ほとんどは通常の Widget の説明になります。

作ったもの

https://github.com/akihokurino/flutter-calendar-widget




Google カレンダーの Widget 風なものを作成しました。
Flutter と Widget それぞれから Firestore にアクセスし、スケジュールのデータを共有します。

iOS WidgetKit について

iOS14 辺りからでてきたもので、スマホのホーム画面にアプリケーションを展開して動かすことが可能です。

ただし、

  • swiftui で記述する必要がある
  • ObservedObject 等が使えない
  • 利用できるメモリに厳しめの制約がある
  • 更新間隔をある程度設定できるがシステムのブラックボックスな部分がある

など注意も必要です。

Flutter × iOS WidgetKit

しばらくは Flutter 関係なく進めます。

新規のターゲットを作る形で WidgetKit を作成します。

Firebase を Widget からも使っていくので、Pod を Embed しておきます。(Flutter が Pod を利用するため、Widget で使うライブラリも Pod 経由にしています)

Podfile 側にも修正が必要で、新たに作ったターゲットでも Pod でインストールする必要があります。
アプリ側の Pod と同じバージョンを利用したいが、どうすればいいかわかりませんでした。

~~省略~~

target 'Runner' do
  use_frameworks!
  use_modular_headers!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

target 'WidgetExtension' do
  use_frameworks!

  pod 'Firebase/Auth'
  pod 'Firebase/Firestore'
end

~~省略~~

Capability で AppGroup を設定しておき、ローカルデータも共有できるようにしておきます。

AppGroups の設定は Apple Developer Program のほうでも設定する必要があるため、注意が必要です。アプリ自体の Identifer の AppGroups と Widget 用の Identifer の AppGroups をそれぞれオンにしておきます。

AppGroups 用の Identifer も生成されます

今回は Firestore をデータの永続化層に使いますが、FirebaseAuth の認証状態をアプリと Widget 側で共有する場合、Keychain Sharing というものを使う必要があります。特に難しいものではなく、任意の文字列を登録していく形になります。ここは Apple Developer Program 側の設定は不要で適当な文字列を設定できます。

上記までで設定に関しては完了であとは SwiftUI で Widget のロジックを実装していきます。
WidgetKit では Small, Medium, Large の 3 種類の大きさの Widget を設定できるのでそれぞれを下記の形で定義することでクラスを分けています。
ポイントはここで Firebase の設定を完了させ、先に設定した Keychain を使って FirebaseAuth の設定を行います。これによってアプリ側で認証した状態を Widget 側でも使うことが可能になります。

~ios/Widget/WidgetBundle.swift

import FirebaseAuth
import FirebaseCore
import SwiftUI
import WidgetKit

@main
struct Widgets: WidgetBundle {
    init() {
        // Keychain Sharingの設定を行う
        FirebaseApp.configure()
        try? Auth.auth().useUserAccessGroup("\(Env["IOS_TEAM_ID"]!).app.akiho.calendar.keychain")
    }

    // 各サイズごとにWidgetを用意する
    var body: some Widget {
        SmallWidget()
        MediumWidget()
        LargeWidget()
    }
}

Widget はタイムラインという設定に従って更新がかかっていくようになっており、その部分の実装が必要になります。大枠はターゲット作成時に作られていくのでそこから自分用にカスタマイズしていく形になります。

サンプルなので雑な箇所がありますが、タイムラインを作る時に、先程共有した Firebase の認証情報を使って Firestore からスケジュールデータを取得して、その結果を SimpleEntry という形で Widget に渡していきます。

AppGroups 経由で共有されたデータもここで取得して Widget に渡すようにします。

~ios/Widget/Provider.swift

import FirebaseAuth
import FirebaseFirestore
import Intents
import SwiftUI
import WidgetKit

struct Schedule: Identifiable {
    let id: String
    let name: String
}

struct Provider: IntentTimelineProvider {
    typealias Entry = SimpleEntry

    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(
            date: Date(),
            dayOfWeek: "日曜日",
            year: "2021",
            month: "01",
            day: "01",
            selectedYMD: "2021-01-01",
            focusedYMD: "2021-01-01",
            schedules: [],
            configuration: ConfigurationIntent()
        )
    }

    func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(
            date: Date(),
            dayOfWeek: "日曜日",
            year: "2021",
            month: "01",
            day: "01",
            selectedYMD: "2021-01-01",
            focusedYMD: "2021-01-01",
            schedules: [],
            configuration: configuration
        )
        completion(entry)
    }

    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        let date = Date()
        let calendar = Calendar.current
        let year = calendar.component(.year, from: date)
        let month = String(format: "%02d", calendar.component(.month, from: date))
        let day = String(format: "%02d", calendar.component(.day, from: date))

        let dayOfWeek: String
        switch Calendar.current.dateComponents([.weekday], from: date).weekday {
        case 1:
            dayOfWeek = "日曜日"
        case 2:
            dayOfWeek = "月曜日"
        case 3:
            dayOfWeek = "火曜日"
        case 4:
            dayOfWeek = "水曜日"
        case 5:
            dayOfWeek = "木曜日"
        case 6:
            dayOfWeek = "金曜日"
        case 7:
            dayOfWeek = "土曜日"
        default:
            dayOfWeek = ""
        }

        // AppGroupsからデータを取得する
        let userDefaults = UserDefaults(suiteName: "group.app.akiho.calendar")
        let selectedYMD = userDefaults?.string(forKey: "selectedDate") ?? ""
        let focusedYMD = userDefaults?.string(forKey: "focusedDate") ?? ""

        // アプリ側で認証した状態を使う
        let loginId = Auth.auth().currentUser!.uid

        // Firestoreからデータを取得する
        let db = Firestore.firestore()
        db.collection("schedule/\(loginId)/\(year)-\(month)")
            .whereField("dateYMD", isEqualTo: "\(year)-\(month)-\(day)")
            .order(by: "createdAtTimestamp")
            .limit(to: 4)
            .getDocuments { collection, err in
                if err != nil {
                    let timeline = Timeline(entries: [SimpleEntry(
                        date: date,
                        dayOfWeek: dayOfWeek,
                        year: "\(year)",
                        month: month,
                        day: day,
                        selectedYMD: selectedYMD,
                        focusedYMD: focusedYMD,
                        schedules: [],
                        configuration: configuration
                    )], policy: .atEnd)
                    completion(timeline)
                    return
                }

                let schedules: [Schedule] = collection!.documents.map {
                    Schedule(id: $0.get("id") as! String, name: $0.get("name") as! String)
                }

                var entries: [SimpleEntry] = []
                // 1時間に1度更新が走るようにする
                for hourOffset in 0 ..< 24 {
                    let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: date)!
                    let entry = SimpleEntry(
                        date: entryDate,
                        dayOfWeek: dayOfWeek,
                        year: "\(year)",
                        month: month,
                        day: day,
                        selectedYMD: selectedYMD,
                        focusedYMD: focusedYMD,
                        schedules: schedules,
                        configuration: configuration
                    )
                    entries.append(entry)
                }

                // policy = .atEndでタイムラインが完了したら最初からやり直す
                let timeline = Timeline(entries: entries, policy: .atEnd)
                completion(timeline)
            }
    }
}

// Widgetに渡すデータ構造
struct SimpleEntry: TimelineEntry {
    let date: Date
    let dayOfWeek: String
    let year: String
    let month: String
    let day: String
    let selectedYMD: String
    let focusedYMD: String
    let schedules: [Schedule]
    let configuration: ConfigurationIntent
}

あとは、ユーザーが設定したサイズに合わせて Widget の View を実装すれば大体完了です。ここでは一番小さいスモールサイズのみ載せています。
単純に VStack で縦に並べつつ、簡単にスケジュールを表示しています。
ポイントは Widget に指定してる Kind です。これをこの後 Flutter から使っていくことになります。

~ios/Widget/SmallWidget.swift

import Intents
import SwiftUI
import WidgetKit

struct SmallEntryView: View {
    // ここにTimelineから渡ってくるデータが入っている
    var entry: Provider.Entry

    var body: some View {
        VStack(alignment: .leading) {
            Text(entry.dayOfWeek).font(.system(size: 12))
                .foregroundColor(Color.white.opacity(0.8))
            Spacer().frame(height: 5)
            Text(entry.day).font(.system(size: 20)).fontWeight(.bold)
                .foregroundColor(Color.white)

            Spacer()
            if entry.schedules.count > 0 {
                Text(entry.schedules[0].name)
                    .lineLimit(1)
                    .font(.system(size: 14))
                    .padding(.horizontal, 5)
                    .frame(height: 35)
                    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .background(Color.blue.opacity(0.8))
                    .cornerRadius(8)
            }
            if entry.schedules.count > 1 {
                Text(entry.schedules[1].name)
                    .lineLimit(1)
                    .font(.system(size: 14))
                    .padding(.horizontal, 5)
                    .frame(height: 35)
                    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .background(Color.blue.opacity(0.8))
                    .cornerRadius(8)
            }
        }
        .frame(
            minWidth: 0,
            maxWidth: .infinity,
            minHeight: 0,
            maxHeight: .infinity,
            alignment: .topLeading
        )
        .padding(10)
        .background(Color(red: 41.0 / 255.0, green: 42.0 / 255.0, blue: 47.0 / 255.0, opacity: 1.0))
    }
}

struct SmallWidget: Widget {
    // Flutter側で参照する文字列になる
    let kind: String = "SmallWidget"

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            SmallEntryView(entry: entry)
        }
        .configurationDisplayName("Small")
        .description("widget for calendar schedules")
        .supportedFamilies([.systemSmall])
    }
}

さて、ここまでで大体 iOS 側の設定や実装が完了したと思います。

今回はやりませんが、ConfigurationIntent というものを使ってさらにユーザーごとに Widget の設定をカスタマイズすることが可能です。Widget を長押しすることで設定できるアレです。

(例えば、スケジュールを何個表示するかなど)

それがやりたい場合、Xcode 上で ConfigrationIntent を設定し、getTimeline に渡ってくる ConfigurationIntent から設定値を取得する形になります。

この記事では Flutter から扱うというところに主眼があるため、Flutter 側の設定を見ていきましょう。

Flutter 側は

https://pub.dev/packages/home_widget/example

このライブラリを使わせてもらいます。通常だとアプリ側から

WidgetCenter.shared.reloadAllTimelines()

を使って任意のタイミングで Widget を更新したり、Swift で直接 AppGroups のデータ領域に共有データを保存したりなどをするのですが、これをやってくれているライブラリになります。

たまに Flutter は〇〇に対応していないから導入できないなどの声を聞きますが、ベースは Flutter で作り、対応されていないところはネイティブコードを直接書いて、MethodChannel で使えばいいじゃんと思っているので、こういうライブラリはいいなと思います。それ以上に両 OS をワンソースで書ける恩恵がでかいと個人的に思っています。

さて、実際に Flutter のコードを見ていきます。とはいえ、とても簡単です。

Widget 側でやったのと同じように、設定した Keychain を FirebaseAuth の設定に使います。これによってアプリ側で認証したものを Widget 側で使えます。

さらに、 HomeWidget.setAppGroupId(appGroupID); のコードで、AppGroups の ID を HomeWidget に設定しておきます。こうすることで、AppGroups に Flutter 側からアクセスできます。

import 'package:calendar/ui/calendar.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/date_symbol_data_local.dart';

const appGroupID = "group.app.akiho.calendar";

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  initializeDateFormatting("ja_JP");
  await dotenv.load(fileName: '.env');
  await Firebase.initializeApp();

  // Keychain Sharingの設定を行う
  await FirebaseAuth.instance.setSettings(userAccessGroup: "${dotenv.env["IOS_TEAM_ID"]}.app.akiho.calendar.keychain");
  await FirebaseAuth.instance.signInAnonymously();

  // AppGroupsの設定を行う
  HomeWidget.setAppGroupId(appGroupID);

  final app = MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData.dark(),
    home: CalendarPage.init(),
    builder: (context, child) {
      return MediaQuery(
        child: child,
        data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
      );
    },
  );

  runApp(
    ProviderScope(
      child: app,
    ),
  );
}

あとは必要なところで Widget を更新する命令をだします。ここで先ほどポイントだといった Widget の Kind 名を iOSName にいれる必要があります。

HomeWidget.updateWidget(
  name: "SmallWidget",
  androidName: "WidgetProvider",
  iOSName: "SmallWidget",
);

これで Flutter から Widget を使うことができるようになりました。

話がそれますがサンプルのレポジトリは riverpod + hooks を状態管理フレームワークとして利用しています。最近いろんなフレームワークが出ててどれ使えばいいの?という状態になっていますが、個人的にこれがデファクトになるといいなーと思っています。

Android AppWidget について

結構前から実はあったようですが、Android 向けの Widget です。

今回流れで Android もやりましたが、あまりわかっていません。

Flutter × Android AppWidget

iOS の時と違って、全部コードで完結できます。こちらも Flutter では実装できないため、Kotlin で実装していくことになります。基本的には公式の通りにやればすんなり終わります。

まずは AndroidManifest.xml に AppWidgetProvider を設定します。

~android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="app.akiho.calendar">
   <application
        android:label="calendar"
        android:icon="@mipmap/ic_launcher">
        ~~省略~~

        <receiver android:name="WidgetProvider" >
           <intent-filter>
               <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
           </intent-filter>
           <meta-data android:name="android.appwidget.provider"
               android:resource="@xml/widget_info" />
        </receiver>
    </application>
</manifest>

AppWidgetProviderInfo のメタデータを設定します。ここで、Widget の最小サイズや更新頻度、レイアウトの設定を行います。

~android/app/src/main/res/xml/widget_info.xml

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:minWidth="150dp"
    android:minHeight="150dp"
    android:updatePeriodMillis="3600000"
    android:initialLayout="@layout/widget"
    android:resizeMode="horizontal|vertical"
    android:widgetCategory="home_screen">
</appwidget-provider>

Widget のレイアウトを定義します。AppWidget では RemoteViews を使っており、その制約の中でレイアウトを作っていく必要があります。ですが、基本的なレイアウトはサポートされているようです。

~android/app/src/main/res/layout/widget.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="8dp"
    android:orientation="vertical"
    android:background="#292a2f"
    android:padding="20dp"
    android:id="@+id/widget_container">

    <TextView
        android:id="@+id/widget_week"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="14sp"
        android:textStyle="bold"
        android:textColor="#fff"
        tools:text="月曜日" />

    <TextView
        android:id="@+id/widget_day"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="5dp"
        android:textSize="25sp"
        android:textColor="#fff"
        tools:text="01" />

    <LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/schedules"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginTop="20dp"
        android:orientation="vertical">
        <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1">
        </FrameLayout>
        <TextView
            android:id="@+id/schedule_1"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:textSize="14sp"
            android:textColor="#fff"
            android:background="@drawable/schedule_background"
            android:paddingHorizontal="15dp"
            android:paddingVertical="10dp"
            tools:text="スケジュール" />
        <TextView
            android:id="@+id/schedule_2"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:textSize="14sp"
            android:textColor="#fff"
            android:background="@drawable/schedule_background"
            android:paddingHorizontal="15dp"
            android:paddingVertical="10dp"
            tools:text="スケジュール" />
    </LinearLayout>
</LinearLayout>

最後に AppWidgetProvider を作成します。iOS のタイムライン生成でやっていた時と同じようなことをここで行います。iOS と違って、Keychain Sharing などを使わずにストレートに認証を共有したりなどが可能です。

~android/app/src/main/kotlin/app/akiho/calendar/WidgetProvider.kt

package app.akiho.calendar

import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.SharedPreferences
import android.widget.RemoteViews
import es.antonborri.home_widget.HomeWidgetLaunchIntent
import es.antonborri.home_widget.HomeWidgetProvider
import java.util.Calendar
import com.google.firebase.auth.ktx.auth
import com.google.firebase.ktx.Firebase
import com.google.firebase.firestore.ktx.firestore
import android.util.Log
import android.view.View

class Schedule(val name: String)

class WidgetProvider : HomeWidgetProvider() {
    override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, widgetData: SharedPreferences) {
        val calendar = Calendar.getInstance()
        val weekName = arrayOf("日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日")
        val week: String = weekName[calendar.get(Calendar.DAY_OF_WEEK) - 1]
        val year: String = calendar.get(Calendar.YEAR).toString()
        val month: String = (calendar.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
        val day: String = calendar.get(Calendar.DATE).toString().padStart(2, '0')

        val user = Firebase.auth.currentUser
        Firebase.firestore.collection("schedule/${user!!.uid}/${year}-${month}")
            .whereEqualTo("dateYMD", "${year}-${month}-${day}")
            .orderBy("createdAtTimestamp")
            .limit(2)
            .get()
            .addOnSuccessListener { result ->
                val schedules: List<Schedule> = result.documents.map { Schedule(name = it.data!!["name"] as String) }

                appWidgetIds.forEach { widgetId ->
                    val views = RemoteViews(context.packageName, R.layout.widget).apply {
                        val pendingIntent = HomeWidgetLaunchIntent.getActivity(context, MainActivity::class.java)
                        setOnClickPendingIntent(R.id.widget_container, pendingIntent)

                        setTextViewText(R.id.widget_week, week)
                        setTextViewText(R.id.widget_day, day)

                        setViewVisibility(R.id.schedule_1, View.INVISIBLE)
                        setViewVisibility(R.id.schedule_2, View.INVISIBLE)
                        if (schedules.isNotEmpty()) {
                            setViewVisibility(R.id.schedule_1, View.VISIBLE)
                            setTextViewText(R.id.schedule_1, schedules.first().name)
                        }
                        if (schedules.size > 1) {
                            setViewVisibility(R.id.schedule_2, View.VISIBLE)
                            setTextViewText(R.id.schedule_2, schedules[1].name)
                        }
                    }

                    appWidgetManager.updateAppWidget(widgetId, views)
                }
            }
            .addOnFailureListener { exception ->
                Log.w("Log", "Error getting documents.", exception)
            }
    }
}

これまでの内容は

https://developer.android.com/guide/topics/appwidgets?hl=ja

ここを見れば全部書いてあります。

Flutter 側は iOS の時と同じように書くことで Widget とのやりとりが可能になります。今回は WidgetProvider という名前のクラス名で作っているので同じ名前を androidName のに設定します。

HomeWidget.updateWidget(
  name: "SmallWidget",
  androidName: "WidgetProvider",
  iOSName: "SmallWidget",
);

以上で Flutter からそれぞれの OS で Widget を利用することができるようになりました。

参考

Discussion

ログインするとコメントできます