В этой статье я хочу показать как создавать виджеты с состоянием для Android приложения с помощью Glance Compose, обновлять каждый экземпляр отдельно от остальных, настраивать их с помощью конфигурационной активити и закреплять экземпляры из приложения. Пример того, что должно получиться вы можете видеть на видео.
Glance — библиотека, входящая в семейство Jetpack, позволяющая через Jetpack Compose Runtime создавать виджеты для Android и Tiles для WearOS. Она пришла на смену RemoteViews в декабре 2021 года и призвана облегчить нам жизнь через упрощение создания дизайна виджетов и взаимодействие с ними. Библиотека поддерживает интероп с RemoteViews и на момент написания статьи находится в альфа версии.
Мы не будем тут повторять официальную документацию, изучать доступные настройки метадаты и рассматривать базовые сценарии, в которых надо просто бросить виджет в лаунчере. В конце этой статьи я хочу чтобы у вас было понимание как создать statefull виджет с конфигурацией и как добавлять их прямо из приложения.
Какие плюсы?
Помимо очевидного плюса отказа от RemoteViews, который даёт Compose, в копилку стоит добавить также GlanceStateDefinition, который создаётся свой на каждый экземпляр виджета. Что это и за что его нужно любить? StateDefinition — это хранилище на основе DataStore, которое по-сути просто файл. Когда мы добавляем новый виджет, то для него нам предоставляется только к нему прикреплённый стор, который будет единым источником правды для этого виджета. Он выступает в роли посредника между приложением и состоянием виджета и о нём можно думать как о некотором хранилище remember значений, если приводить аналогию с обычным миром Compose. Мы ещё вернёмся к этой сущности ниже.
Также, на мой взгляд, Google добавил довольно удобную систему обновления виджетов. Если раньше для синхронизации вьюшек виджета с состоянием данных нам нужно было где-то хранить его widgetId, бросать Intent, выдумывать свои менеджеры для этого, то сейчас у нас есть пара функций верхнего уровня, с помощью которых можно обновить все виджеты одного типа или выполнить обновление с некоторым предикатом, с помощью которого будут затронуты лишь те экземпляры, которые нам нужны. Это мы тоже посмотрим далее.
Какие ограничения?
Glance поддерживает набор собственных Composable функций и GlanceModifier и не поддерживает MaterialTheme. Смешивать Glance со стандартным Compose не следует, в лучшем случае это будет проигнорировано. На момент написания статьи поддерживаются Box, Row, Column, Text, Button, LazyColumn, Image и Spacer.. К сожалению пока-что нужно создавать xml файл с метадатой, в будущем обещают отказаться от него. Ну и самое неприятное — мы не увидим привычных нам remember, так как в Glance нет рекомпозиции и состояния в привычном нам понимании для Compose. Придётся немного изменить образ мышления, но как только это сделаете — то создавать и расширять виджеты становится легко и очень быстро. Ещё одно ограничение — отсутствие кастомных шрифтов.
Перед тем как приступить к написанию кода давайте ещё посмотрим на то, из каких компонентов состоит вся связка и кто за что отвечает.
GlanceAppWidgetManager — это менеджер наших Glance виджетов, с помощью которого будем получать идентификаторы имеющихся экземпляров виджетов и запрашивать у системы установку виджета напрямую из приложения.
GlanceAppWidgetReciever — класс, который обновляет виджеты когда это необходимо и пришёл на смену AppWidgetProvider. Но в отличие от своего предшественника вся работа спрятана под капот.
GlanceAppWidget — сам виджет. От него мы будем наследоваться и реализовывать свои экземпляры. В этом классе находится точка входа в Composable часть виджета через функцию Content.
GlanceStateDefinition — интерфейс с функциями доступа к контейнеру состояния Glance виджета. Мы будем использовать PreferencesGlanceStateDefinition, который для каждого нового экземпляра виджета будет создавать свой собственный файл для сохранения состояния.
Состояние виджета будет храниться в виде набора примитивов в сторе и для их сохранения мы будем использовать набор функций из androidx.datastore.preferences.core.
И последний интересный компонент — это класс ActionParameters, который позволяет пробрасывать параметры в обработчики нажатий на вьюшки виджета.
Для примера я создал приложение с простыми функциями блокнота.
Задачи:
добавлять для заметки индивидуальный виджет через лаунчер
добавлять виджет из заметки напрямую
обновлять виджет при редактировании заметки к которой он прикреплён
открывать приложение с нужной заметкой при нажатии на виджет
Для создания виджета с состоянием нам нужно выполнить следующие шаги:
Определяем набор значений, из которых будет складываться состояние виджета
Создаём виджет, наследуясь от GlanceAppWidget
Определяем действия, которые которые нужно обрабатывать при нажатии на элементы виджета
Добавляем ресивер и регистрируем его в манифесте
Добавляем метадату
Добавляем конфигурационную активити и обработку сохранения привязки виджета к заметке.
Так как виджет у нас будет отражением состояния заметки, то я хочу хранить в нём такие значения как заголовок, текст заметки, дату последнего изменения и идентификатор заметки. Для простоты создам ключи для этих значений на верхнем уровне прямо в файле, где будет описываться виджет.
val noteId = longPreferencesKey("noteId")
val noteTitle = stringPreferencesKey("noteTitle")
val noteText = stringPreferencesKey("noteText")
val noteUpdatedAt = stringPreferencesKey("noteUpdatedAt")
Ниже создаю виджет и ресивер
class NoteWidget : GlanceAppWidget() {
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
@Composable
override fun Content() {
NoteWidgetContent()
}
}
class NoteWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = NoteWidget()
}
Так как ресивер является BroadcastReceiver, то его нужно зарегистрировать в манифесте.
<receiver
android:name=".widget.NoteWidgetReceiver"
android:enabled="true"
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/widget_meta" />
</receiver>
Чтобы не захламлять NoteWidget я вынес вёрстку в отдельную функцию NoteWidgetContent. Для краткости я не привёл тут импорты, но стоит обратить внимание на то, что в вёрстке используются import androidx.glance и элементы из Glance вместо стандартных из Compose. Например androidx.glance.text.Text вместо androidx.compose.material.Text
@Composable
fun NoteWidgetContent(prefs: Preferences) {
val noteId = prefs[noteIdPK] ?: Long.MIN_VALUE
val noteTitle = prefs[noteTitlePK].orEmpty()
val noteText = prefs[noteTextPK].orEmpty()
val updatedAt = prefs[noteLastUpdatePK].orEmpty()
LazyColumn(
modifier = GlanceModifier
.background(imageProvider = ImageProvider(R.drawable.widget_background))
.appWidgetBackground()
.padding(16.dp)
) {
if (noteTitle.isNotEmpty()) item {
WidgetText(noteTitle, noteId)
}
if (noteText.isNotEmpty()) item {
WidgetText(noteText, noteId)
}
if (updatedAt.isNotEmpty()) item {
WidgetText(updatedAt, noteId, 16.sp)
}
}
}
@Composable
fun WidgetText(text: String, noteId: Long, fontSize: TextUnit = 20.sp) {
Text(
text = text,
style = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = fontSize,
textAlign = TextAlign.Start,
color = ColorProvider(
day = Color.White,
night = Color.White
)
)
)
}
Далее нужно добавить файл xml с настройками виджета и файлы с разметкой, которые в этой конфигурации указаны. Повторюсь, мы опустим рассмотрение этих базовых моментов, по ним информации достаточно. Нам важно разобраться с тем, как виджету работать с состоянием.
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_name"
android:initialLayout="@layout/widget_initial_layout"
android:minWidth="240dp"
android:minHeight="100dp"
android:previewImage="@drawable/widget_preview"
android:previewLayout="@layout/widget_preview_layout"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:widgetCategory="home_screen" />
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Loading"/>
</FrameLayout>
Теперь у нас есть необходимый минимум, чтобы можно было на лаунчере вызвать меню виджетов и выбрать наш. Для того, чтобы на этом шаге мы могли выбрать к какой заметке привязать экземпляр виджета, нам нужно добавить конфигурационную активити. Не забудьте добавить её в xml файле с настройками виджета
android:configure="com.example.note_glance_widget.widget.config.ConfigWidgetActivity"
Весь код активити находится тут, нам же интересна та его часть, которая касается выбора заметки и привязки её к виджету. Это происходит в методе saveWidgetState, в который передаётся идентификатор заметки.
private fun saveWidgetState(id: Long) = lifecycleScope.launch(Dispatchers.IO) {
val glanceId = GlanceAppWidgetManager(applicationContext).getGlanceIdBy(widgetId)
val note = repository.getNote(id)?.let { it.toEntity() } ?: return@launch
updateAppWidgetState(applicationContext, glanceId) { prefs ->
prefs[noteIdPK] = id
prefs[noteTitlePK] = note.title
prefs[noteTextPK] = note.text
prefs[noteLastUpdatePK] = note.formatUpdatedAt
}
NoteWidget().update(applicationContext, glanceId)
}
widgetId тут у нас хранится как свойство активити и инициализируется с помощью интента, в котором лежит по ключу AppWidgetManager.EXTRA_APPWIDGET_ID
Для того, чтобы обновить виджет и сохранить в его сторе нужные нам данные, необходимо получить glanceId. GlanceAppWidgetManager умеет конвертировать идентификаторы виджетов в GlanceId с помощью метода getGlanceIdBy, это тут и используется. Далее я беру заметку, по которой был клик, и с помощью функции updateAppWidgetState сохраняю в стор виджета нужные значения по ключам, которые мы описывали ранее. На этом этапе данные в стор виджета сохранены, но его отображение ещё не обновлено. Для этого нужно явно вызвать NoteWidget().update(applicationContext, glanceId). После закрытия активити мы должны увидеть виджет с данными из его состояния. Если сейчас открыть песочницу приложения, то в ней можно найти сторы, которые создаются для каждого виджета индивидуально и удаляются вместе с ними.
Идём дальше. Теперь если мы будем редактировать наши заметки, то увидим, что в виджетах информация не обновляется. Не порядок, давайте исправлять.
Я добавлю функцию, которая будет принимать заметку и сохранять её значения в тот стор, в котором лежит идентификатор этой заметки. Для того, чтобы понять в какой стор сохранить данные, я получаю от GlanceAppWidgetManager все glanceId, пробегаюсь по их сторам в методе updateAppWidgetState и, если идентификатор заметки в сторе совпадает с идентификатором обновляемой заметки, обновляю данные. У виджетов есть метод updateIf<State>, который принимает лямбду — предикат, в которой мы определяем наше условие. Он поможет нам обновить отображение только того виджета, который относится к обновляемой заметке и не дёргать лишний раз другие экземпляры.
suspend fun GlanceAppWidgetManager.mapNoteToWidget(context: Context, note: Note) =
getGlanceIds(NoteWidget::class.java)
.forEach { glanceId ->
updateAppWidgetState(context, glanceId) { prefs ->
if(prefs[noteIdPK] == note.id) {
prefs[noteTitlePK] = note.title
prefs[noteTextPK] = note.text
prefs[noteLastUpdatePK] = note.formatUpdatedAt
}
}
NoteWidget().updateIf<Preferences>(context) {
it[noteIdPK] == note.id
}
}
Осталось 2 задачи — разобраться как открывать нужную заметку по нажатию на виджет и как закреплять виджеты напрямую из приложения.
Клики по элементам виджета Glance реализуются с помощью набора нескольких колбеков
actionRunCallback
actionStartActivity
actionStartService
actionStartBroadcastReceiver
Тут есть довольно хорошее их описание и базовых вещей, которые мы упустили.
Для открытия активити я буду использовать actionStartActivity и передавать аргументом идентификатор заметки, который возьму из стора виджета.
Опишем ключ для параметра, который будет передаваться обработчику клика на элементы виджета.
val noteIdParam = longPreferencesKey("noteIdParam")
Теперь добавим текстовым элементам в виджете обработчик кликов по ним:
@Composable
fun WidgetText(text: String, noteId: Long, fontSize: TextUnit = 20.sp) {
Text(
text = text,
style = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = fontSize,
textAlign = TextAlign.Start,
color = ColorProvider(
day = Color.White,
night = Color.White
)
),
modifier = GlanceModifier.clickable(
actionStartActivity<RootActivity>(
parameters = actionParametersOf(
noteIdParam to noteId
)
)
)
)
}
Далее в RootActivity получаем идентификатор из интента и открываем нужную заметку. Тут есть место оптимизации логики навигации, но статья не об этом. По виджету кликнули, данные в активити переданы, заметка открылась.
Берёмся за последнюю задачу — закрепление виджета из приложения. Это мы будем делать на экране заметки с помощью метода handlePinWidget
private fun handlePinWidget(noteId: Long) {
val intent = Intent(context, PinWidgetReceiver::class.java)
intent.putExtra(NOTE_ID, noteId)
val pendingIntent = PendingIntent.getBroadcast(
context,
noteId.toInt(),
intent,
PendingIntent.FLAG_IMMUTABLE
)
GlanceAppWidgetManager(context).requestPinGlanceAppWidget(
NoteWidgetReceiver::class.java,
successCallback = pendingIntent
)
}
У GlanceAppWidgetManager есть метод requestPinGlanceAppWidget. Он принимает на вход класс ресивера нашего виджета и PendingIntent, который будет являться колбеком и вызовется системой после того, как отработает закрепление виджета. Тот компонент, который будет добавлен в этот PendingIntent, будет ответственен за настойку этого экземпляра виджета. Я для этой цели создам BroadcastReciever и положу в Intent с ним идентификатор заметки, которая должна быть закреплена.
class PinWidgetReceiver : BroadcastReceiver() {
@Inject
lateinit var repository: NotesRepository
override fun onReceive(context: Context, intent: Intent) {
val noteId = intent.getLongExtra(NOTE_ID, Long.MIN_VALUE)
CoroutineScope(EmptyCoroutineContext).launch {
delay(3000)
val note = repository.getNote(noteId)?.let { it.toEntity() } ?: return@launch
val glanceManager = GlanceAppWidgetManager(context)
val lastAddedGlanceId = glanceManager.getGlanceIds(NoteWidget::class.java).last()
mapNoteToWidget(context, lastAddedGlanceId, note)
}
}
private suspend fun mapNoteToWidget(context: Context, lastAddedGlanceId: GlanceId, note: Note) {
updateAppWidgetState(context, lastAddedGlanceId) { prefs ->
prefs[noteIdPK] = note.id
prefs[noteTitlePK] = note.title
prefs[noteTextPK] = note.text
prefs[noteLastUpdatePK] = note.formatUpdatedAt
}
NoteWidget().update(context, lastAddedGlanceId)
}
}
Тут есть магическое место с ожиданием трёх секунд. Дело в том, что если это не сделать, то почему-то нам не доступен сразу стор нового виджета. Скорее всего дело в асинхронном процессе его создания. 3 секунды у меня хватает для того, чтобы стор был корректно предоставлен. Возможно я тут не прав и буду рад, если поправите меня и укажете корректный способ получения синхронного доступа к стору.
В этом случае обновление виджета будет выполняться через получение последнего добавленного идентификатора GlanceId с помощью метода GlanceAppWidgetManager.getGlanceIds.
Это всё, что я хотел показать, надеюсь материал был полезным и поможет вам разобраться с тем, как добавить состояние в Glance виджет.
Полезные ссылки: