Представьте классический сценарий в мобильном приложении: пользователю нужно выбрать год рождения, час будильника или количество товаров в корзине. На iOS для таких случаев давно существует элегантное и интуитивное решение — Wheel Picker (или UIPickerView). Этот компонент стал неотъемлемой частью языка дизайна Apple.

Для Android существуют свои альтернативы. NumberPicker — пожалуй, самый близкий по духу аналог iOS Wheel Picker из стандартной библиотеки Android. Он напоминает «колесо» с прокруткой, но с характерной «угловатостью», минимальной кастомизацией и специфичным поведением, которое далеко не всегда вписывается в современный дизайн, зачастую очень близкий между различными платформами.

Это сподвигло меня на написание своего «колеса» с 3D трансформацией. Наше «колесо» должно:

  • Иметь настраиваемую высоту элементов и настраиваемое количество видимых элементов

  • Иметь наблюдаемое состояние

  • Уметь программно с анимацией менять текущий выбранный элемент

  • Уметь работать с бесконечным списком

  • Уметь восстанавливать свое состояние

Базовый компонент

Весь код доступен в моём GitHub-репозитории

За основу я выберу LazyColumn, он принимает LazyListState, который и дает нам широкие возможности для взаимодействия со списком. Создадим класс WheelPickerState, который будет инкапсулировать логику работы со списком (многие части кода в этом классе заимствованы из библиотеки compose-cupertino).

@Stable
class WheelPickerState(
    internal val infinite: Boolean = false,
    internal val initiallySelectedItemIndex: Int = 0
)

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

private val nativeIndex = if (infinite) {
        INFINITE_OFFSET + initiallySelectedItemIndex
    } else {
        initiallySelectedItemIndex
    }

const val INFINITE_OFFSET = Int.MAX_VALUE / 2 // ≈ 1 млрд.

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


Далее создадим internal переменную lazyListState и укажем начально выбранный индекс элемента.

internal val lazyListState: LazyListState = LazyListState(firstVisibleItemIndex = nativeIndex)

А также публичные вспомогательные состояния, которые будут транслировать некоторые поля LazyListState'а.

    val canScrollBackward: Boolean
        get() = lazyListState.canScrollBackward

    val canScrollForward: Boolean
        get() = lazyListState.canScrollForward

    val isScrollInProgress: Boolean
        get() = lazyListState.isScrollInProgress

    val interactionSource: InteractionSource
        get() = lazyListState.interactionSource

Выбранный элемент всегда будет находиться вертикально по центру нашего «колеса», поэтому мы уже можем можем его определить.

private val selectedItem by derivedStateOf {
        with(lazyListState.layoutInfo) {
            visibleItemsInfo.fastFirstOrNull {
                it.offset + it.size - viewportStartOffset > viewportSize.height / 2
            }
        }
    }

// Может быть отрицательным
internal val currentSelectedItemIndex by derivedStateOf {
        if (infinite) {
            selectedItem?.index?.minus(INFINITE_OFFSET)
        } else {
            selectedItem?.index
        } ?: initiallySelectedItemIndex
    }

Также напишем @Composable функцию для создания экземпляра этого state'а.

@Composable
fun rememberWheelPickerState(
    infinite: Boolean = false,
    initialIndex: Int = 0,
): WheelPickerState {

    return rememberSaveable(
        saver = WheelPickerState.Saver()
    ) {
        WheelPickerState(infinite, initialIndex)
    }
}

// WheelPickerState.companion object:
companion object {
  fun Saver() = listSaver(
            save = {
                listOf(it.infinite, it.currentSelectedItemIndex)
            },
            restore = {
                WheelPickerState(it[0] as? Boolean ?: false, it[1] as? Int ?: 0)
            }
        )
}

