📱

Glanceを使ってAndroidアプリのウィジェットを作る with Hilt/Room/Repository

2024/12/23に公開

はじめに

その昔、Glanceがまだalphaの頃に触って下記の記事を書いたものの、それ以降まともに触っていませんでした。

https://qiita.com/morayl/items/cef29e81d3a3c079d6c4

当時は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

今回は以下のファイルを用意しました。

todo_widget_meta_data.xml
<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以降は targetCellWidthtargetCellHeight でグリッドセル単位で指定できます。
Android11以前の場合は、minWidthとminHeightで、デフォルトサイズをdp指定します。
Android12以降でも、ホーム画面によってはグリッドベースのレイアウトをサポートしていないことがあり、その場合は、minWidth/Heightが使われるので、両方定義することが推奨されます。

updatePeriodMillis

ウィジェットの更新間隔を決める値で、指定した間隔で onUpdate() が呼ばれるようになります。
30分以上の更新間隔の場合に使えて、それ未満の場合は0(無効)にして、WorkManagerなどを使う必要があります。

https://developer.android.com/develop/ui/views/appwidgets/advanced?hl=ja#periodic

updatePeriodMillis は 30 分未満の値をサポートしていません。ただし、定期的な更新を無効にするには、0 を指定します。
ユーザーが設定で更新頻度を調整できるようにします。たとえば、株価情報を 15 分おきに更新する、1 日 4 回のみ更新する、といった設定ができます。この場合は、updatePeriodMillis を 0 に設定し、代わりに WorkManager を使用します。

provideGlanceのサンプルコードには、15分おきに更新する例が記載されています。

https://developer.android.com/reference/kotlin/androidx/glance/appwidget/GlanceAppWidget#provideGlance(android.content.Context,androidx.glance.GlanceId)

previewLayoutとpreviewImage

Android12以降はpreviewLayoutを使ってスケーラブルなプレビューが指定可能です。
Android11以前では、previewImage を使って画像の指定が可能です。

previewImage と previewLayout の両方の属性を指定することをおすすめします。

リファレンスには、このようにあり、両方指定してもpreviewLayoutが優先されるので、minSdkが11以前の場合は、両方定義しておくのが良さそうです。
minSdkが12以上なのであれば、previewLayoutだけで十分なのかもしれません。スケーラブルなウィジェット プレビューの下位互換性を見る限り、デバイスではなく、OSによって挙動が変わりそうだったので。(実際に試してはいません)


AppWidgetProviderInfoの他の属性や詳しい解説は、以下に記載されています。

https://developer.android.com/develop/ui/views/appwidgets?hl=ja#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>

下記に解説があります。

https://developer.android.com/develop/ui/compose/glance/create-app-widget?hl=ja#declare-appwidget

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については、下記で詳しく説明されています。

https://developer.android.com/develop/ui/compose/glance/theme?hl=ja

依存性を注入する

データ取得のための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を生成出来ます。

詳しくはこちら。

https://developer.android.com/training/dependency-injection/hilt-android?hl=ja#not-supported

データを読み取る

続いて、データの読み取りです。
今回は、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のリファレンスに詳しく書かれています。

https://developer.android.com/reference/kotlin/androidx/glance/appwidget/GlanceAppWidget#provideGlance(android.content.Context,androidx.glance.GlanceId)

今回のサンプルはRoomですが、ApiやDataStoreを利用することも出来ます。
エラーハンドリングを行う例がリファレンスに載っています。

https://developer.android.com/develop/ui/compose/glance/error-handling?hl=ja#try-catch-non-composable

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からデータが受け取れるようになります。

https://developer.android.com/develop/ui/compose/glance/user-interaction?hl=ja#provide-parameters

repository(suspend fun)に対して何かしたい場合は、rememberCoroutineScopeで作成したscopeを使って、ラムダ内で処理を完結させることが出来ます。
また、ActionCallbackを用いて、suspendな処理を実行させることも出来ます。

https://developer.android.com/develop/ui/compose/glance/user-interaction?hl=ja#run-actioncallback

RemoteView時代のウィジェットのように、BroadcastReceiverのonReceiveを経由して何かする必要がなくなり、とても体験が良くなっています。

完成

これで、要件を満たすウィジェットを作成できました!

おわりに

今回は、Glanceを使ったアプリウィジェットの作り方を、データの読み取りやHiltの使い方も交えて説明しました。
UIがComposeで書けるようになったことだけでなく、provideGlanceを用いたデータ読み取りや、クリック処理の簡略化など、本当に楽にウィジェットが作れるようになったと感じました。
みなさまも、気軽にGlanceしていきましょう!

参考

参考にした公式ページあれこれ

Discussion