5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

こんにちは。
株式会社アイスタイルで@cosmeアプリのAndroidエンジニアをしている鈴木です。
最近は、iOSアプリの案件をこなしていたり段々いろんなことを担当し始め、日々学んでいる毎日です。

今回は個人的な興味もあってJetpack Glanceを初めて触ってみたので、Glanceでのウィジェットの実装方法や気づいたことを書き残しておこうと思います。

Jetpack Glanceとは

Jetpack Glance1とは、アプリウィジェットをJetpack Compose2で作成することができるフレームワークです。

導入方法

導入方法は至ってシンプルです。
build.gladleもしくはbuild.glagle.ktsに次のdependencyを追加するだけです。

Groovy DSLの場合

dependencies {
   implementation "androidx.glance:glance-appwidget:1.1.0"
}

Kotlin DSLの場合

dependencies {
   implementation("androidx.glance:glance-appwidget:1.1.0")
}

実装にあたって最低限用意するもの

実装にあたって必要なクラスは2つです。
GlanceAppWidgetを継承したクラスとGlanceAppWidgetReceiverを継承したクラスをまずは用意します。
ここでは、GlanceAppWidgetを継承したクラスをSampleAppWidgetとします。

import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.provideContent

class SampleAppWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            SampleScreen()
        }
    }
}

GlanceAppWidgetを継承したクラスを作成した場合には、provideGlanceをoverrideする必要があります。provideGlanceはCoroutineWorkerとしてバックグラウンドで実行されます。
provideGlance内部では、provideContentを使用してComposableを提供することができます。
provideContent内にウィジェットに表示したいUIのComposableを用意します。ここでは、SampleScreenとしています。

import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.glance.GlanceModifier
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxSize
import androidx.glance.text.Text

@Composable
fun SampleScreen() {
    Column(
        modifier = GlanceModifier.fillMaxSize()
            .background(color = Color.White),
        verticalAlignment = Alignment.CenterVertically,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "SampleScreen")
    }
}

SampleScreenでは、全体が白背景でテキストを表示するだけとしています。
この時の表示イメージは次のようになります。

次に、GlanceAppWidgetReceiverを継承したクラスをSampleAppWidgetReceiverとします。

import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver

class SampleAppWidgetReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget: GlanceAppWidget
        get() = SampleAppWidget()
}

GlanceAppWidgetReceiver3を継承したクラスを作成した場合には、glanceAppWidgetをoverrideする必要があります。
ここで、GlanceAppWidgetを生成するので、先ほど作成したSampleAppWidgetを指定します。

次に、Manifestにてreceiver4の記載をします。...は省略を表し、以降同様です。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <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.Glance_sample"
        tools:targetApi="31">
        ...
        <receiver android:name=".ui.SampleAppWidgetReceiver"
            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/sample_appwidget_info" />
        </receiver>
        ...
    </application>
</manifest>

先ほど作成したSampleAppWidgetReceiverを宣言します。recieverタグ内では、intent-filterとmeta-dataを設定できます。
intent-filterでは、応答できるインテントの種類を指定でき、今回はWidgetの更新ということで android.appwidget.action.APPWIDGET_UPDATE" を指定しています。
meta-dataでは、ウィジェットに関する詳細情報を提供するためのXMLリソースファイルを指定しています。
この例では、 @xml/sample_appwidget_info というXMLファイルを指定してます。sample_appwidget_infoの詳細な情報は次のとおりです。

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/glance_default_loading_layout"
    android:minWidth="50dp"
    android:minHeight="50dp"
    android:targetCellWidth="2"
    android:targetCellHeight="2"
    android:resizeMode="none"
    android:updatePeriodMillis="1800000"
    android:previewImage="@drawable/ic_widget_smaple_preview"
    android:widgetCategory="home_screen" />

initialLayoutは、ウィジェットの初期レイアウトを指定します。minWidthとminHeightは、ウィジェットの最小幅と最小高さを指定できます。
targetCellWidthとtargetCellHeightは、ホームスクリーン上で占めるセル幅を指定します。
resizeModeは、ウィジェットがリサイズ可能かどうかを示し、noneはリサイズ不可を意味します。他にもhorizontalやvertical、horizontal|verticalという値も取れます。
widgetCategoryは、ウィジェットが表示されるカテゴリーを指定します。ホームスクリーンに表示したいので、今回はhome_screenを指定しています。他に、keyguardなどもあります。
previewImageは、ウィジェット選択画面で表示される画像になります。

プレビュー画像 選択画面
widget2.png

