📱

Jetpack Glance(ウィジェット)入門

2024/07/23に公開

概要

Jetpack GlanceとはComposeと同じ書き方でウィジェットを作れるもの。※同じものではない

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

使用方法

依存の追加

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>

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

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