Вдохновившись классными колесиками для выбора времени и даты напоминаний Telegram, я захотел сделать на одном из своих пет‑проектов что‑то подобное. Первой мыслью было — найти этот код в исходниках Telegram, но т.к. скорее всего, у них это написано на Java, я решил не играть в лотерею и не тратить время на раскопки в Java‑коде, потому что я хотел сделать это на Jetpack Compose.

На Habr я нашел только одну похожую статью 2015го года, написанную на Java.

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

С виду его можно сразу разделить на части:

  • Колонка с данными (3 шт)

  • Рамочка для выбранного значения

  • Кнопка (опционально)

Time picker

Верстка

Написать одну колонку - значит, написать все 3. Я начну с цифр.

@Composable
internal fun TimeColumnPicker(
    initialValue: Int,
    onValueChange: (Int) -> Unit,
    range: IntRange,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current
    val listState = rememberLazyListState(initialFirstVisibleItemIndex = initialValue)

    // Генерация списка значений времени.
    val list by remember {
        mutableStateOf(mutableListOf<String>().apply {
            (1..(countOfVisibleItemsInPicker / 2)).forEach { _ -> add("") }
            for (i in range) add(i.getTimeDefaultStr())
            (1..(countOfVisibleItemsInPicker / 2)).forEach { _ -> add("") }
        })
    }

    var selectedValue by remember { mutableIntStateOf(initialValue) }
    var firstIndex by remember { mutableStateOf(0) }
    var lastIndex by remember { mutableStateOf(0) }

    Box(
        modifier = modifier.height(listHeight.dp),
        contentAlignment = Alignment.Center
    ) {
        Border(itemHeight = itemHeight.dp, color = Theme.colors.oppositeTheme)

        LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
            itemsIndexed(items = list) { index, it ->
                    Box(
                        modifier = Modifier.fillParentMaxHeight(1f / countOfVisibleItemsInPicker),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(
                            text = it,
                            fontSize = FontSize.medium19,
                        )
                    }
            }
        }
    }
}

Цвета кстати задаются с помощью кастомной темы, об этом писал в этой статье.

Обращу внимание здесь на fillMaxParentHeight: в отличие от fillMaxHeight занимает всю высоту родителя, не растягивая его.

Функция getTimeDefaultStr здесь возвращает строковое представление числа, если в нем 2 цифры, и добавляет 0, если одна:

fun Int.getTimeDefaultStr(): String =  "${if (this <= 9) "0" else ""}$this"

Чтобы Date&&Time пикер выглядел одинаково на всех устройствах, а не разъезжался, а определил ему константную высоту, которую в последствие можно заменять. Плюсом ко всему, я определил еще 2 видимых внутри модуля переменных для удобной модификации без раскопок в коде.

// Количество видимых элементов в столбце  
internal const val countOfVisibleItemsInPicker = 5  
  
// Высота одного элемента  
internal const val itemHeight = 35f  
  
// Высота списка  
internal const val listHeight = countOfVisibleItemsInPicker * itemHeight

Обращаю внимание на то, что я задаю без размерностей во благо реюза.

Более того, пока что я не нашел лучшего способа выровнять текст по середине, т.к. почему-то, если в Text задать размеры и выравнивание по центру, он будет по центру по горизонтали, но все же вверху.

Последний элемент — рамочка, и можно приступать к логике. «Ничего лучше, (чем Row) не придумал» (Возможно, в комментариях кто‑то предложит лучший вариант)

@Composable  
internal fun Border(itemHeight: Dp, color: Color) {  
    val width = 2.dp  
    val strokeWidthPx = with(LocalDensity.current) { width.toPx() }  
    Row(  
        modifier = Modifier  
            .fillMaxWidth()  
            .height(itemHeight)  
            .drawBehind {  
                drawLine(  
                    color = color,  
                    strokeWidth = strokeWidthPx,  
                    start = Offset(0f, 0f),  
                    end = Offset(size.width, 0f)
                )  
                
                drawLine(  
                    color = color,  
                    strokeWidth = strokeWidthPx,  
                    start = Offset(0f, size.height),  
                    end = Offset(size.width, size.height)  
                )  
            }  
    ) {}  
}

