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

9c88667e3c3aa7b1831aa97e2644555d.png

Также в 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, в котором записано значение смещения в пикселях всего нашего контейнера от верхнего края контейнера.

Условие, по которому айтем полностью виден, такое: смещение всего контейнера минус смещение айтема должно быть больше либо равно высоте айтема.

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

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)
       }
   }
}

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)

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, как будто у нас скролл всегда в начальном состоянии.

Вот, что у нас получилось.

41c92deac51c9598e56f52f8c7f799d9.gif48defb64ea496b28afd77cc936f23611.gif

Покажите мне код

Весь код можно посмотреть в репозитории приложения.

Экран EditTagsScreen со списком ярлыков, где мы скроллили к айтему в фокусе при показе клавиатуры.

Composable функция ярлыка EditTagBlock.

Composable функция ScrollAwareTopAppBar, которая работает с elevation и цветом фона в зависимости от скролла. Её применение можно посмотреть на том же  EditTagsScreen.

© Habrahabr.ru