updatePeriodMillisは、ウィジェットが自動的に更新される間隔をミリ秒単位で指定できます。1800000は、30分に相当します。最小の単位は30分5になります。より短い間隔で更新をしたい場合は、WorkManagerによる定期実行であれば可能なようです。provideGlanceのKDocにあるコードによると、下記のようにすることで対応できます。

 WorkManager. getInstance(context).enqueueUniquePeriodicWork(
   "weatherWidgetWorker",
   ExistingPeriodicWorkPolicy. KEEP,
   PeriodicWorkRequest.Builder(
     WeatherWidgetWorker::class.java,
     15.minutes. toJavaDuration()
  ).setInitialDelay(15.minutes. toJavaDuration()).build())

以上が、Jetpack Glanceを利用した場合に最低限Widgetを表示する手順になります。

Tips

クリックした時の特定のカスタムしたアクションの処理を行いたい場合

まず、ActionCallbackのonActionを実装したActionクラスを作成します。

import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback

class LaunchActivityAction : ActionCallback {
    override suspend fun onAction(
        context: Context,
        glanceId: GlanceId,
        parameters: ActionParameters
    ) {
        val currentTime = parameters.getOrDefault(ActionParameters.Key(KEY_CURRENT_TIME), "")
        context.startActivity(
            TransitionActivity.createIntent(
                context,
                currentTime
            )
        )
    }

    companion object {
        const val KEY_CURRENT_TIME = "key_current_time"
    }
}

次にScreenのComposableにて、actionRunCallbackを指定します。

@Composable
fun SampleScreen() {
    Column(
        modifier = GlanceModifier.fillMaxSize()
            .clickable(
                onClick = actionRunCallback<LaunchActivityAction>(
                    parameters = actionParametersOf(
                        ActionParameters.Key<String>(LaunchActivityAction.KEY_CURRENT_TIME) to System.currentTimeMillis()
                            .toString()
                    )
                )
            ),
    ) {
      ...
    }
}

clickableのonClickにてactionRunCallback関数を使用して、ユーザーがタップした時のアクションを実行します。ActionParametersでPairでKeyとvalueを指定します。
先に記述してあるLaunchActivityActionのonAction内部で、parametersで指定のKeyから値を取り出すことができます。タップ時に値を渡したい場合は上記の流れで指定します。
今回は例としてActivityを起動していますが、Activity起動だけであれば、actionStartActivityも用意されておりますのでこちらでも起動はできます。
その他、必要に応じて幾つかのユーザー操作の処理方法があります。6

API通信をして定期的に情報を更新したい場合

WorkManagerとPreferences DataStore7を併用します。
今回は例として、GitHub API v3を利用して通信を行い、Key-Valueのペアで特定のUserのnameとavatarUrlを保存して、ウィジェットに反映させる部分をサンプルに紹介していきます。

まずは、Preferences DataStoreを利用したSampleWidgetPreferencesを作成します。

import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first

private const val DATASTORE_NAME = "SampleWidget"

val Context.dataStore by preferencesDataStore(
    name = DATASTORE_NAME
)

object SampleWidgetPreferences {
    private val KEY_USER_IMAGE_URL = stringPreferencesKey("key_user_image_url")
    private val KEY_USER_NAME = stringPreferencesKey("key_user_name")

    suspend fun getUserImageUrl(context: Context): String? {
        return context.dataStore.data.first()[KEY_USER_IMAGE_URL]
    }

    suspend fun setUserImageUrl(context: Context, value: String) {
        context.dataStore.edit { preferences ->
            preferences[KEY_USER_IMAGE_URL] = value
        }
    }

    suspend fun getUserName(context: Context): String? {
        return context.dataStore.data.first()[KEY_USER_NAME]
    }

    suspend fun setUserName(context: Context, value: String) {
        context.dataStore.edit { preferences ->
            preferences[KEY_USER_NAME] = value
        }
    }
}

preferencesDataStoreで、DataStoreの名前を決めて、userのnameとuserのimage urlを取得と保持を行うobjectを作成します。

次にWorkerを用意して、API通信を行いPreferences DataStoreを利用してuserのnameとimage urlを保存する部分をSampleWidgetWorkerとして作成します。

import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.work.CoroutineWorker
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.example.glance_sample.api.GitHubClient
import com.example.glance_sample.data.SampleWidgetPreferences

