LoginSignup
9
4

More than 1 year has passed since last update.

Glanceを使ってJetpackComposeでAndroidのウィジェットを開発しよう

Last updated at Posted at 2022-03-08

はじめに

昨年、Glanceのアルファリリースがあり、22年3月現在はalpha-03が公開されています。(Glanceとは?)
RemoteViewは使用せず、JetpackComposeを使ってウィジェットが作成できるとあって注目されています。
今回は、Glanceを使って1からウィジェットを作成してみたので、使い方やポイントを紹介します。
基本的な作りから、DBから値を取ってきて反映するところまで試してみました。
ベストプラクティスではなく、調べたり試行錯誤したりしつつ行ったので、お気づきの点がありましたらツッコミをいただければと思います。

確認環境

// Glance
androidx.glance:glance-appwidget:1.0.0-alpha03
// Kotlin
org.jetbrains.kotlin:kotlin-stdlib:1.6.10
// AGP
com.android.tools.build:gradle:7.1.2

Glance利用の大まかな流れ

  • GlanceAppWidgetを継承したクラスを作り、Composeでウィジェットのレイアウトを組む
  • GlanceAppWidgetReceiverを継承したクラスを作り、ウィジェットを更新処理を書く
  • ウィジェットの初期レイアウトとmeta-dataを作成し、AndroidManifestに紐付ける

準備

使うモジュール(appなど)のbuild.gradleに依存関係や設定を追加するだけです。

build.gradle.kts(app)
dependencies {
    implementation("androidx.glance:glance-appwidget:1.0.0-alpha03")
}

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.1.0"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Glanceでは、Glance専用のComposeが使われるため、普通のComposeの依存関係の追加は不要です。
goovyでの書き方や、最新情報は以下で。
https://developer.android.com/jetpack/androidx/releases/glance#groovy

必須クラスの解説

GlanceAppWidgetとGlanceAppWidgetReceiverの最小実装を見てみましょう。

GlanceAppWidget

ウィジェット本体です。

class MyGlanceAppWidget : GlanceAppWidget() {
    @Composable
    override fun Content() {
    }
}

Contentでレイアウトを組めば、それがウィジェットとなります。

GlanceAppWidgetReceiver

ウィジェットの更新を司るReceiverです。

class MyGlanceAppWidgetReceiver : GlanceAppWidgetReceiver() {

    override val glanceAppWidget: GlanceAppWidget
        get() = MyGlanceAppWidget()
}

このクラスでonUpdateやonReceiveを実装することで、更新処理をカスタマイズできます。

作ってみる

スクロール可能なフルーツのリストを表示するウィジェットを作ってみましょう
image.png

ウィジェット本体

MyGlanceWidget .kt
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceModifier
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.background
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider

class MyGlanceWidget : GlanceAppWidget() {

    @Composable
    override fun Content() {
        val fruitsList = listOf("もも", "りんご", "メロン", "バナナ", "ぶどう", "いちご")
        Box(
            modifier = GlanceModifier.clickable(actionStartActivity<MainActivity>()).fillMaxSize().background(Color(0, 100, 0)),
            contentAlignment = Alignment.Center
        ) {
            LazyColumn(horizontalAlignment = Alignment.Horizontal.CenterHorizontally, modifier = GlanceModifier.fillMaxWidth()) {
                items(fruitsList) { fruits ->
                    Row(
                        verticalAlignment = Alignment.CenterVertically,
                        modifier = GlanceModifier.clickable(actionStartActivity<MainActivity>()).padding(horizontal = 8.dp)
                    ) {
                        Text(
                            text = fruits,
                            style = TextStyle(color = ColorProvider(Color.White), fontSize = 16.sp),
                        )
                    }
                }
            }
        }
    }
}

LazyColumnを使うことで、スクロール可能なリストが生成できます。
Glance用のComposeを利用すると言いましたが、利用できるものはBoxやTextなど通常と同じクラスになっています。
が、modifierはGlanceModifierというクラスになって、中で設定できることも異なっています。
タップしたときの挙動はclickableで設定でき、アクティビティの起動は簡単にできます。

// これだけ
GlanceModifier.clickable(actionStartActivity<MainActivity>())