Рамочка будет также иметь высоту — высоту айтема. Ее нужно вставить в Box.
В первых двух строках мы задаем толщину линии в dp и переводим ее в пиксели в зависимости от девайса через LocalDensity, функция drawBehind позволяет рисовать на фоне элемента. Рисуем 2 линии:

  • от точки (0,0), до точки (x, 0) - горизонтальная линия сверху

  • от точки (0,y), до точки (x, y) - горизонтальная линия снизу.

    x,y - значение ширины и высоты элемента соответственно.

Логика

А теперь самое интересное. (То, к чему я шел очень долго, поскольку я не люблю работать с offset'ами в рекомпозиции, но без этого никак). У нас есть 2 задачи:

  • Выравнивать список, если пользователь закончил взаимодействие «не ровно»

  • Отсылать данные для дальнейшей обработки выше по дереву вызовов.

LaunchedEffect(listState.isScrollInProgress) {  
    if (!listState.isScrollInProgress && listState.firstVisibleItemScrollOffset.pixelsToDp(  
            context  
        ) % itemHeight != 0f // иначе будет постоянная рекомпозиция  
    ) {  
        // Перемотка к центральному элементу  
    listState.animateScrollToItem(listState.itemForScrollTo(context = context))  
    }  
}

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

А во второй — нужно проверять, когда пользователь оставил список «не ровно» умирать. Иначе будет бесконечная рекомпозиция, поскольку список будет пытаться прокручиваться даже когда он выровнен.

Поможет offset — количество пикселей, на которое уже промотали список. Считается от начальной верхней границы до текущей верхней границы первого видимого элемента.
Остатком от деления как раз мы и узнаем, на сколько пикселей виден последний элемент.
pixelsToDp — метод, найденный на просторах интернета.

fun Int.pixelsToDp(context: Context): Float {  
    val densityDpi = context.resources.displayMetrics.densityDpi  
    return this / (densityDpi / 160f)  
}

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

Выглядит это так:

internal fun LazyListState.itemForScrollTo(context: Context): Int {  
    val offset = firstVisibleItemScrollOffset.pixelsToDp(context)  
    return when {  
        offset == 0f -> firstVisibleItemIndex  
        offset % itemHeight >= itemHeight / 2 -> firstVisibleItemIndex + 1  
        else -> firstVisibleItemIndex  
    }  // здесь можно конвертировать в простой if, но для наглядности оставлю
}

Рассчитываем offset. Далее попадаем в ситуацию, когда нужно решить, к какому элементу скроллить.

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

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

Далее прокрутка:

На картинке высота элемента составляет 50dp (изменил начальное значение на более удобное), т.е. до НИЖНЕЙ грани оранжевого 24 с копейками, а значит, он виден меньше чем на половину - прокручиваем к firstVisibleIndex + 1, иначе (если б значение на экране было, допустим, 23dp) получается, что оранжевый виден на 27dp == больше половины, значит прокрутить уже нужно к firstVisibleIndex.

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

val offset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } }
LaunchedEffect(offset) {
        val newValue =
            list[listState.itemForScrollTo(context) + countOfVisibleItemsInPicker / 2].toIntOrNull()
        if (newValue != null && newValue != selectedValue) {
            onValueChange(newValue)
            selectedValue = newValue
        }
    }

Здесь все намного проще, чем в прошлом пункте. Мы каждый раз рассчитываем выбранный элемент, и если он не совпадает с выбранным в прошлый раз, onValueChange, передавая новое значение и фиксируя его также в selectedValue для следующих проверок. listState.itemForScrollTo(context) + countOfVisibleItemsInPicker/2 - центральный элемент списка.

toIntOrNull здесь для безопасности, потому что когда мы заполняли список первое и последнее значение были == "", потому что иначе список просто не даст нам прокрутиться до первого и последнего элемента (список не даст прокрутиться, но учесть этот случай я считаю нужным).

