В этой статье я хочу поделиться опытом работы со скроллом в приложении, написанном на Jetpack Compose.
Какое-то время назад я решил, что надо попробовать Compose в деле и начал делать pet project приложение Хотелки, суть которого в записи своих желаний и возможности делиться списком желаний с помощью любого мессенджера.
В ходе работы над приложением мне пришлось вплотную поработать со скроллом: определять текущую позицию и скроллить к определенному элементу списка, когда появляется клавиатура. Этим опытом я и хочу поделиться в данной статье.
Работа с позицией скролла и скролл к элементу в LazyColumn
На одном из экранов есть айтемы, которые скроллятся. Каждый айтем – это ярлык, кликнув на него айтем становится инпутом, где можно изменить название ярлыка. При этом открывается клавиатура и, если айтем был в нижней части, то клавиатура его перекроет. Поэтому нам нужно скроллить к айтему, который сейчас в фокусе.
У LazyColumn для контроля скролла есть класс LazyListState
. Используя его, можно узнать текущую позицию скролла и проскроллить, куда нужно.
Чтобы его использовать, нужно перед описанием LazyColumn
создать LazyListState
val lazyListState: LazyListState = rememberLazyListState()
а потом передать его в LazyColumn
LazyColumn(state = lazyListState) {}
Для того, чтобы узнать позицию скролла нам предлагается два поля в LazyListState
:
firstVisibleItemIndex
– индекс самого верхнего видимого элемента на экране, firstVisibleItemScrollOffset
– смещение в пикселях от верхнего края нашего контейнера LazyColumn
до верхней грани элемента. Это смещение будем нулевым в начальной позиции скролла и по мере того, как первый элемент будет перемещаться вверх, значение firstVisibleItemScrollOffset
будет расти пока не достигнет размера элемента и дальше снова будет нулевым, так как первым видимым элементом на экране станет уже следующий.
![](https://habrastorage.org/getpro/habr/upload_files/9c8/866/7e3/9c88667e3c3aa7b1831aa97e2644555d.png)
Также в LazyListState
есть LazyListLayoutInfo
, в котором есть полезный список visibleItemsInfo: List<LazyListItemInfo>
. В нем перечислены все элементы, которые сейчас на экране.
Этот список мы и будем использовать, чтобы определить находится ли сейчас айтем с фокусом на экране – и нам не нужно ничего делать или он не виден пользователю и нам нужно проскроллить к нему.
Делаем это с помощью вот такой функции:
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
, в котором записано значение смещения в пикселях всего нашего контейнера от верхнего края контейнера.
Условие, по которому айтем полностью виден, такое: смещение всего контейнера минус смещение айтема должно быть больше либо равно высоте айтема.
![](https://habrastorage.org/getpro/habr/upload_files/8ac/bd2/2eb/8acbd22ebb7e9850509e23d6e9fbd6ef.png)
Для того, чтобы наша функция корректно отрабатывала, нам нужно вызывать её строго после того, как клавиатура появилась на экране и наш список перерисуется с новыми размерами (в частности у него уменьшится 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
попытается проскроллить список так, чтобы элемент оказался на самом верху (верхняя грань айтема совпадала с верхней гранью контейнера). Попытается, потому что в зависимости от количества элементов и позиции нашего айтема в списке не всегда получится проскроллить так, чтобы он оказался наверху.
![](https://habrastorage.org/getpro/habr/upload_files/b71/d54/155/b71d541551c69353b65135e1a9c2a171.gif)
Нам такое поведение не подходит – мы хотим, чтобы айтем в фокусе был прямо над клавиатурой, то есть его нижняя грань совпадала с нижней гранью всего контейнера 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)
}
}
}
![](https://habrastorage.org/getpro/habr/upload_files/223/297/bc7/223297bc79e13cbaadce3bd0448151d8.gif)
Теперь мы передаём в метод скролла смещение, но передаём отрицательное значение. Всё дело в том, что когда 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)
![](https://habrastorage.org/getpro/habr/upload_files/f39/509/c01/f39509c010ea84025492d6f1e6d72b77.gif)
Как видно, скролл стал намного лучше выглядеть – теперь он происходит сразу, как только появляется клавиатура, и мы теперь не полагаемся на 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, как будто у нас скролл всегда в начальном состоянии.
Вот, что у нас получилось.
![](https://habrastorage.org/getpro/habr/upload_files/41c/92d/eac/41c92deac51c9598e56f52f8c7f799d9.gif)
![](https://habrastorage.org/getpro/habr/upload_files/48d/efb/64e/48defb64ea496b28afd77cc936f23611.gif)
Покажите мне код
Весь код можно посмотреть в репозитории приложения.
Экран EditTagsScreen со списком ярлыков, где мы скроллили к айтему в фокусе при показе клавиатуры.
Composable функция ярлыка EditTagBlock.
Composable функция ScrollAwareTopAppBar, которая работает с elevation и цветом фона в зависимости от скролла. Её применение можно посмотреть на том же EditTagsScreen.