Вдохновившись классными колесиками для выбора времени и даты напоминаний 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 при прокрутке кажется, что список крутится, как колесо. Немного понаблюдав за прокруткой я понял, что добиться такого эффекта можно тремя пунктами:
Сужение по ширине
-
Сужение по высоте
Эти 2 пункта дадут небольшую иллюзию отдаления
-
Уменьшение прозрачности элемента
Этот пункт как раз даст иллюзию прокрутки по колесу (имхо)
Всё это будет рассчитываться относительно расстояния от айтема до центра.
Далее будет код с подробными комментариями, чтобы не объяснять его каждую строку, ибо так статья выйдет очень большой.
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)
KivApple
28.10.2024 14:39https://developer.android.com/reference/android/widget/NumberPicker
Вот суть в том, что он на самом деле может выбирать далеко не только Number, если дать ему кастомный NumberPicker.Formatter.
Выглядит один в один как то что в Telegram и то что вы сделали. Разумеется, потребуется поставить 3 экземпляра NumberPicker в ряд, первому дав свой Formatter, чтобы показывать даты вместо чисел, остальным только ограничить минимальное и максимальное значение.
namikiri
Зачем именно такой таймпикер? Мне, например, очень нравится тот, что в стандартном Android, в виде часов, а не как в
TelegramiOS барабанный. Дату же удобнее всего выбирать на календаре или ввести с клавиатуры, а не крутить барабан как в казино.Не надо пропагандировать сомнительные UX-решения, пожалуйста.
vafeen Автор
Дело вкуса) Консерватизм имеет место быть!
namikiri
Не консерватизм, а стремление к удобству.
vafeen Автор
Мой дорогой друг, удобство - это довольно абстрактное понятие, и у каждого оно своё, как и вкус, о котором я сказал выше)
therteenten
Не надо пропагандировать сомнительные UX-решения, пожалуйста.