Вот как это выглядит. В пустых квадратиках те самые пустые строки.

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

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

  1. Сужение по ширине

  2. Сужение по высоте

    Эти 2 пункта дадут небольшую иллюзию отдаления

  3. Уменьшение прозрачности элемента

    Этот пункт как раз даст иллюзию прокрутки по колесу (имхо)

Всё это будет рассчитываться относительно расстояния от айтема до центра.

Далее будет код с подробными комментариями, чтобы не объяснять его каждую строку, ибо так статья выйдет очень большой.

internal fun calculateScaleX(listState: LazyListState, index: Int): Float {
    // Получаем информацию о текущем состоянии компоновки списка
    val layoutInfo = listState.layoutInfo
    // Извлекаем индексы видимых элементов
    val visibleItems = layoutInfo.visibleItemsInfo.map { it.index }
    // Если элемент не виден, возвращаем масштаб 1 (нормальный)
    if (!visibleItems.contains(index)) return 1f
    // Находим информацию о конкретном элементе по индексу
    val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return 1f
    // Вычисляем центр видимой области
    val center = (layoutInfo.viewportEndOffset + layoutInfo.viewportStartOffset) / 2f
    // Вычисляем расстояние от центра до середины элемента
    val distance = abs((itemInfo.offset + itemInfo.size / 2) - center)
    // Максимальное расстояние до центра для расчета масштаба
    val maxDistance = layoutInfo.viewportEndOffset / 2f
    // Сжимаем элемент до половины при максимальном расстоянии
    return 1f - (distance / maxDistance) * 0.5f
}

internal fun calculateScaleY(listState: LazyListState, index: Int): Float {
    // Получаем информацию о текущем состоянии компоновки списка
    val layoutInfo = listState.layoutInfo
    // Извлекаем индексы видимых элементов
    val visibleItems = layoutInfo.visibleItemsInfo.map { it.index }
    // Если элемент не виден, возвращаем масштаб 1 (нормальный)
    if (!visibleItems.contains(index)) return 1f
    // Находим информацию о конкретном элементе по индексу
    val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return 1f
    // Вычисляем центр видимой области
    val center = (layoutInfo.viewportEndOffset + layoutInfo.viewportStartOffset) / 2f
    // Вычисляем расстояние от центра до середины элемента
    val distance = abs((itemInfo.offset + itemInfo.size / 2) - center)
    // Максимальное расстояние до центра для расчета масштаба
    val maxDistanceY = layoutInfo.viewportEndOffset / 2f
    // Сжимаем элемент полностью при максимальном расстоянии
    return 1f - (distance / maxDistanceY)
}

internal fun calculateAlpha(index: Int, listState: LazyListState): Float {
    // Получаем информацию о текущем состоянии компоновки списка
    val layoutInfo = listState.layoutInfo
    // Извлекаем индексы видимых элементов
    val visibleItems = layoutInfo.visibleItemsInfo.map { it.index }
    // Если нет видимых элементов, возвращаем максимальную непрозрачность
    if (visibleItems.isEmpty()) return 1f
    // Вычисляем центр видимой области
    val center = (layoutInfo.viewportEndOffset + layoutInfo.viewportStartOffset) / 2f
    // Находим информацию о конкретном элементе по индексу
    val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return 1f
    // Вычисляем расстояние от центра до середины элемента
    val distance = abs((itemInfo.offset + itemInfo.size / 2) - center)
    // Максимальное расстояние для расчета прозрачности
    val maxDistance = layoutInfo.viewportEndOffset / 2f
    // Уменьшаем прозрачность до 0.3 при максимальном расстоянии
    return 1f - (distance / maxDistance) * 0.7f
}

Теперь все эти методы вставляем в текст элемента списка:

TextForThisTheme(
        modifier = Modifier.graphicsLayer(
            scaleX = calculateScaleX(listState, index),
            scaleY = calculateScaleY(listState, index),
            alpha = calculateAlpha(index, listState)
        ),
        text = it,
        fontSize = FontSize.medium19,
    )

