Прокрутка

Модификаторы прокрутки

Модификаторы verticalScroll и horizontalScroll предоставляют самый простой способ прокрутить элемент, границы которого превышают максимально допустимый размер. С модификаторами verticalScroll и horizontalScroll вам не нужно перемещать или смещать содержимое.

@Composable
private fun ScrollBoxes() {
    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .verticalScroll(rememberScrollState())
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

Простой вертикальный список, реагирующий на прокрутку жестов

ScrollState позволяет изменить положение прокрутки или получить её текущее состояние. Чтобы создать его с параметрами по умолчанию, используйте rememberScrollState() .

@Composable
private fun ScrollBoxesSmooth() {
    // Smoothly scroll 100px on first composition
    val state = rememberScrollState()
    LaunchedEffect(Unit) { state.animateScrollTo(100) }

    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .padding(horizontal = 8.dp)
            .verticalScroll(state)
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

Прокручиваемый модификатор

Модификатор scrollable отличается от модификаторов scroll тем, что scrollable распознаёт движения прокрутки и фиксирует дельты, но не смещает своё содержимое автоматически. Вместо этого эта функция делегируется пользователю через ScrollableState , который необходим для корректной работы модификатора.

При создании ScrollableState необходимо предоставить функцию consumeScrollDelta , которая будет вызываться на каждом шаге прокрутки (при жестовом вводе, плавной прокрутке или перелистывании) с указанием дельты в пикселях. Эта функция должна возвращать пройденное расстояние прокрутки, чтобы гарантировать корректное распространение события при наличии вложенных элементов с модификатором scrollable .

Следующий фрагмент кода распознает жесты и отображает числовое значение смещения, но не смещает никакие элементы:

@Composable
private fun ScrollableSample() {
    // actual composable state
    var offset by remember { mutableStateOf(0f) }
    Box(
        Modifier
            .size(150.dp)
            .scrollable(
                orientation = Orientation.Vertical,
                // Scrollable state: describes how to consume
                // scrolling delta and update offset
                state = rememberScrollableState { delta ->
                    offset += delta
                    delta
                }
            )
            .background(Color.LightGray),
        contentAlignment = Alignment.Center
    ) {
        Text(offset.toString())
    }
}

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

Вложенная прокрутка

Вложенная прокрутка — это система, в которой несколько компонентов прокрутки, содержащихся друг в друге, работают вместе, реагируя на один жест прокрутки и передавая свои дельты прокрутки (изменения).

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

Compose предоставляет несколько способов управления вложенной прокруткой между компонуемыми элементами. Типичный пример вложенной прокрутки — список внутри другого списка, а более сложный случай — сворачивающаяся панель инструментов .

Автоматическая вложенная прокрутка

Простая вложенная прокрутка не требует никаких действий с вашей стороны. Жесты, инициирующие прокрутку, автоматически передаются от дочерних элементов к родительским, так что когда дочерний элемент не может прокручивать дальше, жест обрабатывается родительским элементом.

Автоматическая вложенная прокрутка поддерживается и предоставляется по умолчанию некоторыми компонентами и модификаторами Compose: verticalScroll , horizontalScroll , scrollable , Lazy API и TextField . Это означает, что когда пользователь прокручивает внутренний дочерний элемент вложенных компонентов, предыдущие модификаторы распространяют дельты прокрутки на родительские элементы, которые поддерживают вложенную прокрутку.

В следующем примере показаны элементы, к которым применен модификатор verticalScroll , внутри контейнера, к которому также применен модификатор verticalScroll .

@Composable
private fun AutomaticNestedScroll() {
    val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)
    Box(
        modifier = Modifier
            .background(Color.LightGray)
            .verticalScroll(rememberScrollState())
            .padding(32.dp)
    ) {
        Column {
            repeat(6) {
                Box(
                    modifier = Modifier
                        .height(128.dp)
                        .verticalScroll(rememberScrollState())
                ) {
                    Text(
                        "Scroll here",
                        modifier = Modifier
                            .border(12.dp, Color.DarkGray)
                            .background(brush = gradient)
                            .padding(24.dp)
                            .height(150.dp)
                    )
                }
            }
        }
    }
}

Два вложенных вертикальных прокручиваемых элемента пользовательского интерфейса, реагирующих на жесты внутри и снаружи внутреннего элемента.

Использование модификатора nestedScroll

Если вам нужно создать расширенную скоординированную прокрутку между несколькими элементами, модификатор nestedScroll обеспечивает большую гибкость, определяя иерархию вложенной прокрутки. Как упоминалось в предыдущем разделе, некоторые компоненты имеют встроенную поддержку вложенной прокрутки. Однако для компонуемых элементов, которые не прокручиваются автоматически, таких как Box или Column , дельты прокрутки таких компонентов не будут распространяться во вложенной системе прокрутки и не достигнут NestedScrollConnection или родительского компонента. Чтобы решить эту проблему, вы можете использовать nestedScroll для предоставления такой поддержки другим компонентам, включая пользовательские компоненты.

Вложенный цикл прокрутки

Вложенный цикл прокрутки представляет собой поток дельт-прокрутки, которые отправляются вверх и вниз по иерархическому дереву через все компоненты (или узлы), которые являются частью вложенной системы прокрутки, например, с помощью прокручиваемых компонентов и модификаторов или nestedScroll .

Фазы вложенного цикла прокрутки

Когда прокручиваемый компонент обнаруживает событие-триггер (например, жест), еще до того, как будет запущено фактическое действие прокрутки, сгенерированные дельты отправляются во вложенную систему прокрутки и проходят три фазы: предварительная прокрутка, потребление узла и пост-прокрутка.

Фазы вложенного прокрутки цикла

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

Фаза предварительной прокрутки — отправка вверх

Это дает вложенным родительским элементам прокрутки (компонуемым с использованием модификаторов nestedScroll или scrollable) возможность что-то сделать с дельтой до того, как сам узел сможет ее использовать.

Фаза предварительной прокрутки – всплывающая вниз

В фазе потребления узел сам использует ту дельту, которая не была использована его родителями. В этот момент прокрутка фактически завершена и становится видимой.

Потребление узла фаза

На этом этапе ребёнок может выбрать, использовать ли весь оставшийся свиток или его часть. Всё, что останется, будет отправлено обратно для прохождения фазы после свитка.

Наконец, на этапе пост-прокрутки все, что сам узел не потребил, будет снова отправлено его предкам для потребления.

Фаза после прокрутки — отправка вверх

Фаза после прокрутки работает аналогично фазе до прокрутки, когда любой из родителей может решить, потреблять или нет.

Фаза после прокрутки – всплывание вниз

Аналогично прокрутке, по завершении жеста перетаскивания намерение пользователя может быть преобразовано в скорость, используемую для прокрутки (анимированной прокрутки) прокручиваемого контейнера. Прокрутка также является частью вложенного цикла прокрутки, и скорости, генерируемые событием перетаскивания, проходят через аналогичные фазы: предварительная прокрутка, потребление узла и постпрокрутка. Обратите внимание, что анимация прокрутки связана только с сенсорным жестом и не будет запускаться другими событиями, такими как a11y или аппаратная прокрутка.

Участвовать во вложенном цикле прокрутки

Участие в цикле подразумевает перехват, использование и отчётность о потреблении дельт по всей иерархии. Compose предоставляет набор инструментов для управления работой вложенной системы прокрутки и для непосредственного взаимодействия с ней, например, когда нужно что-то сделать с дельтами прокрутки ещё до того, как прокручиваемый компонент начнёт прокручиваться.

Если цикл вложенной прокрутки представляет собой систему, действующую на цепочке узлов, модификатор nestedScroll позволяет перехватывать эти изменения, вставлять их в них и влиять на данные (дельты прокрутки), распространяемые по цепочке. Этот модификатор может быть размещён в любом месте иерархии и взаимодействует с экземплярами вложенных модификаторов прокрутки выше по дереву, обмениваясь информацией через этот канал. Строительными блоками этого модификатора являются NestedScrollConnection и NestedScrollDispatcher .

NestedScrollConnection позволяет реагировать на фазы цикла вложенной прокрутки и влиять на её систему. Он состоит из четырёх методов обратного вызова, каждый из которых представляет одну из фаз потребления: до/после прокрутки и до/после переброса:

val nestedScrollConnection = object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        println("Received onPreScroll callback.")
        return Offset.Zero
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        println("Received onPostScroll callback.")
        return Offset.Zero
    }
}

