Вдохновившись классными колесиками для выбора времени и даты напоминаний 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!
ссылка на оригинал статьи https://habr.com/ru/articles/853568/
Добавить комментарий