class SampleWidgetWorker(
    private val context: Context,
    private val params: WorkerParameters
) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result {
        // workDataOfにてappWidgetIdを渡しているので、渡されているか確認する
        val appWidgetId = inputData.getInt(KEY_APP_WIDGET_ID, -1)
        if (appWidgetId == -1) {
            return Result.failure()
        }
        val glanceAppWidgetManager = GlanceAppWidgetManager(context)
        val glanceId = try {
            glanceAppWidgetManager.getGlanceIdBy(appWidgetId)
        } catch (e: IllegalArgumentException) {
            return Result.failure()
        }
        val client = GitHubClient()
        val user = client.getGitHubUser("suzukiyut27")
        val userImageUrl = user.avatarUrl
        val userName = user.name
        if (userImageUrl.isNotBlank() && !userName.isNullOrBlank()) {
            SampleWidgetPreferences.setUserImageUrl(context, userImageUrl)
            SampleWidgetPreferences.setUserName(context, userName)
            SampleAppWidget().update(context, glanceId)
            return Result.success()
        } else {
            return Result.failure()
        }
    }

    companion object {
        private const val KEY_APP_WIDGET_ID = "key_app_widget_id"
        fun request(
            context: Context,
            appWidgetIds: IntArray,
        ) {
            appWidgetIds.forEach { appWidgetId ->
                val request = OneTimeWorkRequestBuilder<SampleWidgetWorker>()
                    .setInputData(
                        workDataOf(
                            KEY_APP_WIDGET_ID to appWidgetId
                        )
                    )
                    .build()
                WorkManager.getInstance(context).enqueue(request)
            }
        }
    }
}

更新のたびに1回だけ非同期処理を実行して欲しいので、OneTimeWorkRequestを作成します。OneTimeWorkRequestBuilderで、inputデータとしてappWidgetIdを受け取るようにし、enqueueを実行します。実行したい処理をdoWorkに書きます。今回の例では、GitHubのAPIを利用して指定したuserNameのUserのavatarUrlとnameを所得してきて、Preferences DataStoreにKey-Valueで保存しています。
worker内部でglanceIdを取得するためには、GlanceAppWidgetManagerを利用します。GlanceAppWidgetManagerを利用してappWidgetIdからglanceIdを取得することができます。
更新時に、glanceIdを指定して、updateを呼ぶことでWidgetの更新を行います。updateを呼ばないとウィジェットに反映されないです。

SampleAppWidgetReceiverのonUpdateにて、WorkerのRequestを投げるようにします。ここに書くことで、Widgetの設定で指定した更新間隔で定期的にWorkerの処理が実行されるようになります。

class SampleAppWidgetReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget: GlanceAppWidget
        get() = SampleAppWidget()

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

SampleAppWidget側のコードも次のように変更します。

class SampleAppWidget : GlanceAppWidget() {
    private var uiState by mutableStateOf<SampleWidgetUiState>(SampleWidgetUiState.Loading)
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            when (val uiState = uiState) {
                SampleWidgetUiState.Loading -> {
                    LaunchedEffect(key1 = Unit) {
                        delay(3000)
                        updateUI(context)
                    }
                    Column(
                        modifier = GlanceModifier.fillMaxSize()
                            .background(color = Color.White),
                        horizontalAlignment = Alignment.CenterHorizontally,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        CircularProgressIndicator()
                    }
                }

                is SampleWidgetUiState.Error -> {
                    Column(
                        modifier = GlanceModifier.fillMaxSize()
                            .background(color = Color.White),
                        horizontalAlignment = Alignment.CenterHorizontally,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Text(text = uiState.message)
                        Spacer(modifier = Modifier.size(16.dp))
                        Button(text = "Reload", onClick = { updateUI(context) })
                    }
                }

                is SampleWidgetUiState.Success -> {
                    SampleScreen(
                        context = context,
                        url = uiState.userImageUrl,
                        userName = uiState.userName,
                        current = uiState.current
                    )
                }
            }
        }
    }

    private fun updateUI(context: Context) {
        runBlocking {
            val userImageUrl = SampleWidgetPreferences.getUserImageUrl(context)
            val userName = SampleWidgetPreferences.getUserName(context)
            val current = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()).format(Date())
            uiState = if (userImageUrl.isNullOrBlank() || userName.isNullOrBlank()) {
                SampleWidgetUiState.Error("User data not found")
            } else {
                SampleWidgetUiState.Success(userName, userImageUrl, current)
            }
        }
    }
}