Каждый обратный вызов также предоставляет информацию о распространяемой дельте: available дельте для данной фазы и consumed дельте, использованной в предыдущих фазах. Если в какой-либо момент вы захотите остановить распространение дельты вверх по иерархии, вы можете использовать вложенное соединение прокрутки:

val disabledNestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            return if (source == NestedScrollSource.SideEffect) {
                available
            } else {
                Offset.Zero
            }
        }
    }
}

Все обратные вызовы предоставляют информацию о типе NestedScrollSource .

NestedScrollDispatcher инициализирует вложенный цикл прокрутки. Использование диспетчера и вызов его методов запускают цикл. Прокручиваемые контейнеры имеют встроенный диспетчер, который отправляет в систему дельты, полученные во время жестов. По этой причине в большинстве случаев настройки вложенной прокрутки используется NestedScrollConnection вместо диспетчера, чтобы реагировать на уже существующие дельты, а не отправлять новые. Подробнее см. в примере NestedScrollDispatcherSample .

Изменить размер изображения при прокрутке

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

Изменение размера изображения в зависимости от положения прокрутки

В этом фрагменте кода показано изменение размера изображения в LazyColumn в зависимости от положения вертикальной прокрутки. Изображение уменьшается при прокрутке вниз и увеличивается при прокрутке вверх, оставаясь в пределах заданных минимального и максимального размера:

@Composable
fun ImageResizeOnScrollExample(
    modifier: Modifier = Modifier,
    maxImageSize: Dp = 300.dp,
    minImageSize: Dp = 100.dp
) {
    var currentImageSize by remember { mutableStateOf(maxImageSize) }
    var imageScale by remember { mutableFloatStateOf(1f) }

    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // Calculate the change in image size based on scroll delta
                val delta = available.y
                val newImageSize = currentImageSize + delta.dp
                val previousImageSize = currentImageSize

                // Constrain the image size within the allowed bounds
                currentImageSize = newImageSize.coerceIn(minImageSize, maxImageSize)
                val consumed = currentImageSize - previousImageSize

                // Calculate the scale for the image
                imageScale = currentImageSize / maxImageSize

                // Return the consumed scroll amount
                return Offset(0f, consumed.value)
            }
        }
    }

    Box(Modifier.nestedScroll(nestedScrollConnection)) {
        LazyColumn(
            Modifier
                .fillMaxWidth()
                .padding(15.dp)
                .offset {
                    IntOffset(0, currentImageSize.roundToPx())
                }
        ) {
            // Placeholder list items
            items(100, key = { it }) {
                Text(
                    text = "Item: $it",
                    style = MaterialTheme.typography.bodyLarge
                )
            }
        }

        Image(
            painter = ColorPainter(Color.Red),
            contentDescription = "Red color image",
            Modifier
                .size(maxImageSize)
                .align(Alignment.TopCenter)
                .graphicsLayer {
                    scaleX = imageScale
                    scaleY = imageScale
                    // Center the image vertically as it scales
                    translationY = -(maxImageSize.toPx() - currentImageSize.toPx()) / 2f
                }
        )
    }
}

Ключевые моменты кода

  • Этот код использует NestedScrollConnection для перехвата событий прокрутки.
  • onPreScroll рассчитывает изменение размера изображения на основе дельты прокрутки.
  • Переменная состояния currentImageSize хранит текущий размер изображения, ограниченный minImageSize и maxImageSize. imageScale является производным от currentImageSize .
  • LazyColumn смещается на основе currentImageSize .
  • Image использует модификатор graphicsLayer для применения рассчитанного масштаба.
  • translationY в graphicsLayer обеспечивает вертикальное центрирование изображения при его масштабировании.

Результат

Предыдущий фрагмент кода приводит к эффекту масштабирования изображения при прокрутке:

Рисунок 1. Эффект масштабирования изображения при прокрутке.

Взаимодействие вложенной прокрутки

При попытке вложить прокручиваемые элементы View в прокручиваемые компонуемые элементы или наоборот могут возникнуть проблемы. Наиболее заметные из них возникают, когда при прокрутке дочернего элемента достигается его начальная или конечная граница, и родительский элемент ожидает, что прокрутка будет перехвачена. Однако это ожидаемое поведение может либо не произойти, либо работать не так, как ожидалось.