今回は、全体でも1アイテムでも、どちらかをタップしたときにMainActivityが起動するようになっています。

Composeの書き方の説明は割愛していますが、初めての方は下記が参考になるかもしれません。
https://developer.android.com/jetpack/compose/layouts/basics

Receiver

今回の場合、レイアウトに特に変化はないので、Receiverは最小構成でOKです

MyGlanceAppWidgetReceiver.kt
class MyGlanceAppWidgetReceiver : GlanceAppWidgetReceiver() {

    override val glanceAppWidget: GlanceAppWidget
        get() = MyGlanceAppWidget()
}

ウィジェットの紐付け

AndroidManifestにReceiverの登録が必要です。
また、meta-dataとしてウィジェットの情報を記載する必要があります。

AndroidManifest.xml
<receiver
    android:name=".glance.MyGlanceWidgetReceiver"
    android:exported="false">
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
    </intent-filter>
    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/glance_my_widget_meta_data" />
</receiver>

@xml/glance_my_widget_meta_dataはウィジェットの情報を定義するxmlで、res/xmlに配置します。

glance_my_widget_meta_data.xml
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/glance_my_widget_initial"
    android:minWidth="110dp"
    android:minHeight="40dp"
    android:previewImage="@mipmap/ic_launcher"
    android:resizeMode="horizontal|vertical"
    android:targetCellWidth="2"
    android:targetCellHeight="2"
    android:updatePeriodMillis="0"
    android:widgetCategory="home_screen" />

targetCellWidthtargetCellHeightはAndroid12(SDK31)以上で動作します。
定義していると、12以上のデバイスではminWidth,minHeightの代わりに使用されるようです。

previewImageは仮画像で、本当はちゃんとしたプレビューの画像を定義する必要があります。
updatePeriodMillisは自動更新は不要なため0にしています。

initialLayoutは、ウィジェット配置後に初回更新されるまでのレイアウトです。
現状では、これはComposeで記載する術はなさそうで、xmlレイアウトを組む必要があります。
こちらは、res/layoutに配置すればOKです。

glance_my_widget_initial.xml
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    android:gravity="center">

    <ProgressBar
        android:id="@+id/widget_initial_loading"
        style="?android:attr/progressBarStyle"
        android:layout_width="24dp"
        android:layout_height="24dp" />

    <TextView
        android:id="@+id/widget_initial_loading_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:gravity="center"
        android:paddingVertical="8dp"
        android:text="@string/loading"
        android:textColor="@color/black"
        android:textSize="16sp" />

</LinearLayout>

これはRemoteViewの制約を受けるので、ConstraintLayoutは利用できず、FrameLayout,LinearLayout,RelativeLayout,GridLayoutしか利用できません。
複雑なものを表現したいときはRelativeLayout(お久しぶりです!)を利用しなければなりませんが、1度ウィジェットが更新されればComposeが使われるので、そんなに凝る必要はないかもしれません。
なお、アプリ更新時や、再起動後にも表示されました。

meta-dataの詳しい属性などは、公式リファレンスで確認できます。

まとめ

以上でGlanceのウィジェットが完成します。
必要なことは5つです。

  • GlanceAppWidgetを継承したウィジェット本体
  • GlanceAppWidgetReceiverを継承したクラス
  • ウィジェットの初期レイアウトのxml
  • ウィジェットのmeta-dataのxml
  • AndroidManifestにReceiverとウィジェットを紐付ける

アプリに入れた情報を表示し、定期更新するウィジェットを作成する

ウィジェットでは、アプリで登録した情報を表示したり、定期的に更新をしたいものがあると思います。
そういったものをGlanceでどう実現するか紹介します。
ユーザーがDBに登録したフルーツを、ランダムな順番で表示し、毎日特定時刻に更新されるウィジェットを作ってみます。

Receiver

SampleRepository(DBを利用するクラス)のfindAllFruitsした結果をランダムにしたものをウィジェットに反映します。
GlanceWidgetには、DataStoreから値を読み出す仕組みが実装されているため、ReceiverでDBから必要な値を読み出し、DataStoreへ反映する流れになります。
※Dagger-Hiltも使っていますが、本質ではないので置いておきます。

@AndroidEntryPoint
class MyGlanceWidgetReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget: GlanceAppWidget
        get() = MyGlanceWidget()

    private val scope = CoroutineScope(Dispatchers.IO + Job())

    @Inject
    lateinit var sampleRepository: SampleRepository

    override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
        super.onUpdate(context, appWidgetManager, appWidgetIds)
        update(context)
    }

    override fun onReceive(context: Context, intent: Intent) {
        super.onReceive(context, intent)
        if (intent.action == ACTION_REQUEST_UPDATE) {
            update(context)
        }
    }

    private fun update(context: Context) {
        scope.launch {
            val fruitsList = sampleRepository.findAllFruits().shuffled()
            val ids = GlanceAppWidgetManager(context).getGlanceIds(MyGlanceWidget::class.java)
            ids.forEach { id ->
                updateAppWidgetState(context, id) { pref ->
                    pref[stringPreferencesKey(MyGlanceWidget.KEY_PREFERENCES_FRUITS_LIST)] =
                        fruitsList.joinToString(separator = MyGlanceWidget.FRUITS_SEPARATOR)
                }
                MyGlanceWidget().update(context, id)
            }
        }
    }

    companion object {
        private const val ACTION_REQUEST_UPDATE = "action_request_update"

        // 定期更新用
        fun createUpdatePendingIntent(context: Context): PendingIntent {
            return createUpdateIntent(context)
                .let { PendingIntent.getBroadcast(context, 1, it, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) }
        }

        // 手動更新用
        fun createUpdateIntent(context: Context): Intent {
            return Intent(context, MyGlanceWidgetReceiver::class.java)
                .setAction(ACTION_REQUEST_UPDATE)
        }
    }
}

privateなupdateメソッドの流れ

  1. scopeを展開し、DBから値を取る
  2. GlanceAppWidgetManager#getGlanceIdsでGlanceIdのリストを取得
    クラスで指定したウィジェットの、今配置されているもののIdが取れます。
  3. updateAppWidgetStateを使うと、DataStoreのMutablePreferecenesがブロックに渡ってくるので、そこにウィジェットに渡したいデータを入れる。(alpha03で改善されてMutableが渡るようになりました)
    DataStoreに渡せる型は限られているため、今回はリストを区切り文字で区切ったStringに変えて値を入れています。
    updateAppWidgetStateはGlanceIdを渡すので、複数ウィジェットがあってもそれぞれのDataStoreに値を入れることができます。
  4. その後、指定Idのウィジェットの更新を行う。

本当は、WorkManagerやForegroundServiceなどを利用した方が良さそうですが、Glance更新に重きを置いているのでこうしました。
それらを利用した場合も、後述の手動更新のようにBroadCastを投げればウィジェットを更新できると思います。

(余談)
onUpdateにはappWidgetIds: IntArrayが渡ってくるので、onUpdateからの場合はわざわざgetGlanceIdsせず、引数のを使えばいいのでは?と思われるかもしれません。
しかし、updateAppWidgetStateのIdには内部的にAppWidgetIdというのが必要で(AppWidgetIdでないと中でrequire(glanceId is AppWidgetId)があって弾かれる)、AppWidgetIdはinternalなので開発者が生成することができません。
そのため、onUpdate/onReceiveどちらの場合もGlanceAppWidgetManager#getGlanceIdsでIdを取得しています。

onReceiveとcompanion objectのメソッド

アプリ更新や再起動の際は、onUpdateが呼ばれてウィジェットが更新されます。
onReceiveでは、ユーザーがアプリ側でDBに値を入れたとき(手動更新)や、定期的なウィジェット更新の際に使われます。(自分でBroadCastを投げる形です)

ここからはGlanceには直接関係ない話です。

createUpdateIntentは手動更新する際に使うもので、例えばユーザーが入力を完了してDBに値が入った後に使うことで、ウィジェットを更新することができます。(Fragmentなどで呼ぶ)

ウィジェット手動更新(抜粋)
activity.sendBroadcast(MyGlanceWidgetReceiver.createUpdateIntent(context))

createUpdatePendingIntentAlarmManagerで使うPendingIntentのためのものです。

ウィジェット定期更新(抜粋)
val pendingIntent = MyGlanceWidgetReceiver.createUpdatePendingIntent(context)
alarmManager.setRepeating(AlarmManager.RTC, triggerDateTime, TimeUnit.DAYS.toMillis(1), pendingIntent)

このような形で、ウィジェットを毎日更新することができます。
meta-data.xmlのupdatePeriodMillisを利用して定期更新することもでき、その場合はAlarmMangaerは不要です。

ウィジェット本体

DataStoreの現在の値を取得して、レイアウトに反映する流れになります。
前のサンプルとは、fruitsListの定義が変わるだけで、レイアウトの実装は同じです。

MyGlanceWidget.kt
class MyGlanceWidget : GlanceAppWidget() {

    @Composable
    override fun Content() {
        val prefs = currentState<Preferences>()
        val fruitsString = prefs[stringPreferencesKey(KEY_PREFERENCES_FRUITS_LIST)]
        val fruitsList = fruitsString?.split(FRUITS_SEPARATOR).orEmpty()
        Box(
            modifier = GlanceModifier.clickable(actionStartActivity<MainActivity>()).fillMaxSize().background(Color(0, 100, 0)),
            contentAlignment = Alignment.Center
        ) {
            LazyColumn(horizontalAlignment = Alignment.Horizontal.CenterHorizontally, modifier = GlanceModifier.fillMaxWidth()) {
                items(fruitsList) { fruits ->
                    Row(
                        verticalAlignment = Alignment.CenterVertically,
                        modifier = GlanceModifier.clickable(actionStartActivity<MainActivity>()).padding(horizontal = 8.dp)
                    ) {
                        Text(
                            text = fruits,
                            style = TextStyle(color = ColorProvider(Color.White), fontSize = 16.sp),
                        )
                    }
                }
            }
        }
    }

    companion object {
        const val KEY_PREFERENCES_FRUITS_LIST = "key_preferences_fruits_list"
        const val FRUITS_SEPARATOR = ","
    }
}

GlanceAppWidgetでは、GlanceStateDefinitionというプロパティが実装されており、currentState()で最新のGlance用DataStoreの値を取得することができます。
先程のReceiverで利用したupdateAppWidgetStateで更新した値が取得できます。
区切り文字のStringなので、splitでListに変換してLazyColumnに入れています。

これで、完成です。

注意点

デバッグ時

アプリ更新時のウィジェットの更新は、Receiverのintent-filterandroid.appwidget.action.APPWIDGET_UPDATEを受信することで行われます。
AndroidStudioのRunでアプリを実行した場合、APPWIDGET_UPDATEが来ないため、アプリ更新時のウィジェット更新確認はできません。

これを試したい場合は、生成したapkをadb installで入れる必要があります。
apk生成方法はいくつかありますが、手っ取り早いのはStudioのBuild apkで生成されたapk(app-debug.apk)が
app/build/intermediates/apk/debug
に配置されるので、それを
adb install -r app-debug.apk
で端末に入れることで、APPWIDGET_UPDATEが発火し、アプリ更新時のウィジェット更新を確認できるようになります。
(apk配置場所やapk名は環境により異なります。また、adb installに-tが必要になることがあります)

GlanceとCompose

前述しましたが、GlanceのComposeは通常のComposeではなく、Glance専用のComposeを使います。
「通常のComposeで使えたアレが使えない!」といったことは起こりうるので注意が必要です。
ただ、まだalphaなので、stableに向けて充実していく可能性はあります。

初回のupdateAppWidgetStateの値が反映されない

これは私がまだ解決できていない問題です。
アプリ初回インストールを終え、ウィジェットを配置した際に、フルーツが表示されない問題がありました。
MyGlanceWidgetReceiverのupdateは呼ばれていて、updateAppWidgetStateで値は入れているのですが、初回のMyGlanceWidgetのContentのcurrentStateではその値が読めないようです。
私の使い方が悪いのか、不具合なのか、もし分かる方がいらっしゃれば教えていただきたいところです。

最後に

今回はウィジェットをJetpackComposeで作れるGlanceを紹介しました。
まだアルファなので、改善の余地があるところ、ベストプラクティスが分かっていないところもありますが、ウィジェット作成のハードルはかなり下がったのではないかと感じます。
これからはComposeで楽しくウィジェットを作っていきましょう!

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4