Так как список может быть бесконечным, то необходимо преобразовать выбранный элемент LazyList'а в индекс элемента в входящем списке. Для этого напишем несколько дополнительных функций.

    fun selectedItem(itemsCount: Int) = currentSelectedItemIndex.modSign(itemsCount)

    private fun selectedItemState(itemsCount : Int) : State<Int> {
        return derivedStateOf { currentSelectedItemIndex.modSign(itemsCount) }
    }

    @Composable
    fun selectedItemIndex(totalItemsCount: Int): Int =
        remember(totalItemsCount) {
            derivedStateOf {
                selectedItem(totalItemsCount)
            }
        }.value

fun Int.modSign(o: Int): Int = mod(o).let {
    if (it >= 0) it else this - it
}

Именно с помощью них мы и будем отслеживать, какой элемент списка выбран в данный момент.

UI-компонент

Создадим @Composable функцию WheelPicker, в которую передадим необходимые аргументы.

@Composable
fun <T> WheelPicker(
    modifier: Modifier = Modifier,
    data: List<T>,
    key: (index: Int) -> String? = { null },
    itemContent: @Composable (Int) -> Unit,
    state: WheelPickerState,
    nonFocusedItems: Int = 6,
    contentAlignment: Alignment = Alignment.Center,
    contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp),
    itemHeightDp: Dp = 44.dp,
) {
    // редактируем количество так, чтобы получилось нечетное количество элементов
    val visibleItems = nonFocusedItems / 2 * 2 + 1

    // Функция для преобразования индекса LazyList'a в индекс в списке
    fun getListIndex(index: Int) =
        if (state.infinite) {
            ((index - WheelPickerState.INFINITE_OFFSET) % data.size).let {
                if (it >= 0) it else data.size - abs(it)
            }
        } else {
            index
        }

    val height = itemHeightDp * (visibleItems)
    val scope = rememberCoroutineScope()
    val density = LocalDensity.current

    val itemHeightPx by remember {
        mutableIntStateOf(with(density) { itemHeightDp.toPx().roundToInt() })
    }

    // отступы для ContentPadding'а
    val edgeOffsetPx = remember(visibleItems, itemHeightPx) {
        (with(density) { height.toPx() } - itemHeightPx) / 2
    }
    val edgeOffsetDp = remember(visibleItems) {
        with(density) { edgeOffsetPx.absoluteValue.toDp() }
    }

    LazyColumn(
        state = state.lazyListState,
        modifier = Modifier
            .requiredHeight(height),
        contentPadding = PaddingValues(vertical = edgeOffsetDp)
    ) {
        items(
            count = if (state.infinite) Int.MAX_VALUE else data.size,
            key = { index -> key(getListIndex(index)) + "_$index" }
        ) { index ->

            // Находим индекс элемента в списке
            val listIndex = getListIndex(index)

            ItemWrapper(
                modifier = Modifier.fillMaxWidth(),
                onItemClick = {
                    scope.launch {
                        state.lazyListState.animateScrollToItem(index)
                    }
                },
                itemHeightDp = itemHeightDp,
                contentPadding = contentPadding,
                contentAlignment = contentAlignment,
            ) {
                itemContent(listIndex)
            }
        }
    }
}

// Обертка для элемента
@Composable
private fun ItemWrapper(
    modifier: Modifier,
    onItemClick: () -> Unit,
    itemHeightDp: Dp,
    contentPadding: PaddingValues,
    contentAlignment: Alignment,
    content: @Composable () -> Unit
) {
    Box(
        modifier = modifier
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                onClick = onItemClick
            )
            .requiredHeight(itemHeightDp)
            .padding(contentPadding),
        contentAlignment = contentAlignment
    ) {
        content()
    }
}

В экране применим эту функцию.

    val list = buildList {
        repeat(10) {
            add("Item ${(it + 1)}")
        }
    }
    Column(
        modifier = Modifier.background(Color.White)
    ) {
        WheelPicker(
            data = list,
            itemContent = {
                Text(
                    text = list[it],
                    color = Color.Black,
                    fontSize = 18.sp
                )
            },
            state = rememberWheelPickerState(
              initialIndex = 4
            )
        )
    }

Получаем следующий результат:

Стандартный LazyColumn
Стандартный LazyColumn

Ничего необычного, стандартный LazyColumn.

Оверлей

Далее поверх LazyColumn нарисуем оверлей - индикатор выбранного элемента

@Stable
data class OverlayConfiguration(
    val scrimColor: Color = Color.White.copy(alpha = 0.7f),
    val focusColor: Color = Color.Gray.copy(alpha = 0.4f),
    val cornerRadius: Dp = 8.dp,
    val horizontalPadding: Dp = 8.dp,
    val verticalPadding: Dp = -2.dp
)

fun CacheDrawScope.pickerOverlay(
        edgeOffsetYPx: Float,
        itemHeightPx: Int,
        overlay: OverlayConfiguration,
    ): DrawResult {
        val w = this.size.width
        val h = this.size.height
        val radius = overlay.cornerRadius.toPx()
        val verticalPadding = overlay.verticalPadding.toPx()
        val horizontalPadding = overlay.horizontalPadding.toPx()

        val scrimHeight = edgeOffsetYPx + verticalPadding
        val highlightHeight = itemHeightPx - verticalPadding * 2
        val path = getCenterItemPath(
            width = w,
            height = h,
            itemHeightPx = itemHeightPx,
            edgeOffsetY = edgeOffsetYPx,
            horizontalPadding = horizontalPadding,
            verticalPadding = verticalPadding,
            radius = radius
        )
        return onDrawWithContent {
            drawContent()
            this.drawPath(path, overlay.scrimColor)

            this.drawRoundRect(
                color = overlay.focusColor,
                topLeft = Offset(x = horizontalPadding, y = scrimHeight),
                size = Size(w - horizontalPadding * 2, highlightHeight),
                cornerRadius = CornerRadius(radius),
                style = Fill,
            )
        }
    }

// Path с вырезанным скругленным прямоугольником посередине
private fun getCenterItemPath(
        width: Float,
        height: Float,
        itemHeightPx: Int,
        edgeOffsetY: Float,
        horizontalPadding: Float = 0f,
        verticalPadding: Float = 0f,
        radius: Float
    ): Path {
        val boundPath = Path().apply {
            addRect(Rect(0f,0f,width,height))
        }
        val focusPath = Path().apply {
            val left = horizontalPadding
            val top = edgeOffsetY + verticalPadding
            val right = width - horizontalPadding
            val bottom = edgeOffsetY + itemHeightPx - verticalPadding
            addRoundRect(RoundRect(left, top, right, bottom, radius, radius))
        }
        val resPath = Path()
        resPath.op(boundPath,focusPath, PathOperation.Difference)
        return resPath
    }
LazyColumn(
        state = state.lazyListState,
        modifier = Modifier
            .requiredHeight(height)
            .drawWithCache {
                pickerOverlay(
                    edgeOffsetYPx = edgeOffsetPx,
                    itemHeightPx = itemHeightPx,
                    overlay = overlay
                )
            },
        contentPadding = PaddingValues(vertical = edgeOffsetDp)
    ) {
      ...
}

И получим такой результат:

LazyColumn с оверлеем
LazyColumn с оверлеем

Уже вырисовывается нечто похожее на «колесо».

Закрепление выбранного элемента

Добавим механизм закрепления выбранного элемента. Для этого в LazyColumn существует flingBehavior

LazyColumn(
        state = state.lazyListState,
        modifier = Modifier
            .requiredHeight(height)
            .drawWithCache {
                pickerOverlay(
                    edgeOffsetYPx = edgeOffsetPx,
                    itemHeightPx = itemHeightPx,
                    overlay = overlay
                )
            },
        contentPadding = PaddingValues(vertical = edgeOffsetDp),
        flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)
    ) { 
      ...
}

И, в целом, уже получился готовый к использованию компонент. Не хватает только 3D эффекта вращения «колеса».

