Бесконечная автопрокрутка списков с помощью RecyclerView и LazyLists в Compose
Изучение различных подходов к созданию бесконечных автопрокручиваемых списков на Android
Имплементация RecyclerView + ListAdapter
RecyclerView - это действительно классный и мощный инструмент для отображения списка(ов) содержимого на Android. Существует масса отличных статей и примеров о различных решениях RecyclerView, поэтому здесь мы не будем их рассматривать. Основное внимание будет уделено созданию бесконечных списков с автоматической прокруткой.
Как мы можем решить эту проблему?
Одна вещь, которая приходит на ум, - это создать список элементов, которые повторяются столько раз, что можно представить его как бесконечный. Хотя это решение вполне работоспособно, оно немного расточительно, но мы можем попытаться сделать лучше, не так ли?
Давайте перейдем непосредственно к коду для настройки FeaturesAdapter
, который имплементирует ListAdapter.
data class Feature(
@DrawableRes val iconResource: Int,
val contentDescription: String,
)
class FeaturesAdapter : ListAdapter<Feature, RecyclerView.ViewHolder>(FeatureDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater
.from(parent.context)
.inflate(R.layout.item_feature_tile, parent, false)
return FeatureItemViewHolder(view)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val itemViewHolder = holder as FeatureItemViewHolder
itemViewHolder.bind(getItem(position))
}
inner class FeatureItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(feature: Feature) {
with(itemView) {
imageFeature.setImageResource(feature.iconResource)
imageFeature.contentDescription = feature.contentDescription
}
}
}
}
class FeatureDiffCallback : DiffUtil.ItemCallback<Feature>() {
override fun areItemsTheSame(oldItem: Feature, newItem: Feature): Boolean =
oldItem.iconResource == newItem.iconResource
override fun areContentsTheSame(oldItem: Feature, newItem: Feature): Boolean =
oldItem == newItem
}
Адаптер, реализующий ListAdapter, который вычисляет различия между списками по мере их обновления.
Почему ListAdapter?
RecyclerView.Adapter - это базовый класс для представления данных списка в RecyclerView, включая вычисление различий между списками в фоновом потоке. Этот класс является удобной оберткой вокруг AsyncListDiffer, которая имплементирует общее поведение Adapter по умолчанию для доступа к элементам и подсчета.
Но почему сравнение (diff, diffing) имеет значение, когда мы просто хотим показать несколько одинаковых элементов в цикле? Давайте углубимся в код и посмотрим.
private fun setupFeatureTiles(featuresList: List<Features>) {
with(recyclerFeatures) {
layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
adapter = featuresAdapter
}
featuresAdapter.submitList(featuresList)
lifecycleScope.launch { autoScrollFeaturesList() }
}
Функция имеет параметр для списка свойств, которые могут быть предоставлены ViewModel
. Этот список передается адаптеру в качестве начального, и запускается корутина с вызовом autoScrollFeaturesList
. Это и есть основная логика, приведенная ниже.
private tailrec suspend fun autoScrollFeaturesList() {
if (recyclerFeatures.canScrollHorizontally(DIRECTION_RIGHT)) {
recyclerFeatures.smoothScrollBy(SCROLL_DX, 0)
} else {
val firstPosition =
(recyclerFeatures.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
if (firstPosition != RecyclerView.NO_POSITION) {
val currentList = featuresAdapter.currentList
val secondPart = currentList.subList(0, firstPosition)
val firstPart = currentList.subList(firstPosition, currentList.size)
featuresAdapter.submitList(firstPart + secondPart)
}
}
delay(DELAY_BETWEEN_SCROLL_MS)
autoScrollFeaturesList()
}
private const val DELAY_BETWEEN_SCROLL_MS = 25L
private const val SCROLL_DX = 5
private const val DIRECTION_RIGHT = 1
Давайте разберемся, как это сделать
Начнем с того, что существует рекурсивная функция, которая вызывает сама себя, поскольку RecyclerView вынужден прокручивать список бесконечно.
RecyclerView прокручивается на 5 пикселей, если его можно прокрутить по горизонтали, это означает, что до конца списка еще не дошли.
Если RecyclerView не может больше прокручивать, это означает, что он достиг конца списка, теперь мы разделим существующий список на две части:
— Первая часть начинается от первого видимого элемента списка до последнего.
— Вторая часть начинается от первого элемента существующего списка до первого видимого элемента (не включительно).Новый список передается адаптеру. Именно здесь и пригодится технология сравнения (diff, diffing) списков в адаптере. Адаптер определяет, что видимая часть списка совпадает с первой частью нового списка, поэтому в RecyclerView в этот момент не происходит визуального обновления, и теперь элементы списка находятся справа.
Затем снова срабатывает шаг 2, прокручивая список на 5 пикселей.
Это функция приостановки, поэтому корутина будет отменена при исчезновении области видимости, и нам не нужно беспокоиться о ее явном прекращении.
Этот GIF наглядно демонстрирует процесс отправки нового списка в адаптер. Если он похож на обработку просмотров, то потому, что так выглядел исходный GIF, который был отредактирован для создания этого... ну, давайте двигаться дальше.
. . .
Теперь с Compose
LazyList - это внутренняя имплементация для отображения списков в Compose. В этом посте мы будем использовать LazyRow, который идеально подходит для нашего горизонтально прокручиваемого списка.
Давайте напишем компонуемые (Composables) FeatureTile
и FeatureList
.
@Composable
fun FeatureTile(feature: Feature) {
Card(
shape = MaterialTheme.shapes.small,
modifier = Modifier
.size(Dimens.grid_6)
.aspectRatio(1f)
.padding(1.dp),
elevation = Dimens.plane_2
) {
Image(
painter = painterResource(id = feature.iconResource),
contentDescription = feature.contentDescription,
alignment = Alignment.Center,
modifier = Modifier.padding(Dimens.grid_1_5)
)
}
}
FeatureTile - аналог FeaturesAdapter.kt
@Composable
fun FeatureList(
list: List<Feature>,
modifier: Modifier,
) {
var itemsListState by remember { mutableStateOf(list) }
val lazyListState = rememberLazyListState()
LazyRow(
state = lazyListState,
modifier = modifier,
) {
items(itemsListState) {
FeatureTile(feature = it)
Spacer(modifier = Modifier.width(Dimens.grid_1))
if (it == itemsListState.last()) {
val currentList = itemsListState
val secondPart = currentList.subList(0, lazyListState.firstVisibleItemIndex)
val firstPart = currentList.subList(lazyListState.firstVisibleItemIndex, currentList.size)
rememberCoroutineScope().launch {
lazyListState.scrollToItem(0, maxOf(0, lazyListState.firstVisibleItemScrollOffset - SCROLL_DX_INT))
}
itemsListState = firstPart + secondPart
}
}
}
LaunchedEffect(Unit) {
autoScroll(lazyListState)
}
}
private tailrec suspend fun autoScroll(lazyListState: LazyListState) {
lazyListState.scroll(MutatePriority.PreventUserInput) {
scrollBy(SCROLL_DX)
}
delay(DELAY_BETWEEN_SCROLL_MS)
autoScroll(lazyListState)
}
private const val DELAY_BETWEEN_SCROLL_MS = 8L
private const val SCROLL_DX = 1f
FeatureList с автоматической прокруткой
Что на самом деле здесь происходит?
FeatureList показывает список функций в LazyRow. Здесь мы используем преимущества State.
... когда состояние вашего приложения меняется, Jetpack Compose планирует рекомпозицию. Рекомпозиция запускает функции компоновки, которые могли поменяться в ответ на изменение состояния, и Jetpack Compose обновляет изменения в композиции, для их отображения. - State and Jetpack Compose
Давайте разложим все по полочкам
Объект
MutableState
инициализируется списком функций, предоставленных компонуемымFeatureList
. Таким образом, если список будет обновлен, сборныйLazyRow
будет перекомпонован с новым списком.items() используется для добавления списка элементов, а последним параметром является лямбда, в которой определяется содержимое элемента.
Когда выдается последний элемент,
itemsListState
обновляется новым списком, аналогично подходуRecyclerView
, использованному выше. ПосколькуitemsListState
проверяется компоновкой, и изменение этого состояния, да вы угадали, планирует рекомпозицию дляLazyRow
.Интересным различием между
LazyLists
иRecyclerView
(сListAdapter
) является то, что состояние прокрутки сохраняется вLazyLists
таким образом, что если список будет обновлен, состояние прокрутки не изменится. Если состояние прокрутки находится в конце списка, то при обновлении списка состояние прокрутки все равно останется в конце списка. Поэтому нам нужно сбросить состояние прокрутки перед обновлением списка, чтобы добиться желаемого эффекта. Состояние прокрутки сбрасывается на элемент с индексом 0 для обновленного списка, который является первым видимым элементом в текущем списке, поэтому мы не видим никаких визуальных изменений.Когда
FeaturesList
входит в композицию, срабатывает блок LaunchedEffect и происходит начальный вызов рекурсивной функцииautoScroll
. Корутин отменяется, когда компонуемыйFeaturesList
выходит из композиции.В итоге
autoScroll
прокручивает список вперед с некоторой задержкой между каждым прокручиванием, аналогично подходуRecyclerView
.
. . .
Бонус: AutoScrollingLazyRow
Поскольку функция компоновки успешно выполняется, необходимо создать общую имплементацию AutoScrollingLazyRow
, которую легко использовать и применять повторно.
@Composable
fun <T : Any> AutoScrollingLazyRow(
list: List<T>,
modifier: Modifier = Modifier,
scrollDx: Float = SCROLL_DX,
delayBetweenScrollMs: Long = DELAY_BETWEEN_SCROLL_MS,
divider: @Composable () -> Unit = { Spacer(modifier = Modifier.width(Dimens.grid_1)) },
itemContent: @Composable (item: T) -> Unit,
) {
var itemsListState by remember { mutableStateOf(list) }
val lazyListState = rememberLazyListState()
LazyRow(
state = lazyListState,
modifier = modifier,
) {
items(itemsListState) {
itemContent(item = it)
divider()
if (it == itemsListState.last()) {
val currentList = itemsListState
val secondPart = currentList.subList(0, lazyListState.firstVisibleItemIndex)
val firstPart = currentList.subList(lazyListState.firstVisibleItemIndex, currentList.size)
rememberCoroutineScope().launch {
lazyListState.scrollToItem(0, maxOf(0, lazyListState.firstVisibleItemScrollOffset - scrollDx.toInt()))
}
itemsListState = firstPart + secondPart
}
}
}
LaunchedEffect(Unit) {
autoScroll(lazyListState, scrollDx, delayBetweenScrollMs)
}
}
private tailrec suspend fun autoScroll(
lazyListState: LazyListState,
scrollDx: Float,
delayBetweenScrollMs: Long,
) {
lazyListState.scroll(MutatePriority.PreventUserInput) {
scrollBy(scrollDx)
}
delay(delayBetweenScrollMs)
autoScroll(lazyListState, scrollDx, delayBetweenScrollMs)
}
private const val DELAY_BETWEEN_SCROLL_MS = 8L
private const val SCROLL_DX = 1f
AutoScrollingLazyRow(list = featuresList) {
FeatureTile(feature = it)
}
Стандартный компонент AutoScrollingLazyRow
. . .
Заключительные мысли и тангенциальные соображения
При использовании LaunchedEffect
с ключом Unit перекомпонуется только LazyRow
, это логично и является ожидаемым поведением. Однако, если ключ для LaunchedEffect
установлен в itemsListState
, список Features List также перекомпонуется. LaunchedEffect
перезапускается при изменении ключа, но поскольку ничто другое в области видимости FeaturesList
не использует itemsListState
, важно обратить внимание на то, что установка неправильных ключей для LaunchedEffect
может вызвать нежелательные рекомпозиции.
Бесконечные вертикальные списки с автоматической прокруткой также могут быть созданы с помощью аналогичной техники. Небольшой нюанс при использовании варианта Compose заключается в том, что пользовательский ввод отключен для простоты. В этой заметке рассмотрен один подход к созданию бесконечных автопрокручивающихся списков, но в Compose может быть множество различных способов добиться этого!
Ссылки
Материал подготовлен в рамках курса "Android Developer. Professional". Приглашаем на день открытых дверей онлайн, где можно будет узнать подробнее о формате обучения и программе, а также познакомиться с преподавателем курса.