Эта проблема возникает из-за ожиданий, заложенных в прокручиваемых компонуемых объектах. Для прокручиваемых компонуемых объектов действует правило «вложенной прокрутки по умолчанию», которое означает, что любой прокручиваемый контейнер должен участвовать во вложенной цепочке прокрутки, как в качестве родительского элемента через NestedScrollConnection , так и в качестве дочернего элемента через NestedScrollDispatcher . Дочерний элемент затем запускает вложенную прокрутку для родительского элемента, когда тот достигает границы. Например, это правило позволяет Compose Pager и Compose LazyRow эффективно работать вместе. Однако, когда прокрутка взаимодействия выполняется с помощью ViewPager2 или RecyclerView , непрерывная прокрутка от дочернего элемента к родительскому невозможна, поскольку они не реализуют NestedScrollingParent3 .

Чтобы включить API взаимодействия вложенной прокрутки между прокручиваемыми элементами View и прокручиваемыми составными элементами, вложенными в обоих направлениях, вы можете использовать API взаимодействия вложенной прокрутки для смягчения этих проблем в следующих сценариях.

Взаимодействующее родительское View содержащее дочернее представление ComposeView

Взаимодействующий родительский View — это элемент, который уже реализует NestedScrollingParent3 и, следовательно, может получать дельты прокрутки от взаимодействующего вложенного дочернего компонуемого элемента. ComposeView в этом случае будет действовать как дочерний элемент и должен будет (косвенно) реализовать NestedScrollingChild3 . Одним из примеров взаимодействующего родительского элемента является androidx.coordinatorlayout.widget.CoordinatorLayout .

Если вам требуется вложенная прокручиваемая совместимость между прокручиваемыми родительскими контейнерами View и вложенными прокручиваемыми дочерними составными элементами, вы можете использовать rememberNestedScrollInteropConnection() .

rememberNestedScrollInteropConnection() позволяет и запоминает NestedScrollConnection , который обеспечивает взаимодействие с помощью вложенной прокрутки между родительским элементом View , реализующим NestedScrollingParent3 и дочерним элементом Compose. Его следует использовать вместе с модификатором nestedScroll . Поскольку вложенная прокрутка включена по умолчанию на стороне Compose, это соединение можно использовать для включения вложенной прокрутки на стороне View и добавления необходимой связующей логики между Views и компонуемыми элементами.

Распространенным вариантом использования является использование CoordinatorLayout , CollapsingToolbarLayout и дочернего компонуемого элемента, как показано в этом примере:

<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:fitsSystemWindows="true">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/collapsing_toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <!--...-->

        </com.google.android.material.appbar.CollapsingToolbarLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

В вашей Activity или Fragment вам необходимо настроить дочерний компонуемый элемент и требуемый NestedScrollConnection :

open class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<ComposeView>(R.id.compose_view).apply {
            setContent {
                val nestedScrollInterop = rememberNestedScrollInteropConnection()
                // Add the nested scroll connection to your top level @Composable element
                // using the nestedScroll modifier.
                LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop)) {
                    items(20) { item ->
                        Box(
                            modifier = Modifier
                                .padding(16.dp)
                                .height(56.dp)
                                .fillMaxWidth()
                                .background(Color.Gray),
                            contentAlignment = Alignment.Center
                        ) {
                            Text(item.toString())
                        }
                    }
                }
            }
        }
    }
}

Родительский компонуемый элемент, содержащий дочерний AndroidView

Этот сценарий описывает реализацию API взаимодействия вложенной прокрутки на стороне Compose, когда у вас есть родительский компонуемый элемент, содержащий дочерний AndroidView . AndroidView реализует NestedScrollDispatcher , поскольку он является дочерним элементом для прокручиваемого родительского элемента Compose, а также NestedScrollingParent3 , поскольку он является родительским элементом для прокручиваемого дочернего элемента View . В этом случае родительский элемент Compose сможет получать дельты вложенной прокрутки от вложенного прокручиваемого дочернего элемента View .

В следующем примере показано, как можно реализовать взаимодействие вложенной прокрутки в этом сценарии, а также сворачивающуюся панель инструментов Compose:

@Composable
private fun NestedScrollInteropComposeParentWithAndroidChildExample() {
    val toolbarHeightPx = with(LocalDensity.current) { ToolbarHeight.roundToPx().toFloat() }
    val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }

    // Sets up the nested scroll connection between the Box composable parent
    // and the child AndroidView containing the RecyclerView
    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // Updates the toolbar offset based on the scroll to enable
                // collapsible behaviour
                val delta = available.y
                val newOffset = toolbarOffsetHeightPx.value + delta
                toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
                return Offset.Zero
            }
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .nestedScroll(nestedScrollConnection)
    ) {
        TopAppBar(
            modifier = Modifier
                .height(ToolbarHeight)
                .offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) }
        )

        AndroidView(
            { context ->
                LayoutInflater.from(context)
                    .inflate(R.layout.view_in_compose_nested_scroll_interop, null).apply {
                        with(findViewById<RecyclerView>(R.id.main_list)) {
                            layoutManager = LinearLayoutManager(context, VERTICAL, false)
                            adapter = NestedScrollInteropAdapter()
                        }
                    }.also {
                        // Nested scrolling interop is enabled when
                        // nested scroll is enabled for the root View
                        ViewCompat.setNestedScrollingEnabled(it, true)
                    }
            },
            // ...
        )
    }
}

private class NestedScrollInteropAdapter :
    Adapter<NestedScrollInteropAdapter.NestedScrollInteropViewHolder>() {
    val items = (1..10).map { it.toString() }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): NestedScrollInteropViewHolder {
        return NestedScrollInteropViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(R.layout.list_item, parent, false)
        )
    }

    override fun onBindViewHolder(holder: NestedScrollInteropViewHolder, position: Int) {
        // ...
    }

    class NestedScrollInteropViewHolder(view: View) : ViewHolder(view) {
        fun bind(item: String) {
            // ...
        }
    }
    // ...
}

В этом примере показано, как можно использовать API с scrollable модификатором:

@Composable
fun ViewInComposeNestedScrollInteropExample() {
    Box(
        Modifier
            .fillMaxSize()
            .scrollable(rememberScrollableState {
                // View component deltas should be reflected in Compose
                // components that participate in nested scrolling
                it
            }, Orientation.Vertical)
    ) {
        AndroidView(
            { context ->
                LayoutInflater.from(context)
                    .inflate(android.R.layout.list_item, null)
                    .apply {
                        // Nested scrolling interop is enabled when
                        // nested scroll is enabled for the root View
                        ViewCompat.setNestedScrollingEnabled(this, true)
                    }
            }
        )
    }
}

И наконец, в этом примере показано, как API взаимодействия вложенной прокрутки используется с BottomSheetDialogFragment для достижения успешного поведения перетаскивания и закрытия:

class BottomSheetFragment : BottomSheetDialogFragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val rootView: View = inflater.inflate(R.layout.fragment_bottom_sheet, container, false)

        rootView.findViewById<ComposeView>(R.id.compose_view).apply {
            setContent {
                val nestedScrollInterop = rememberNestedScrollInteropConnection()
                LazyColumn(
                    Modifier
                        .nestedScroll(nestedScrollInterop)
                        .fillMaxSize()
                ) {
                    item {
                        Text(text = "Bottom sheet title")
                    }
                    items(10) {
                        Text(
                            text = "List item number $it",
                            modifier = Modifier.fillMaxWidth()
                        )
                    }
                }
            }
            return rootView
        }
    }
}

Обратите внимание, что rememberNestedScrollInteropConnection() установит NestedScrollConnection в элементе, к которому вы его прикрепляете. NestedScrollConnection отвечает за передачу дельт с уровня Compose на уровень View . Это позволяет элементу участвовать во вложенной прокрутке, но не включает автоматическую прокрутку элементов. Для компонуемых элементов, которые не прокручиваются автоматически, таких как Box или Column , дельты прокрутки таких компонентов не будут распространяться во вложенной системе прокрутки и не достигнут NestedScrollConnection предоставляемого rememberNestedScrollInteropConnection() , поэтому эти дельты не достигнут родительского компонента View . Чтобы решить эту проблему, убедитесь, что вы также установили модификаторы scrollable для этих типов вложенных компонуемых элементов. Вы можете обратиться к предыдущему разделу о вложенной прокрутке для получения более подробной информации.

Несотрудничающий родительский View содержащий дочерний ComposeView

Несотрудничающее представление — это представление, которое не реализует необходимые интерфейсы NestedScrolling на стороне View . Обратите внимание, что это означает, что взаимодействие с вложенной прокруткой с этими Views не реализовано «из коробки». Несотрудничающие Views — это RecyclerView и ViewPager2 .

Дополнительные ресурсы

{% дословно %} {% endverbatim %} {% дословно %} {% endverbatim %}