3D эффект вращения

Одной из ключевых особенностей WheelPicker в iOS является его объёмный вид - элементы не просто двигаются вверх-вниз, а вращаются вокруг оси.

Picker Wheel в iOS
Picker Wheel в iOS

В View-отладчике эти элементы отображаются следующим образом:

Вид спереди
Вид спереди
Вид сбоку
Вид сбоку

Наложим поверх изображения систему координат Android:

Система координат
Система координат

Ключевые моменты:

  • Элементы «колеса» вращаются вокруг оси X. Чем дальше элемент от оси X, тем больше его угол поворота

  • Элементы имеют постоянную высоту

  • За счет перспективы элементы сужаются (визуально)

Математическая модель вращения

Построим математическую модель вращения нашего «колеса».

Определим коэффициент смещения элемента отно��ительно центра видимой области

offsetFraction = (itemCenterY - viewportCenterY) / viewportCenterY

Угол поворота тогда будет

rotationX = -90 * offsetFraction

Реализуем это в коде

private fun GraphicsLayerScope.render3DVerticalItemEffect(
    index: Int,
    getLayoutInfo: () -> LazyListLayoutInfo,
    transformOrigin: TransformOrigin = TransformOrigin.Center,
) {
    val layoutInfo = getLayoutInfo()
    // Информацию об элементе можно получить из LazyListLayoutInfo
    val itemInfo = layoutInfo.visibleItemsInfo.find { item -> item.index == index }
        ?: return

    val itemCenterY = getItemCenter(itemInfo) + layoutInfo.beforeContentPadding
    val viewportCenterY = layoutInfo.viewportSize.height / 2F

    val offsetFraction = (itemCenterY - viewportCenterY) / viewportCenterY

    // Визуальное сужение элемента (квадратичная функция с коэффициентом создает более плавный эффект)
    val scale = 1 - (offsetFraction.absoluteValue).pow(2) * 0.1f
    scaleX = scale

    rotationX = -90 * offsetFraction
    this.transformOrigin = transformOrigin
}

private fun getItemCenter(itemInfo: LazyListItemInfo): Float {
    val itemCenterY = itemInfo.size / 2F
    return itemInfo.offset.toFloat() + itemCenterY
}

Добавим логику в ItemWrapper:

@Composable
private fun ItemWrapper(
    modifier: Modifier,
    onItemClick: () -> Unit,
    itemHeightDp: Dp,
    contentPadding: PaddingValues,
    contentAlignment: Alignment,
    index: Int,
    getLayoutInfo: () -> LazyListLayoutInfo,
    transformOrigin: TransformOrigin = TransformOrigin.Center,
    content: @Composable () -> Unit
) {
    Box(
        modifier = modifier
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                onClick = onItemClick
            )
            .requiredHeight(itemHeightDp)
            .graphicsLayer {
                render3DVerticalItemEffect(
                    index = index,
                    getLayoutInfo = getLayoutInfo
                )
            }
            .border( // для отладки
                1.dp,
                Color.Red
            )
            .padding(contentPadding),
        contentAlignment = contentAlignment
    ) {
        content()
    }
}

//LazyColumn:
        items(
            count = if (state.infinite) Int.MAX_VALUE else data.size,
            key = { index -> key(getNativeIndex(index)) + "_$index" }
        ) { index ->

            val listIndex = getNativeIndex(index)

            ItemWrapper(
                modifier = Modifier.fillMaxWidth(),
                onItemClick = {
                    scope.launch {
                        state.lazyListState.animateScrollToItem(index)
                    }
                },
                itemHeightDp = itemHeightDp,
                contentPadding = contentPadding,
                contentAlignment = contentAlignment,
                index = index,
                getLayoutInfo = {
                    state.lazyListState.layoutInfo
                },
                transformOrdigin = transformOrigin
            ) {
                itemContent(listIndex)
            }
        }

Получаем такой результат:

Видно, что элементы расположены неестественным образом, к тому же не соблюдается перспектива. Поправим это

// GraphicsLayerScope.render3DVerticalItemEffect
    // Не показываем элементы, которые не попадают в viewport
    if (offsetFraction.absoluteValue >= 1.0f) {
        alpha = 0f
    }

    // Определяем радиус колеса (L = π * r = viewportHeight / curveRate (считаем, что длина кривой равна сумме высот всех элементов, т.е. весь viewport, деленный на коэффициент кривой, т.к. длина окружности меньше суммы всех видимых элементов))
    val r = (2f * viewportCenterY / curveRate / Math.PI).toFloat()

    translationY = if (offsetFraction == 0f) {
        0f
    } else {
        // Определяем положение элемента по вертикали (вспоминаем тригонометрию)
        val h =
            (sin(Math.toRadians(offsetFraction.absoluteValue * 90.0)) * r).toFloat()

        // Вычисляем смещение элемента по вертикали
        val diffY = if (offsetFraction < 0) {
            (viewportCenterY - h.absoluteValue) - itemCenterY.absoluteValue
        } else {
            (viewportCenterY + h.absoluteValue) - itemCenterY.absoluteValue
        }
        diffY
    }

    // Добавляем перспективу (значение вычислено эмпирически)
    this.cameraDistance = layoutInfo.viewportSize.height.toFloat() / 22f

// Коэффициент кривой, можно поставить свой
private val curveRate = 1.0f
private const val viewportCurveRate = 0.653f //  При этом коэффициенте заполняется весь viewport, получен эмпирически

В Jetpack Compose 3D-преобразования используют камеру для рендеринга. cameraDistance определяет расстояние от камеры до плоскости контента. Чем меньше значение, тем сильнее эффект перспективы (как при использовании широкоугольного объектива)

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

// Переносим обработку нажатия на элемент из ItemWrapper в LazyColumn
    LazyColumn(
                state = state.lazyListState,
                modifier = Modifier
                    .requiredHeight(height)
                    .drawWithCache {
                        pickerOverlay(
                            edgeOffsetYPx = edgeOffsetPx,
                            itemHeightPx = itemHeightPx,
                            overlay = overlay
                        )
                    }
                    .pointerInput(Unit) {
                        detectTapGestures {
                            val clickedItem = calculateTapItem(
                                tapOffset = it,
                                getLayoutInfo = {
                                    state.lazyListState.layoutInfo
                                }
                            )

                            clickedItem?.let {
                                scope.launch {
                                    state.lazyListState.animateScrollToItem(it)
                                }
                            }
                        }
                    },
                contentPadding = PaddingValues(vertical = edgeOffsetDp),
                flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)
            )

    
private fun calculateTapItem(
    tapOffset: Offset,
    getLayoutInfo: () -> LazyListLayoutInfo
): Int? {
    val layoutInfo = getLayoutInfo()
    val viewportCenterY = layoutInfo.viewportSize.height / 2F

    return layoutInfo.visibleItemsInfo.find {
        val itemCenterY = getItemCenter(it) + layoutInfo.beforeContentPadding

        val offsetFraction = (itemCenterY - viewportCenterY) / viewportCenterY

        val r = (2f * viewportCenterY  / curveRate / Math.PI).toFloat()

        val h =
            (sin(Math.toRadians(offsetFraction.absoluteValue * 90.0)) * r).toFloat()
        val diffY = if (offsetFraction < 0) {
            (viewportCenterY - h.absoluteValue) - itemCenterY.absoluteValue
        } else {
            (viewportCenterY + h.absoluteValue) - itemCenterY.absoluteValue
        }
        // высота элемента, которую видит пользователь
        val itemHeightVisible = it.size * cos(Math.toRadians(offsetFraction.absoluteValue * 90.0))

        // Находим, в границы какого элемента попадает tapOffset 
        tapOffset.y in (itemCenterY + diffY - itemHeightVisible / 2) .. (itemCenterY + diffY + itemHeightVisible / 2)
    }?.index // возвращаем индекс элемента
}

