Изучение различных подходов к созданию бесконечных автопрокручиваемых списков на Android

Бесконечная прокрутка списков с помощью RecyclerView (слева) и LazyRow (справа)
Бесконечная прокрутка списков с помощью RecyclerView (слева) и LazyRow (справа)

Имплементация RecyclerView + ListAdapter

RecyclerView - это действительно классный и мощный инструмент для отображения списка(ов) содержимого на Android. Существует масса отличных статей и примеров о различных решениях RecyclerView, поэтому здесь мы не будем их рассматривать. Основное внимание будет уделено созданию бесконечных списков с автоматической прокруткой.

Сделано с любовью ​️ используя RecyclerView (В GIF уменьшена частота кадров)
Сделано с любовью ​️ используя RecyclerView (В GIF уменьшена частота кадров)

Как мы можем решить эту проблему?

Одна вещь, которая приходит на ум, - это создать список элементов, которые повторяются столько раз, что можно представить его как бесконечный. Хотя это решение вполне работоспособно, оно немного расточительно, но мы можем попытаться сделать лучше, не так ли?

Давайте перейдем непосредственно к коду для настройки 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

Давайте разберемся, как это сделать

  1. Начнем с того, что существует рекурсивная функция, которая вызывает сама себя, поскольку RecyclerView вынужден прокручивать список бесконечно.

  2. RecyclerView прокручивается на 5 пикселей, если его можно прокрутить по горизонтали, это означает, что до конца списка еще не дошли.

  3. Если RecyclerView не может больше прокручивать, это означает, что он достиг конца списка, теперь мы разделим существующий список на две части:
    — Первая часть начинается от первого видимого элемента списка до последнего.
    — Вторая часть начинается от первого элемента существующего списка до первого видимого элемента (не включительно).

  4. Новый список передается адаптеру. Именно здесь и пригодится технология сравнения (diff, diffing) списков в адаптере. Адаптер определяет, что видимая часть списка совпадает с первой частью нового списка, поэтому в RecyclerView в этот момент не происходит визуального обновления, и теперь элементы списка находятся справа.

  5. Затем снова срабатывает шаг 2, прокручивая список на 5 пикселей.

Это функция приостановки, поэтому корутина будет отменена при исчезновении области видимости, и нам не нужно беспокоиться о ее явном прекращении.

Примерное визуальное представление шага 3 и 4.
Примерное визуальное представление шага 3 и 4.

Этот GIF наглядно демонстрирует процесс отправки нового списка в адаптер. Если он похож на обработку просмотров, то потому, что так выглядел исходный GIF, который был отредактирован для создания этого... ну, давайте двигаться дальше.

.     .     .

Теперь с Compose

 Сделано с любовью ​️ с использованием Compose (В GIF уменьшена частота кадров)
Сделано с любовью ​️ с использованием Compose (В GIF уменьшена частота кадров)

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

Давайте разложим все по полочкам

  1. Объект MutableState инициализируется списком функций, предоставленных компонуемым FeatureList. Таким образом, если список будет обновлен, сборный LazyRow будет перекомпонован с новым списком.

  2. items() используется для добавления списка элементов, а последним параметром является лямбда, в которой определяется содержимое элемента.

  3. Когда выдается последний элемент, itemsListState обновляется новым списком, аналогично подходу RecyclerView, использованному выше. Поскольку itemsListState проверяется компоновкой, и изменение этого состояния, да вы угадали, планирует рекомпозицию для LazyRow.

  4. Интересным различием между LazyLists и RecyclerViewListAdapter) является то, что состояние прокрутки сохраняется в LazyLists таким образом, что если список будет обновлен, состояние прокрутки не изменится. Если состояние прокрутки находится в конце списка, то при обновлении списка состояние прокрутки все равно останется в конце списка. Поэтому нам нужно сбросить состояние прокрутки перед обновлением списка, чтобы добиться желаемого эффекта. Состояние прокрутки сбрасывается на элемент с индексом 0 для обновленного списка, который является первым видимым элементом в текущем списке, поэтому мы не видим никаких визуальных изменений.

  5. Когда FeaturesList входит в композицию, срабатывает блок LaunchedEffect  и происходит начальный вызов рекурсивной функции autoScroll. Корутин отменяется, когда компонуемый FeaturesList выходит из композиции.

  6. В итоге 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". Приглашаем на день открытых дверей онлайн, где можно будет узнать подробнее о формате обучения и программе, а также познакомиться с преподавателем курса.

Комментарии (0)