Реализация Undo в Snackbar на Jetpack Compose
Пользовательский опыт (UX — User Experience) - это то, как пользователи воспринимают продукт и какие впечатления получают от взаимодействия с ним.
В процессе разработки мобильного приложения важно делать акцент на пользовательском опыте в ситуациях, когда от пользователя требуется подтверждение каких-либо действий (или, наоборот, отмена уже совершённого действия). Если приложение будет выводить AlertDialog по поводу и без, пользователю это вряд ли понравится.
В библиотеке компонентов материального дизайна есть Snackbar — виджет, который используется для отображения сообщений в нижней части приложения.
Чем хорош снэкбар:
информирует пользователя о статусе процесса, который приложение выполнило или будет выполнять,
не прерывает взаимодействие пользователя с приложением,
может содержать дополнительную кнопку для программирования различных действий.
Используем Snackbar
В Jetpack Compose уже есть реализация снэкбара. В примере ниже он отображается после нажатия на кнопку:
@Composable
fun SnackbarSample() {
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val modifier = Modifier
Box(modifier.fillMaxSize()) {
Button(onClick = {
coroutineScope.launch {
snackbarHostState.showSnackbar(message = "This is a Snackbar")
}
}) {
Text(text = "Click me!")
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
В коде два основных компонента: SnackbarHost и SnackbarHostState. Их использование позволяет правильно отображать, скрывать и закрывать снэкбар — в соответствии с гайдлайнами материального дизайна.
Единовременно может отображаться один снэкбар — остальные будут ждать в очереди.
Добавляем Undo в Snackbar
Рассмотрим пример посложнее. Реализуем снэкбар, который отменяет действие пользователя. В нашем примере будем отображать список задач и следить за его изменением. В этом нам поможет ViewModel.
Упрощённый пример:
@Composable
fun HomeList(taskViewModel: ListViewModel = viewModel()) {
Scaffold {
val list by remember(taskViewModel) {
taskViewModel.taskList
}.collectAsState()
LazyColumn {
items(
items = list,
itemContent = { task ->
ListItem(
task = task,
onCheckedChange = taskViewModel::onCheckedChange
)
}
)
}
}
}
@Composable
private fun ListItem(task: Task, onCheckedChange: (Task) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = task.isCompleted, onCheckedChange = { onCheckedChange(task) })
Spacer(Modifier.width(8.dp))
Text(text = task.title)
}
}
Наша composable-функция получает на вход ViewModel, который, в свою очередь, подгружает список задач (viewModel.taskList) и вызывает функцию проверки их статуса (viewModel.onCheckedChange).
Scaffold используется для построения правильной структуры лэйаута. Далее по тексту станет понятно, как она будет связана со снэкбаром. А пока мы имеем такой результат:
Наше приложение должно реализовывать следующее бизнес-правило: в списке отображаются только незавершённые задачи. После нажатия на чекбокс, задача должна сразу же удаляться из списка. За реализацию этой логики отвечает ViewModel.
А теперь давайте реализуем функционал, который позволит пользователю отменять действие. Первым делом создадим лямбда-функцию, которая будет вызываться вместе со снэкбаром, когда пользователь нажимает на чекбокс.
@Composable
fun HomeList(taskViewModel: ListViewModel = viewModel()) {
val coroutineScope = rememberCoroutineScope()
val scaffoldState = rememberScaffoldState()
val onShowSnackbar: (Task) -> Unit = { task ->
coroutineScope.launch {
val snackbarResult = scaffoldState.snackbarHostState.showSnackbar(
message = "${task.title} completed",
actionLabel = "Undo"
)
when (snackbarResult) {
SnackbarResult.Dismissed -> Timber.d("Snackbar dismissed")
SnackbarResult.ActionPerformed -> taskViewModel.onCheckedChange(task)
}
}
}
...
}
Разберём код:
Строка #3: сохраняем CoroutineScope (потребуется позже при показе снэкбара)
Строка #4: сохраняем ScaffoldState, который содержит настроенный SnackbarHostState.
Строка #6: вызываем лямбда-функцию, когда пользователь нажимает на чекбокс. В качестве входного параметра она получает задачу, выбранную в списке.
Строка #8: вызываем suspend-функцию showSnackbar() и показываем снэкбар. Используя SnackbarResult, понимаем, какие произошли изменения (как изменилось состояние).
Строки #12-14: обрабатываем два возможных результата (Dismissed и ActionPerformed). Если кнопку не нажали, пишем сообщение в лог. Если кнопку нажали, ViewModel меняет значение boolean-переменной isCompleted на противоположное (с false на true). Когда значение переменной isCompleted опять будет false, задача вернётся в список.
Описание ViewModel
data class Task(val id: Long, val title: String, var isCompleted: Boolean)
class ListViewModel : ViewModel() {
private val list = mutableListOf(
Task(1L, "Buy milk", false),
Task(2L, "Watch 'Call Me By Your Name'", false),
Task(3L, "Listen 'Local Natives'", false),
Task(4L, "Study about 'fakes instead of mocks'", false),
Task(5L, "Congratulate Rafael", false),
Task(6L, "Watch Kotlin YouTube Channel", false)
)
private val _taskList: MutableStateFlow<List<Task>> = MutableStateFlow(list)
val taskList: StateFlow<List<Task>>
get() = _taskList.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), listOf())
fun onCheckedChange(task: Task) {
list.find { it.id == task.id }?.isCompleted = task.isCompleted.not()
_taskList.value = list.filter { it.isCompleted.not() }
}
}
Теперь мы можем связать наш снэкбар с Scaffold и вызывать лямбда-функцию в качестве параметра в методе onCheckedChange:
Scaffold(scaffoldState = scaffoldState) {
...
ListItem(
task = task,
onCheckedChange = { task ->
taskViewModel.onCheckedChange(task)
onShowSnackbar(task)
}
)
}
Так как у ScaffoldState уже есть SnackbarHostState, который мы определили ранее, мы просто передаём его в виде параметра и получаем желаемый результат:
И ещё кое-что
В процессе разработки я столкнулся с тем, что после нажатия на чекбокс снэкбар появлялся и тут же исчезал. Решить её мне помог Адам Пауэлл на Kotlinlang в Slack.
Проблема заключалась в следующем: снэкбар удалялся из очереди, когда вызов showSnackbar отменялся. Изначально я объявлял rememberCoroutineScope в compose-функции ListItem, которая формировала элементы списка задач. Проблему решил перенос её объявления выше Scaffold.
Что дальше?
Полный исходный код для это статьи доступен в этом gist-репозитории (и в конце статьи). Не пинайте сильно за реализацию ViewModel. Она примитивна, да, и она здесь лишь для имитации "живых данных". В реальном приложении данные будут поступать, например, из Flow, связанного с Room.
Для своего приложения реализацию Undo в снэкбаре я добавил в этом пул-реквесте. Если есть желание, посмотрите.
Надеюсь, эта статья поможет вам создавать свои реализации отмены действий в снэкбарах. Спасибо за внимание, а Адаму Пауэллу за помощь с корутинами.
Полный исходный код
@Composable
fun HomeList(taskViewModel: ListViewModel = viewModel()) {
val coroutineScope = rememberCoroutineScope()
val scaffoldState = rememberScaffoldState()
val onShowSnackbar: (Task) -> Unit = { task ->
coroutineScope.launch {
val snackbarResult = scaffoldState.snackbarHostState.showSnackbar(
message = "${task.title} completed",
actionLabel = "Undo"
)
when (snackbarResult) {
SnackbarResult.Dismissed -> Timber.d("Snackbar dismissed")
SnackbarResult.ActionPerformed -> taskViewModel.onCheckedChange(task)
}
}
}
Scaffold(
scaffoldState = scaffoldState,
topBar = { HomeTopBar() }
) {
val list by remember(taskViewModel) { taskViewModel.taskList }.collectAsState()
LazyColumn {
items(
items = list,
key = { it.id },
itemContent = { task ->
ListItem(
task = task,
onCheckedChange = { task ->
taskViewModel.onCheckedChange(task)
onShowSnackbar(task)
}
)
}
)
}
}
}
@Composable
private fun ListItem(task: Task, onCheckedChange: (Task) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = task.isCompleted,
onCheckedChange = { onCheckedChange(task) }
)
Spacer(Modifier.width(8.dp))
Text(
text = task.title,
style = MaterialTheme.typography.body1,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
}
}
@Composable
private fun HomeTopBar() {
TopAppBar {
Box(modifier = Modifier.fillMaxSize()) {
Text(
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.h5,
text = "My tasks"
)
}
}
}
data class Task(val id: Long, val title: String, var isCompleted: Boolean)
class ListViewModel : ViewModel() {
private val list = mutableListOf(
Task(1L, "Buy milk", false),
Task(2L, "Watch 'Call Me By Your Name'", false),
Task(3L, "Listen 'Local Natives'", false),
Task(4L, "Study about 'fakes instead of mocks'", false),
Task(5L, "Congratulate Rafael", false),
Task(6L, "Watch Kotlin YouTube Channel", false)
)
private val _taskList: MutableStateFlow<List<Task>> = MutableStateFlow(list)
val taskList: StateFlow<List<Task>>
get() = _taskList.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), listOf())
fun onCheckedChange(task: Task) {
list.find { it.id == task.id }?.isCompleted = task.isCompleted.not()
_taskList.value = list.filter { it.isCompleted.not() }
}
}
От переводчика: комментарии и правки приветствуются.