Glanceを使ってAndroidアプリのウィジェットを作る with Hilt/Room/Repository
はじめに
その昔、Glanceがまだalphaの頃に触って下記の記事を書いたものの、それ以降まともに触っていませんでした。
当時はalpha03ですが、今やGlanceはstableになっています。
最近ウィジェットを作成する機会があり、stableなGlanceを触りました。
そこで得た知見をまとめ、ウィジェットの作り方を説明します。
題材
TODOウィジェットを題材にして解説していきます。
ウィジェットの要件は以下です。
- androidx.Roomに保存してあるTodoを表示する
- 表示しきれない場合スクロール出来る
- リストのTodoをタップすると、完了としてTodoを消すことが出来る
環境
Android Studio Ladybug | 2024.2.1 Patch 2
依存関係
implementation("androidx.glance:glance-appwidget:1.1.1")
implementation("androidx.glance:glance-material3:1.1.1")
// material2を使う場合は以下
// implementation("androidx.glance:glance-material:1.1.1")
implementation("com.google.dagger:hilt-android:2.51.1")
Glanceでウィジェットを作成する
Androidのウィジェットの作成に必要なものは、主に以下の3つです。
- AppWidgetProviderInfo
- ウィジェットの設定を書くxmlファイル(meta-dataとも呼ばれる)
- GlanceAppWidget
- ウィジェット本体
- GlanceAppWidgetReceiver
- ウィジェットのブロードキャストを処理するクラス
AppWidgetProviderInfo
今回は以下のファイルを用意しました。
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="40dp"
android:previewImage="@drawable/todo_glance_image"
android:previewLayout="@layout/todo_glance_preview"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:updatePeriodMillis="18000000"
android:widgetCategory="home_screen" />
いくつか解説していきます。
initialLayout
その名の通り、初期レイアウトで、ウィジェットの準備が整うまで表示されます。
- ユーザーがウィジェットを配置したとき
- 端末再起動時
- 後述の
provideGlance
でprovideContentが呼ばれるまでの間
これはGlanceを使う場合でもComposeでは書けず、xmlのレイアウトを指定する必要があります。
しかし、Glanceのライブラリには @layout/glance_default_loading_layout
が存在しており、それを指定することで「ProgressBarだけ表示するレイアウト」を出すことが出来ます。
Googleからの「Glanceを使うユーザーにはレイアウトxmlを書かせないぞ!」という強い意志を感じます(妄想)
もし、文字を一緒に出したい・カスタムしたいといった場合には、xmlを書くことになります。
targetCellWidth/HeightとminWidth/Height
ウィジェットのデフォルトサイズを指定する属性で、OSによって使われるものが変わります。
Android12以降は targetCellWidth
と targetCellHeight
でグリッドセル単位で指定できます。
Android11以前の場合は、minWidthとminHeightで、デフォルトサイズをdp指定します。
Android12以降でも、ホーム画面によってはグリッドベースのレイアウトをサポートしていないことがあり、その場合は、minWidth/Heightが使われるので、両方定義することが推奨されます。
updatePeriodMillis
ウィジェットの更新間隔を決める値で、指定した間隔で onUpdate()
が呼ばれるようになります。
30分以上の更新間隔の場合に使えて、それ未満の場合は0(無効)にして、WorkManagerなどを使う必要があります。
updatePeriodMillis は 30 分未満の値をサポートしていません。ただし、定期的な更新を無効にするには、0 を指定します。
ユーザーが設定で更新頻度を調整できるようにします。たとえば、株価情報を 15 分おきに更新する、1 日 4 回のみ更新する、といった設定ができます。この場合は、updatePeriodMillis を 0 に設定し、代わりに WorkManager を使用します。
provideGlanceのサンプルコードには、15分おきに更新する例が記載されています。
previewLayoutとpreviewImage
Android12以降はpreviewLayout
を使ってスケーラブルなプレビューが指定可能です。
Android11以前では、previewImage
を使って画像の指定が可能です。
previewImage と previewLayout の両方の属性を指定することをおすすめします。
リファレンスには、このようにあり、両方指定してもpreviewLayout
が優先されるので、minSdkが11以前の場合は、両方定義しておくのが良さそうです。
minSdkが12以上なのであれば、previewLayout
だけで十分なのかもしれません。スケーラブルなウィジェット プレビューの下位互換性を見る限り、デバイスではなく、OSによって挙動が変わりそうだったので。(実際に試してはいません)
AppWidgetProviderInfoの他の属性や詳しい解説は、以下に記載されています。
xmlをreceiverと紐づける
AndroidManifestで、次で作成するReceiverのmeta-dataに紐づけます。
<receiver android:name=".glance.TodoWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/todo_widget_meta_data" />
</receiver>
下記に解説があります。
GlanceAppWidgetReceiver
Receiverは、単純に継承して、次に作成するウィジェットのInstanceを返すだけです。シンプル。
class TodoWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget
get() = TodoWidget()
}
GlanceAppWidget
ウィジェット本体を作っていきます。
最小構成から、TODOウィジェットを作る過程を順番に解説していきます。
最小構成
class TodoWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
Text("Todo List")
}
}
}
GlanceAppWidgetを継承し、provideGlance
をオーバーライドし、その中でprovideContent
を呼び、その中でComposeを実装します。
配置するとこんな感じになります。
Pixelのデフォルト壁紙なのですが、見えづらいですね。(何もしていないので当たり前)
Themeを適用する
Themeを適用して背景色を入れます。
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
GlanceTheme {
Box(
GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.background),
contentAlignment = Alignment.Center
) {
Text(
text = "Todo List",
style = TextStyle(fontSize = 20.sp, color = GlanceTheme.colors.onSurface)
)
}
...
GlanceThemeで囲うことで、簡単にThemeを利用することが出来ます。
今回はmaterial3を利用していますが、依存を変えれば2を使うことも出来ます。
配置するとこんな感じ。見やすくなりました。
GlanceThemeについては、下記で詳しく説明されています。
依存性を注入する
データ取得のためのRepositoryを注入します。
GlanceAppWidgetはHiltでサポートされていないので、 @AndroidEntryPoint
は使えません。
Hiltには@EntryPoint
が用意されており、それを使うことで、サポート外のクラスでもHiltのモジュールを利用した依存性の注入が行えます。
class TodoWidget : GlanceAppWidget() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface TodoWidgetEntryPoint {
fun todoRepository(): TodoRepository
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
val hiltEntryPoint =
EntryPointAccessors.fromApplication<TodoWidgetEntryPoint>(context.applicationContext)
val repository = hiltEntryPoint.todoRepository()
...
@EntryPoint
を付けたinterfaceを用意し、それをEntryPointAccessors
で使うことでEntryPointを生成出来ます。
詳しくはこちら。
データを読み取る
続いて、データの読み取りです。
今回は、Roomとデータのやり取りをするRepositoryを想定します。
override suspend fun provideGlance(context: Context, id: GlanceId) {
// 略(EntryPointの処理)
val todoListFlow: Flow<List<Todo>> = repository.getTodoList()
val initial = todoListFlow.first()
provideContent {
val todoList by todoListFlow.collectAsState(initial)
GlanceTheme {
...
provideGlanceで、時間のかかる処理を実行することが出来ます。
以下がポイントです。
- CoroutineWorkerとしてバックグラウンドで実行され、provideContentを呼ぶ前だと、10分以内のタスクを実行できる
- provideContentを呼び出したあとは、45秒間の実行時間があり、その間はrecompose可能
- provideGlanceが実行されてから、provideContentでUIが提供されるまで時間がかかる場合、initial_layoutが表示される
- provideContent内で、collectAsStateでデータソースを監視する
- ウィジェットの更新メソッドである、
update
およびupdateAll
は、provideGlanceがすでに実行中の場合にそれを再起動しない。そのため、composition内でデータを監視し、再起動されない場合でもデータが更新されるようにする必要がある
- ウィジェットの更新メソッドである、
provideGlanceのリファレンスに詳しく書かれています。
今回のサンプルはRoomですが、ApiやDataStoreを利用することも出来ます。
エラーハンドリングを行う例がリファレンスに載っています。
provideGlanceによって、時間の制限(特定タイミングとか、30分未満とか)に引っかからなければ、WorkManagerを使うことなく、データを読み込み、表示するウィジェットを作ることが出来ます。(だいぶ便利になりました)
リストを表示する
データが読み取れるようになったので、続いてリスト表示です。
GlanceTheme {
Box(
GlanceModifier.fillMaxSize().background(GlanceTheme.colors.background),
contentAlignment = Alignment.Center
) {
LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) {
item {
Text(
text = "Todo List",
style = TextStyle(fontSize = 20.sp, color = GlanceTheme.colors.onSurface)
)
}
items(todoList) {
Text(
text = it.name,
style = TextStyle(fontSize = 16.sp, color = GlanceTheme.colors.onSurface)
)
...
LazyColumnを用いて、Composeお馴染みの形で簡単に実装することが出来ます。
厳密にはCompose = Glanceではなく、Glanceで使えるComposableは、androidx.glanceパッケージにあるものだけなので、注意が必要です。
クリックを処理する
クリック処理は、Glance特有のものになっています。
以下は、下記2つを実装したサンプルです。
- 「Todo List」というタイトルを押したら、MainActivityを起動する
- Todoを押したら、repository経由でTodoを消す
provideContent {
...
val scope = rememberCoroutineScope()
GlanceTheme {
...
item {
Text(
modifier = GlanceModifier.clickable(actionStartActivity<MainActivity>()),
text = "Todo List",
style = TextStyle(fontSize = 20.sp, color = GlanceTheme.colors.onSurface)
)
}
items(todoList) {
Text(
modifier = GlanceModifier.clickable { scope.launch { repository.deleteTodo(it) } },
text = it.name,
style = TextStyle(fontSize = 16.sp, color = GlanceTheme.colors.onSurface)
)
...
GlanceModifier.clickableを用いてクリックを処理します。
アクティビティの起動は、actionStartActivity<>()
だけで実現できます。
何か引数を渡したい場合は、引数にActionParametersを渡すことによって、Activity側でintentのExtrasからデータが受け取れるようになります。
repository(suspend fun)に対して何かしたい場合は、rememberCoroutineScope
で作成したscopeを使って、ラムダ内で処理を完結させることが出来ます。
また、ActionCallback
を用いて、suspendな処理を実行させることも出来ます。
RemoteView時代のウィジェットのように、BroadcastReceiverのonReceiveを経由して何かする必要がなくなり、とても体験が良くなっています。
完成
これで、要件を満たすウィジェットを作成できました!
おわりに
今回は、Glanceを使ったアプリウィジェットの作り方を、データの読み取りやHiltの使い方も交えて説明しました。
UIがComposeで書けるようになったことだけでなく、provideGlanceを用いたデータ読み取りや、クリック処理の簡略化など、本当に楽にウィジェットが作れるようになったと感じました。
みなさまも、気軽にGlanceしていきましょう!
参考
参考にした公式ページあれこれ
Discussion