Наконец, получилось соблюсти перспективу и добиться нужного поведения.

«Колесо» с curveRate = 1.0f
«Колесо» с curveRate = 1.0f

Для curveRate = 0.775f. Элементы стали располагаться чуть дальше друг от друга:

«Колесо» с curveRate = 0.775f
«Колесо» с curveRate = 0.775f

Если посмотреть на границы макета, можно увидеть, что появляются отступы сверху и снизу.

Отступы по вертикали, которые появляются из-за смещения элементов
Отступы по вертикали, которые появляются из-за смещения элементов

Чтобы поправить это, не меняя наших расчетов можно поступить следующим образом:

    Box(
        modifier = modifier
            .height(height / (curveRate / viewportCurveRate))
            .clipScrollableContainer(orientation = Orientation.Vertical) // обрезаем контент по вертикали, чтобы не накладывался оверлей на контент вне колеса
    ) {
      LazyColumn(...)
    }

Отступы ушли.

Корректные границы UI-компонента
Корректные границы UI-компонента

Также дополним наш WheelPickerState, чтобы можно было снаружи менять выбранный элемент (учтем, что список может быть бесконечным):

    var isChangingProgrammatically: Boolean by mutableStateOf(false)

    suspend fun animateScrollToItem(index: Int, totalItemsCount: Int) {
        if (index >= 0) {
            if (infinite) {
                val currentIndex = currentSelectedItemIndex + INFINITE_OFFSET
                val selectedItem = selectedItem(totalItemsCount)
                if (selectedItem == index) return // Ничего не делаем
              
                // Находим, на сколько нужно прокрутить список от текущего элемента
                val diff = calculateOptimalShift(totalItemsCount, selectedItem, index)

                // Проверка, что мы не уйдем за границы списка LazyList'а
                val append = if (diff.first > 0) {
                    if (diff.first + currentIndex < Int.MAX_VALUE) diff.first else diff.second
                } else {
                    if (diff.first + currentIndex > 0) diff.first else diff.second
                }
                animateScrollToItemInternal(currentIndex + append)
            } else {
                animateScrollToItemInternal(index)
            }
        }
    }

    /**
     * @return [Pair] Пара первичная и вторичная разницы между выбранным и желаемым элементами
     * @param totalItemsCount общее количество элементов в списке
     * @param selectedIndex текущий выбранный элемент в списке
     * @param targetIndex желаемый индекс
     */
      private fun calculateOptimalShift(totalItemsCount: Int, selectedIndex: Int, targetIndex: Int): Pair<Int, Int> {
        if (totalItemsCount <= 0) return 0 to 0

        // Нормализуем индексы относительно списка
        val normalizedSelected = ((selectedIndex % totalItemsCount) + totalItemsCount) % totalItemsCount
        val normalizedTarget = ((targetIndex % totalItemsCount) + totalItemsCount) % totalItemsCount

        if (normalizedSelected == normalizedTarget) return 0 to 0

        //Смещение вправо
        val forwardShift = (normalizedTarget - normalizedSelected + totalItemsCount) % totalItemsCount
        // Смещение влево (отрицательное)
        val backwardShift = forwardShift - totalItemsCount

        // Возвращаем минимальное по модулю значение
        return if (forwardShift <= -backwardShift) forwardShift to backwardShift else backwardShift to forwardShift
    }

И наше «колесо» готово:

На этоммоменте я закончу первую часть и подведу небольшой итог. Мы реализовали логику 3D‑вращения колеса, которое умеет сохранять и восстанавливать свое со��тояние. В следующей статье мы реализуем MultiWheelPicker — расширенную версию пикера, которая позволит объединять несколько независимых «колёс» в единый интерфейс. Такой компонент используется в системе iOS для выбора даты или времени.