Погружаемся в работу со скроллом в Jetpack Compose
В этой статье я хочу поделиться опытом работы со скроллом в приложении, написанном на Jetpack Compose.
Какое-то время назад я решил, что надо попробовать Compose в деле и начал делать pet project приложение Хотелки, суть которого в записи своих желаний и возможности делиться списком желаний с помощью любого мессенджера.
В ходе работы над приложением мне пришлось вплотную поработать со скроллом: определять текущую позицию и скроллить к определенному элементу списка, когда появляется клавиатура. Этим опытом я и хочу поделиться в данной статье.
Работа с позицией скролла и скролл к элементу в LazyColumn
На одном из экранов есть айтемы, которые скроллятся. Каждый айтем — это ярлык, кликнув на него айтем становится инпутом, где можно изменить название ярлыка. При этом открывается клавиатура и, если айтем был в нижней части, то клавиатура его перекроет. Поэтому нам нужно скроллить к айтему, который сейчас в фокусе.
У LazyColumn для контроля скролла есть класс LazyListState
. Используя его, можно узнать текущую позицию скролла и проскроллить, куда нужно.
Чтобы его использовать, нужно перед описанием LazyColumn
создать LazyListState
val lazyListState: LazyListState = rememberLazyListState()
а потом передать его в LazyColumn
LazyColumn(state = lazyListState) {}
Для того, чтобы узнать позицию скролла нам предлагается два поля в LazyListState
:
firstVisibleItemIndex
— индекс самого верхнего видимого элемента на экране, firstVisibleItemScrollOffset
— смещение в пикселях от верхнего края нашего контейнера LazyColumn
до верхней грани элемента. Это смещение будем нулевым в начальной позиции скролла и по мере того, как первый элемент будет перемещаться вверх, значение firstVisibleItemScrollOffset
будет расти пока не достигнет размера элемента и дальше снова будет нулевым, так как первым видимым элементом на экране станет уже следующий.
Также в LazyListState
есть LazyListLayoutInfo
, в котором есть полезный список visibleItemsInfo: List
. В нем перечислены все элементы, которые сейчас на экране.
Этот список мы и будем использовать, чтобы определить находится ли сейчас айтем с фокусом на экране — и нам не нужно ничего делать или он не виден пользователю и нам нужно проскроллить к нему.
Делаем это с помощью вот такой функции:
private fun isEditTagItemFullyVisible(lazyListState: LazyListState, editTagItemIndex: Int): Boolean {
with(lazyListState.layoutInfo) {
val editingTagItemVisibleInfo = visibleItemsInfo.find { it.index == editTagItemIndex }
return if (editingTagItemVisibleInfo == null) {
false
} else {
viewportEndOffset - editingTagItemVisibleInfo.offset >= editingTagItemVisibleInfo.size
}
}
}
Зная индекс айтема, для которого мы запросили фокус, мы ищем его среди видимых пользователю. Соответственно если его нет в списке, значит он не виден, а если есть — нам надо ещё определить, полностью ли он виден, так как LazyListState
помещает в visibleItemsInfo
элемент, даже если видна только его часть.
Чтобы определить, виден ли полностью айтем, мы воспользуемся viewportEndOffset
из layoutInfo
, в котором записано значение смещения в пикселях всего нашего контейнера от верхнего края контейнера.
Условие, по которому айтем полностью виден, такое: смещение всего контейнера минус смещение айтема должно быть больше либо равно высоте айтема.
Для того, чтобы наша функция корректно отрабатывала, нам нужно вызывать её строго после того, как клавиатура появилась на экране и наш список перерисуется с новыми размерами (в частности у него уменьшится viewportEndOffset
, так как места стало меньше). Для этого после запроса фокуса для инпута в айтеме, который как раз вызывает клавиатуру, мы делаем небольшую задержку, так как клавиатура не появляется моментально.
coroutineScope.launch {
// We need delay to wait keyboard show that triggers rebuild our ui.
delay(300)
if (!isEditTagItemFullyVisible(lazyListState, index)) {
lazyListState.scrollToItem(index)
}
}
Мы вызываем метод скролла и передаём туда индекс айтема, к которому нужно проскроллить lazyListState.scrollToItem(index)
. По дефолту lazyListState
попытается проскроллить список так, чтобы элемент оказался на самом верху (верхняя грань айтема совпадала с верхней гранью контейнера). Попытается, потому что в зависимости от количества элементов и позиции нашего айтема в списке не всегда получится проскроллить так, чтобы он оказался наверху.
Нам такое поведение не подходит — мы хотим, чтобы айтем в фокусе был прямо над клавиатурой, то есть его нижняя грань совпадала с нижней гранью всего контейнера LazyColumn
.
Для этого в метод scrollToItem()
можно передать scrollOffset
— это смещение от верхней грани контейнера (как и все остальные смещения) до верхней грани айтема, которое должно быть у него после скролла.
Теперь нужно его правильно вычислить. Мы знаем смещение нижней грани контейнера viewportEndOffset
, поэтому нам надо просто отнять высоту айтема от viewportEndOffset
и мы получим искомое смещение scrollOffset
.
coroutineScope.launch {
// We need delay to wait keyboard show that triggers rebuild our ui.
delay(300)
if (!isEditTagItemFullyVisible(lazyListState, index)) {
with(lazyListState.layoutInfo) {
val itemSize = visibleItemsInfo.first().size
val itemScrollOffset = viewportEndOffset - itemSize
lazyListState.scrollToItem(index, -itemScrollOffset)
}
}
}
Теперь мы передаём в метод скролла смещение, но передаём отрицательное значение. Всё дело в том, что когда lazyListState
скроллит к элементу, у него срабатывает дефолтное поведение перемещения элемента на самый верх, а потом он доскролливает так, чтобы offset айтема был равен, переданному scrollOffset
. И здесь важен знак переданного scrollOffset
. Положительный знак говорит lazyListState
, что нужно проскроллить айтем выше (как когда мы скроллим движением пальца снизу вверх). Отрицательный scrollOffset
, что нужно проскроллить айтем ниже (как когда мы скроллим движением пальца сверху вниз). В нашем случае айтем должен оказаться в самом низу контейнера, при этом мы знаем, что по дефолту lazyListState
перемещает наш айтем максимально высоко. Далее нам нужно, чтобы он его переместил ниже, поэтому мы и передаём отрицательный scrollOffset
.
Вроде бы всё готово, но полагаться на delay не хочется при показе клавиатуры, к тому же этот delay заметен при скролле, когда открывается клавиатура. Если его сделать меньше, то есть риск не попасть. Здесь нам на помощь приходят insets. Для работы с инсетами в Compose Google создала проект accompanist, в котором в том числе есть библиотека accompanist-insets. Я опущу детали подключения, это можно посмотреть в официальной документации.
В библиотеке есть удобные extension функции для Modifier, которые будут для вашей Composable функции добавлять нужные отступы. Мы воспользуемся Modifier.navigationBarsWithImePadding()
и проставим этот modifier в нашу рутовую функцию Scaffold для нашего экрана со списком.
navigationBarsWithImePadding()
автоматически добавляет отступы снизу для нав бара и клавиатуры, если она есть на экране. Таким образом, когда появится клавиатура, наша функция Scaffold, внутри которой находится наш LazyCloumn перерисуется. Далее мы можем добавить SideEffect
val focusedTag = editTagItems.find { it.isEditMode }
if (focusedTag != null) {
val insets = LocalWindowInsets.current
val isImeVisible = insets.ime.isVisible
val focusedTagIndex = editTagItems.indexOf(focusedTag)
val isEditTagItemFullyVisible = isEditTagItemFullyVisible(lazyListState, focusedTagIndex)
if (isImeVisible && !isEditTagItemFullyVisible) {
SideEffect {
with(lazyListState.layoutInfo) {
val itemSize = visibleItemsInfo.first().size
val itemScrollOffset = viewportEndOffset - itemSize
coroutineScope.launch {
lazyListState.scrollToItem(focusedTagIndex, -itemScrollOffset)
}
}
}
}
}
Этот SideEffect будет запускаться каждый раз после завершения рекомпозиции, таким образом у нас будут актуальные размеры нашего контейнера, чтобы правильно рассчитать смещения для скролла. Также SideEffect нам нужен для вызова метода скролла, так как он suspend, а делать launch корутин в Composable функциях нельзя, их нужно делать как раз в сайд эффектах.
Чтобы SideEffect не запускался всё время (рекомпозиции могут происходить очень часто), нам нужно поставить условие.
В самом начале мы проверяем, есть ли у нас вообще сейчас айтем в фокусе. Эта информация у нас есть в модели для айтема, так как мы по-разному рисуем сам айтем в зависимости от того, в фокусе он или нет.
val focusedTag = editTagItems.find { it.isEditMode }
if (focusedTag != null) {
…
}
Потом проверяем, есть ли на экране клавиатура сейчас
val insets = LocalWindowInsets.current
val isImeVisible = insets.ime.isVisible
Также мы проверяем полностью ли виден сейчас айтем, который находится в фокусе с помощью функции, которую мы уже использовали выше.
val isEditTagItemFullyVisible = isEditTagItemFullyVisible(lazyListState, focusedTagIndex)
Как видно, скролл стал намного лучше выглядеть — теперь он происходит сразу, как только появляется клавиатура, и мы теперь не полагаемся на delay.
Работа с elevation в тулбаре в зависимости от скролла
В приложении есть тулбары на экранах, которые в Compose называются TopAppBar. И в TopAppBar composable функции есть аргумент elevation
, который по дефолту выставлен AppBarDefaults.TopAppBarElevation = 4.dp
. Это значит, что elevation
будет добавляться всегда, но нам хочется, чтобы elevation
отсутствовал, когда мы только заходим на экран, и появлялся, только когда мы начинаем скроллить контент на экране. Помимо этого для тёмной темы elevation
, который представлен в виде тени у TopAppBar, выглядит не очень хорошо по моему мнению, потому что его не видно толком. Для тёмной темы лучше подойдёт перекрашивание TopAppBar в другой, более светлый, цвет по сравнению с цветом фона контента на экране.
Для этого сделаем свою composable функцию ScrollAwareTopAppBar
, которая добавляет работу с elevation
и цветом фона TopAppBar в зависимости от позиции скролла.
@Composable
fun ScrollAwareTopAppBar(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: @Composable (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
isScrollInInitialState: (() -> Boolean)? = null,
) {
…
}
Для того, чтобы знать, находится ли скролл в начальной позиции или нет, мы добавили лямбду
isScrollInInitialState: (() -> Boolean)
, так как у нас на экране может быть как LazyColumn
, у которого работа со скроллом осуществляется через LazyListState
, так и Column
, у которого работа со скроллом осуществляется через ScrollState
.
И LazyListState и ScrollState являются реализациями интерфейса ScrollableState, но этот интерфейс не предоставляет информацию о текущей позиции скролла, это отдано на реализацию наследникам в силу того, что у них могут быть разные подходы к определению позиции скролла. Как можно увидеть по классам LazyListState
и ScrollState
, это действительно так: у LazyListState
позиция скролла определяется за счёт firstVisibleItemIndex
и firstVisibleItemScrollOffset
, а у ScrollState
за счёт value:Int
, в котором записано текущее значение скролла в пикселях.
Таким образом, чтобы понять, находится ли скролл в начальном состоянии в кейсе с LazyColumn
, мы используем firstVisibleItemIndex
и firstVisibleItemScrollOffset
и создадим extension функцию для удобства:
fun LazyListState.isScrollInInitialState(): Boolean =
firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0
В кейсе с Column extension
функция будет такая
fun ScrollState.isScrollInInitialState(): Boolean = value == 0
Далее на экране, где мы используем наш ScrollAwareTopAppBar мы в лямбде isScrollInInitialState: (() -> Boolean)
, которую передаём в ScrollAwareTopAppBar просто вызываем нужную extension функцию либо у LazyListState, либо у ScrollState в зависимости от того, что у нас используется на экране. Если у нас вообще нет скролла на экране, то ничего не передаем, в ScrollAwareTopAppBar эта лямбда по дефолту null
и наш ScrollAwareTopAppBar будет выставлять elevation
и красить background TopAppBar, как будто у нас скролл всегда в начальном состоянии.
Вот, что у нас получилось.
Покажите мне код
Весь код можно посмотреть в репозитории приложения.
Экран EditTagsScreen со списком ярлыков, где мы скроллили к айтему в фокусе при показе клавиатуры.
Composable функция ярлыка EditTagBlock.
Composable функция ScrollAwareTopAppBar, которая работает с elevation и цветом фона в зависимости от скролла. Её применение можно посмотреть на том же EditTagsScreen.