Берём Glance Widgets под контроль

от автора

В этой статье я хочу показать как создавать виджеты с состоянием для 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, который позволяет пробрасывать параметры в обработчики нажатий на вьюшки виджета.

Для примера я создал приложение с простыми функциями блокнота.

Задачи:

  • добавлять для заметки индивидуальный виджет через лаунчер

  • добавлять виджет из заметки напрямую

  • обновлять виджет при редактировании заметки к которой он прикреплён

  • открывать приложение с нужной заметкой при нажатии на виджет

Для создания виджета с состоянием нам нужно выполнить следующие шаги:

  1. Определяем набор значений, из которых будет складываться состояние виджета

  2. Создаём виджет, наследуясь от GlanceAppWidget

  3. Определяем действия, которые которые нужно обрабатывать при нажатии на элементы виджета

  4. Добавляем ресивер и регистрируем его в манифесте

  5. Добавляем метадату

  6. Добавляем конфигурационную активити и обработку сохранения привязки виджета к заметке.

Так как виджет у нас будет отражением состояния заметки, то я хочу хранить в нём такие значения как заголовок, текст заметки, дату последнего изменения и идентификатор заметки. Для простоты создам ключи для этих значений на верхнем уровне прямо в файле, где будет описываться виджет.

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 виджет.

Код с проектом тут

Полезные ссылки:

App widgets overview

GlanceAppWidget

Demystifying Jetpack Glance for app widgets

App widgets in Android with Glance


ссылка на оригинал статьи https://habr.com/ru/articles/730678/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *