Jetpack Glance(ウィジェット)入門
概要
Jetpack GlanceとはComposeと同じ書き方でウィジェットを作れるもの。※同じものではない
使用方法
依存の追加
libs.versions.toml
[versions]
glance = "1.1.0"
[libraries]
glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }
glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" }
[bundles]
glance = ["glance-appwidget", "glance-material3"]
build.gradle.kts app
dependencies {
implementation(libs.bundles.glance)
}
ウィジェットのメタデータを設定する(xml)
(OS versionにによって適用されるものがまちまちなので注意)
minWidth / minHeight → Android12以降のデフォルトサイズ
targetCellWidth / targetCellHeight → Android11以下のデフォルトサイズ
maxResizeWidth / maxResizeWidth → 最大サイズ
resizeMode → どの方向にリサイズできるようにするか
description → ウィジェットに表示するウィジェット選択ツールの説明
previewLayout → ウィジェット選択するときのpreview
configure → ウィジェットタップした時にどのActivitiyを起動させるか
updatePeriodMillis → 何秒ごとに定期的に更新するか決めれる定期的な更新が必要ない場合は0それ以外の場合はgoogle的には1時間推奨で30分より短い場合は実行されない場合があるらしい
res/xml/appwidget_info.xml
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:maxResizeWidth="250dp"
android:maxResizeHeight="120dp"
android:updatePeriodMillis="1800"
android:configure="com.sample.samplewidget.MainActivity"
android:description="@string/app_name"
android:previewLayout="@layout/widget_preview"
android:initialLayout="@layout/glance_default_loading_layout"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable|configuration_optional">
</appwidget-provider>
GlanceAppWidgetの追加とウィジェット用のThemeの準備
ThemeはGlanceだと別物扱いにあるので改めて定義する必要がある
既存のThemeを利用する
@Composable
fun WidgetTheme(
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colors =
if (dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
GlanceTheme.colors
} else {
ColorProviders(
light = LightColorScheme,
dark = DarkColorScheme,
)
}
GlanceTheme(colors = colors) {
content()
}
}
ここでウィジェットのUIを作っていく
importはGlanceを選ぶように気をつけよう
// stringResource()は使えない
@Composable
fun glanceString(@StringRes id: Int): String {
return LocalContext.current.getString(id)
}
class SampleWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
WidgetTheme {
// この中に記述していく
}
}
}
}
GlanceAppWidgetReceiverの追加
ウィジェットの追加や更新の処理を記述する
class SampleWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = SampleWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
// appwidget_info.xmlで設定したupdatePeriodMillis毎にこの処理が実行される
}
AndroidManifestの修正
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SampleWidget"
tools:targetApi="31">
// 以下追加
<receiver
android:name=".widget.SampleWidgetReceiver"
android:exported="true"
// labelはウィジェット選択時に表示されう
android:label="@string/widget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info" />
</receiver>
ここまでがウィジェットを作る上で必須事項
データの永続化
rememberなども使えるが端末再起動したら消えてしまうので、dataStoreやRoomを使って永続化した方がいい
const val KEY = "key"
class SampleWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val pref: Preferences = currentState()
val state = pref[KEY] ?: 0
// 取得するとき
val pref: Preferences = getAppWidgetState(context, glanceId)
val state = pref[KEY] ?: 0
+α非同期の重い処理はWorkerを使用する
非同期でAPIを叩いて情報を更新したいときや思い処理、より高頻度で定期的に更新したい時はWorkerを使うことが推奨されている
UIは数字とボタンがあり、ボタンを押すとローディングが表示された後数字が1ずつ増えるウィジェットを作る
SampleWorker
class SampleWorker(
private val context: Context,
private val params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val widget = SampleWidget()
val appWidgetIds =
params.inputData.getIntArray(KEY_APP_WIDGET_IDS) ?: throw IllegalArgumentException()
val operation = params.inputData.getString(KEY_OPERATION) ?: throw IllegalArgumentException()
val glanceAppWidgetManager = GlanceAppWidgetManager(context)
appWidgetIds.map { appWidgetId ->
val glanceId = glanceAppWidgetManager.getGlanceIdBy(appWidgetId)
when (operation) {
OPERATION_INCREMENT -> widget.increment(context, glanceId)
else -> throw IllegalArgumentException()
}
}
Result.success()
} catch (e: Exception) {
Result.failure()
}
}
companion object {
const val KEY_APP_WIDGET_IDS = "key_app_widget_ids"
const val KEY_OPERATION = "key_operation"
const val OPERATION_INCREMENT = "operation_increment"
fun requestIncrement(context: Context, ids: IntArray) {
val request = OneTimeWorkRequestBuilder<SampleWorker>()
.setInputData(
workDataOf(
KEY_APP_WIDGET_IDS to ids,
KEY_OPERATION to OPERATION_INCREMENT
)
)
.build()
WorkManager.getInstance(context).enqueue(request)
}
}
}
SampleWidget
class SampleWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val pref: Preferences = currentState()
val state = pref[PREF_KEY_COUNT] ?: 0
val isLoading = pref[PREF_KEY_IS_LOADING] ?: false
val scope = rememberCoroutineScope()
// GlanceId を直接渡せないのでIntに変換
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
WidgetTheme {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(Color.Blue),
) {
if (isLoading) {
CircularProgressIndicator()
} else {
Text(text = "Hello Widget")
Text(text = "state: $state")
Button(
onClick = {
scope.launch {
SampleWorker.requestIncrement(context, intArrayOf(appWidgetId))
}
},
text = "+"
)
}
}
}
}
}
companion object {
private val PREF_KEY_COUNT = intPreferencesKey("pref_key_count")
private val PREF_KEY_IS_LOADING = booleanPreferencesKey("pref_key_is_loading")
}
suspend fun increment(context: Context, glanceId: GlanceId) {
val pref: Preferences = getAppWidgetState(context, glanceId)
val isLoading = pref[PREF_KEY_IS_LOADING] ?: false
val current = pref[PREF_KEY_COUNT] ?: 0
if (isLoading) return
// ローディング中にUI更新
updateAppWidgetState(context, glanceId) {
it[PREF_KEY_IS_LOADING] = true
}
// 画面更新してローディング表示する
update(context, glanceId)
val next = current + 1
delay(1000L)
updateAppWidgetState(context, glanceId) {
it[PREF_KEY_COUNT] = next
it[PREF_KEY_IS_LOADING] = false
}
// 数字を更新させる
update(context, glanceId)
}
}
ここまでボタン押して数字が増えるのを確認できるはず
定期的に更新したいなら以下
SampleWidgetReceiver
class SampleWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = SampleWidget()
private var job: Job? = null
private val scope = CoroutineScope(Dispatchers.IO)
private val sampleWidget by lazy {
SampleWidget()
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
job?.cancel()
job = scope.launch {
val manager = GlanceAppWidgetManager(context)
appWidgetIds.forEach { id ->
val glanceId = manager.getGlanceIdBy(id)
sampleWidget.increment(context, glanceId)
}
}
}
override fun onDisabled(context: Context?) {
super.onDisabled(context)
job?.cancel()
scope.cancel()
}
}
Discussion