updateUI内部に記載されている通り、Preferences DataStoreに保存してあるuserのnameとimage urlを取得する形をとっています。
LaunchedEffectで3秒遅らせているのは、API通信をしてPreferences DataStoreに保存されるまで若干タイムラグがあるためです。
更新した日時の変化をもとにDataの変更がわかるように、サンプルでは現在時刻を取得して表示するように変えています。
実際にホームスクリーンに追加した際の表示が次のとおりです。

異なるサイズ別のウィジェットを作りたい場合

複数のrecieverを登録することで、異なるサイズのウィジェットを作成することができます。
Android Manifestに次のように追記します。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application
        ...
        >
        ...
        <receiver android:name=".ui.SampleAppExtraWidgetReceiver"
            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/sample_appwidget_info_extra" />
        </receiver>
        ...
    </application>

</manifest>

resourceで2つ目のウィジェットの設定を記載したxmlを指定します。sample_appwidget_info_extraの詳細は次のとおりです。

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/glance_default_loading_layout"
    android:minWidth="280dp"
    android:minHeight="100dp"
    android:targetCellWidth="5"
    android:targetCellHeight="2"
    android:resizeMode="none"
    android:updatePeriodMillis="1800000"
    android:previewImage="@drawable/ic_100tb"
    android:widgetCategory="home_screen"
    />

widthやheightを変えて、previewImageも別のものを指定するようにしました。次にSampleAppExtraWidgetReceiverを用意します。

class SampleAppExtraWidgetReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget: GlanceAppWidget
        get() = SampleAppExtraWidget()
}

最後に、SampleAppExtraWidgetを用意します。

class SampleAppExtraWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            Column(
                modifier = GlanceModifier.fillMaxSize()
                    .background(color = Color.White),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(text = "Hello, Glance!")
            }
        }
    }
}

これらを用意すると、次のように表示を確認できるようになります。

追加後 Preview

実装時に注意する点について

Glanceの名前空間に注意

Composeが持つComposable関数と、Glance側で持っているComposable関数が同じ名前で違うものなので、気をつけましょう。
例えば、Spacerはandroidx.compose.foundation.layout.Spacerとandroidx.glance.layout.Spacerが存在します。間違えていると、widget追加時にエラーログが出ることと「コンテンツが表示されません」というエラーメッセージを確認できるので、気づくことはできますが注意してください。

ColumnにverticalArrangementはない

androidx.compose.foundation.layoutのColumnにはvarticalArrangementがありますが、androidx.glance.layoutのColumnにはないです。verticalAlignmentがありますので、垂直方向の配置を変えたい場合は、verticalAlignmentを利用しましょう。

RowにhorizontalArrangementはない

Columnと同様で、androidx.compose.foundation.layoutのRowにはhorizontalArrangementがありますが、androidx.glance.layoutのRowにはないです。horizontalAlignmentがあるので、水平方向の配置を変えたい場合は、horizontalAlignmentを利用しましょう。

resizeModeでnoneを指定していても、ランチャーアプリによってはリサイズされる

noneを指定すれば、リサイズを防げるようでしたが、ランチャーアプリを変えてやってみたところ割と自由にリサイズできてしまいました。ウィジェットを実装する際には、リサイズ前提で組むようにするのが良さそうに思います。

おわりに

Jetpack Glanceではじめるウィジェット作りについての紹介でした。ウィジェットは通常の開発とはちょっと違う部分があったので新鮮に感じました。
また何か新しい知見があれば書いていきたいと思います。

参考にさせていただいた資料

公式ドキュメント以外で、参考にさせていただきましたので紹介です。

今回実装したサンプル

全体のサンプルは下記になります。何かしらの参考になれば幸いです。
https://github.com/suzukiyut27/glance_sample

  1. 公式ドキュメントはこちら

  2. ネイティブUIをビルドする際に推奨されるAndroidの最新ツールキット。ドキュメントはこちら

  3. GlanceAppWidgetReceiverは大元がBroadcastReceiverです。BroadcastReceiverを使用すると、アプリケーションの他のコンポーネントが実行されていない場合でも、システムなどからのインテントをアプリケーションが受信できるようになります。exportedをfalseに指定することで、外部のアプリからの起動を許可しないようにすることができるようになります。

  4. 公式ドキュメントはこちら

  5. 公式ドキュメントによると、「Note: Updates requested with updatePeriodMillis will not be delivered more than once every 30 minutes.」とあるので、30分より短い間隔で配信されることはないようです。

  6. ユーザー操作を処理するドキュメントはこちら

  7. SharedPreferences に代わるものとして改善された新しいデータ保持の方法。Key-Value ペアを保存するのがPreferences DataStore。

5
3
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
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?