С Time пикером все, ну или почти все. Также во внешнем Box указал modifier с маленькой буквы. Все потому, что он должен приходить, как параметр пикера, чтобы в дереве вызовов выше указать ему вес для ровной верстки и другие модификации, как в стандартном Composable. В данном случае у меня Date и Time пикеры имеют веса 2 : 1 : 1 соответственно, но можно сделать и 1 : 1 : 1, дело вкуса.

Date picker

В него изначально приходит initialDate вместо initialValue (selected аналогично) остальные параметры — callback изменения данных и modifier остаются на местах за исключением range. Дата будет считаться с сегодняшнего дня и на год вперед:

var selectedDate by remember { mutableStateOf(initialDate) }//выбранная дата 
val context = LocalContext.current  
val dateToday by remember { mutableStateOf(LocalDate.now()) }//сегодняшняя дата 
val initialDaysIndexItem by remember {  
    mutableStateOf(  
        ChronoUnit.DAYS.between(  
            dateToday,  
            selectedDate  
        ).toInt()  
    )  
}  // начальное значение для прокрутки к нужному элементу
val listState = rememberLazyListState(  
    initialFirstVisibleItemIndex = initialDaysIndexItem  
)
val list by remember {
        mutableStateOf(mutableListOf<String>().apply {
            (1..(countOfVisibleItemsInPicker / 2)).forEach { _ -> add("") }
            for (i in 0..367) add(dateToday.plusDays(i.toLong())  
                .getDateStringWithWeekOfDay(context = context)  )
            (1..(countOfVisibleItemsInPicker / 2)).forEach { _ -> add("") }
        })
    }

Функция форматирования даты у меня завязана на ресурсах, так что это «каждому свое».
Больше ничего не меняется.

Текст кнопки создается на основе callback'ов из двух этих методов.

Цель написания статьи — рассказать читателю, как создать пикер, а не дать его код целиком, поэтому date picker можно по аналогии написать самому.

Вот так выглядят плоды труда темы этой статьи внутри диалога задания напоминания - одного из моих пет-проектов:

(кнопка disabled, title or text required)

Заключение

В этой статье был рассмотрен процесс создания собственного Date и Time пикера «как в Telegram». Более того, в статье предполагается, что читатель умеет что‑то делать не по гайдам )

No errors, no warnings, gentlemen and ladies!

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


  1. namikiri
    28.10.2024 14:39

    Зачем именно такой таймпикер? Мне, например, очень нравится тот, что в стандартном Android, в виде часов, а не как в Telegram iOS барабанный. Дату же удобнее всего выбирать на календаре или ввести с клавиатуры, а не крутить барабан как в казино.

    Не надо пропагандировать сомнительные UX-решения, пожалуйста.


    1. vafeen Автор
      28.10.2024 14:39

      Дело вкуса) Консерватизм имеет место быть!


      1. namikiri
        28.10.2024 14:39

        Не консерватизм, а стремление к удобству.


        1. vafeen Автор
          28.10.2024 14:39

          Мой дорогой друг, удобство - это довольно абстрактное понятие, и у каждого оно своё, как и вкус, о котором я сказал выше)


    1. therteenten
      28.10.2024 14:39

      очень нравится тот, что в стандартном Android, в виде часов

      Не надо пропагандировать сомнительные UX-решения, пожалуйста.


  1. KivApple
    28.10.2024 14:39

    https://developer.android.com/reference/android/widget/NumberPicker

    Вот суть в том, что он на самом деле может выбирать далеко не только Number, если дать ему кастомный NumberPicker.Formatter.

    Выглядит один в один как то что в Telegram и то что вы сделали. Разумеется, потребуется поставить 3 экземпляра NumberPicker в ряд, первому дав свой Formatter, чтобы показывать даты вместо чисел, остальным только ограничить минимальное и максимальное значение.


    1. anegin
      28.10.2024 14:39

      в статье реализация на Compose