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

Комментарии (0)