はじめに
昨年、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に依存関係や設定を追加するだけです。
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を実装することで、更新処理をカスタマイズできます。
作ってみる
スクロール可能なフルーツのリストを表示するウィジェットを作ってみましょう
ウィジェット本体
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です
class MyGlanceAppWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget
get() = MyGlanceAppWidget()
}
ウィジェットの紐付け
AndroidManifestにReceiverの登録が必要です。
また、meta-dataとしてウィジェットの情報を記載する必要があります。
<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に配置します。
<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" />
targetCellWidth
とtargetCellHeight
はAndroid12(SDK31)以上で動作します。
定義していると、12以上のデバイスではminWidth
,minHeight
の代わりに使用されるようです。
previewImage
は仮画像で、本当はちゃんとしたプレビューの画像を定義する必要があります。
updatePeriodMillis
は自動更新は不要なため0にしています。
initialLayout
は、ウィジェット配置後に初回更新されるまでのレイアウトです。
現状では、これはComposeで記載する術はなさそうで、xmlレイアウトを組む必要があります。
こちらは、res/layoutに配置すればOKです。
<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メソッドの流れ
- scopeを展開し、DBから値を取る
-
GlanceAppWidgetManager#getGlanceIds
でGlanceIdのリストを取得
クラスで指定したウィジェットの、今配置されているもののIdが取れます。 -
updateAppWidgetState
を使うと、DataStoreのMutablePreferecenesがブロックに渡ってくるので、そこにウィジェットに渡したいデータを入れる。(alpha03で改善されてMutableが渡るようになりました)
DataStoreに渡せる型は限られているため、今回はリストを区切り文字で区切ったStringに変えて値を入れています。
updateAppWidgetState
はGlanceIdを渡すので、複数ウィジェットがあってもそれぞれのDataStoreに値を入れることができます。 - その後、指定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))
createUpdatePendingIntent
はAlarmManager
で使うPendingIntentのためのものです。
val pendingIntent = MyGlanceWidgetReceiver.createUpdatePendingIntent(context)
alarmManager.setRepeating(AlarmManager.RTC, triggerDateTime, TimeUnit.DAYS.toMillis(1), pendingIntent)
このような形で、ウィジェットを毎日更新することができます。
meta-data.xmlのupdatePeriodMillis
を利用して定期更新することもでき、その場合はAlarmMangaerは不要です。
ウィジェット本体
DataStoreの現在の値を取得して、レイアウトに反映する流れになります。
前のサンプルとは、fruitsListの定義が変わるだけで、レイアウトの実装は同じです。
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で楽しくウィジェットを作っていきましょう!