Flutter × iOS WidgetKit × Android AppWidget
Flutter と Widget を使ったアプリケーションを作る機会があったのでその備忘録として残します。今回 Flutter から使っていますが、現状は Widget はネイティブコードで書く必要があり、ほとんどは通常の 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 側は
このライブラリを使わせてもらいます。通常だとアプリ側から
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)
}
}
}
これまでの内容は
ここを見れば全部書いてあります。
Flutter 側は iOS の時と同じように書くことで Widget とのやりとりが可能になります。今回は WidgetProvider という名前のクラス名で作っているので同じ名前を androidName のに設定します。
HomeWidget.updateWidget(
name: "SmallWidget",
androidName: "WidgetProvider",
iOSName: "SmallWidget",
);
以上で Flutter からそれぞれの OS で Widget を利用することができるようになりました。
Discussion