Travis Griggs
09/26/2023, 4:34 PMTravis Griggs
09/26/2023, 4:34 PMTravis Griggs
09/26/2023, 4:35 PM@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DateWheel(
initial: LocalDate,
onChange: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
padRows: Int = 1, // number of rows to show either side of center
rowHeight: Dp = 30.dp
) {
var currentDate by remember { mutableStateOf(initial) }
val visibleRows = padRows + 1 + padRows
val leafSize = 60
val listState = rememberLazyListState(initialFirstVisibleItemIndex = leafSize)
val isScrolling by remember { derivedStateOf { listState.isScrollInProgress } }
LaunchedEffect(isScrolling) {
if (isScrolling.not()) {
\
// figured the scrolled distance, recenter the list
val deltaDays = listState.firstVisibleItemIndex - leafSize
if (deltaDays != 0) {
currentDate += DatePeriod(days = deltaDays)
listState.scrollToItem(leafSize, 0)
}
}
}
// are there upsides/downsides to having two separate launched effects here? I think I could just inline the callback in the previous one
LaunchedEffect(currentDate) {
onChange(currentDate)
}
LazyColumn(
modifier = modifier.height(rowHeight * visibleRows),
state = listState,
horizontalAlignment = Alignment.End,
// I wish I could adjust this, I dug through the code and it seemed deep in the bowels, it has some parameters for
// spring parameters, but it wasn't clear that I could hoist configuring those up here without inlining/duplicating lots
flingBehavior = rememberSnapFlingBehavior(lazyListState = listState)
) {
items(count = leafSize + visibleRows + leafSize, key = { index -> index }) { index ->
val offset = index - leafSize - padRows
val date = currentDate + DatePeriod(days = offset)
val text = when (date.isToday()) {
true -> "Today"
false -> date.formatted("ccc MMM dd")
}
// the IDE is warning me this layoutInfo access isn't cool and I should consider a derivedStateOf... not entirely clear why
val fractionFromCenter = listState.layoutInfo.normalizedItemPosition(index)
val scale = 1f - (fractionFromCenter.absoluteValue * 0.5f).pow(3)
Box(
modifier = Modifier.height(rowHeight)
) {
Text(
text = text, modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
alpha = scale.pow(6f),
transformOrigin = TransformOrigin.RightMid
)
.align(Alignment.CenterEnd), style = MaterialTheme.typography.titleLarge
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CyclicIntWheel(
initial: Int,
range: ClosedRange<Int>,
onChange: (Int) -> Unit,
modifier: Modifier = Modifier,
format: String = "%02d",
padRows: Int = 1, // number of rows to show either side of center
rowHeight: Dp = 30.dp
) {
var currentValue by remember { mutableIntStateOf(initial) }
val visibleRows = padRows * 2 + 1
val singleRingSize = range.endInclusive - range.start + 1
var ringCount = 3
// make sure we have lots of scrolling room before we get to "edge" of buffer
while (ringCount * singleRingSize / visibleRows < 60) {
ringCount += 2
}
// I find making a local closure (like a method) works in these cases. I could make a separate function, but then I have to pass
// so much context. Curious if that is an antipattern though?
val valueToCenteredIndex: (Int) -> Int =
{ value -> (ringCount / 2) * singleRingSize - padRows + (value - range.start) }
val listState =
rememberLazyListState(initialFirstVisibleItemIndex = valueToCenteredIndex(initial))
val isScrolling by remember { derivedStateOf { listState.isScrollInProgress } }
LaunchedEffect(isScrolling) {
if (isScrolling.not()) {
val snappedValue =
((listState.firstVisibleItemIndex + padRows) % singleRingSize) + range.start
if (snappedValue != currentValue) {
currentValue = snappedValue
listState.scrollToItem(valueToCenteredIndex(snappedValue))
}
}
}
LaunchedEffect(currentValue) {
onChange(currentValue)
}
LazyColumn(
modifier = modifier.height(rowHeight * visibleRows),
state = listState,
horizontalAlignment = Alignment.CenterHorizontally,
flingBehavior = rememberSnapFlingBehavior(lazyListState = listState)
) {
items(count = singleRingSize * ringCount, key = { index -> index }) { index ->
val normalized = index % singleRingSize + range.start
val fractionFromCenter = listState.layoutInfo.normalizedItemPosition(index)
val scale = 1f - (fractionFromCenter.absoluteValue * 0.5f).pow(3)
Box(
modifier = Modifier
.height(rowHeight)
.graphicsLayer(scaleX = scale, scaleY = scale, alpha = scale.pow(6f))
) {
Text(
text = String.format(format, normalized),
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.titleLarge
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DateTimeWheels(
initial: LocalDateTime,
onChange: (LocalDateTime) -> Unit,
modifier: Modifier = Modifier,
padRows: Int = 1, // number of rows to show either side of center
rowHeight: Dp = 30.dp
) {
var currentDateTime by remember { mutableStateOf(initial) }
// is this the right way to broadcast changes from the "view state"
LaunchedEffect(currentDateTime) {
onChange(currentDateTime)
}
// it's kind of annoying I have to hoist these values out here in side the composable, so the drawWithContent can use them.
// Maybe I should be just use a separate Canvas?
val rowPix = with(LocalDensity.current) {
(rowHeight * padRows).toPx()
}
val edgeBuffer = with(LocalDensity.current) {
5.dp.toPx()
}
val borderColor = MaterialTheme.colorScheme.primaryContainer
Row(
modifier = modifier
.height(rowHeight * (padRows + 1 + padRows))
.drawWithContent {
drawContent()
val box = Rect(Offset.Zero, size)
val cutout = RoundRect(box.inset(edgeBuffer, rowPix), CornerRadius(20f))
val skrim = Path()
skrim.addRect(box)
skrim.addRoundRect(cutout)
skrim.fillType = PathFillType.EvenOdd
drawPath(path = skrim, color = Color.White, alpha = 0.5f, style = Fill)
val border = Path().apply { addRoundRect(cutout) }
drawPath(path = border, color = borderColor, style = Stroke(width = 4f))
}, horizontalArrangement = Arrangement.End
) {
DateWheel(
initial = currentDateTime.date,
onChange = { newDate ->
currentDateTime = LocalDateTime(newDate, currentDateTime.time)
},
modifier = Modifier.padding(horizontal = 10.dp),
padRows = padRows,
rowHeight = rowHeight
)
VerticalDivider()
CyclicIntWheel(
initial = currentDateTime.time.hour,
range = 0..23,
onChange = { newHour ->
currentDateTime = LocalDateTime(
currentDateTime.date, currentDateTime.time.replace(hour = newHour)
)
},
modifier = Modifier.padding(horizontal = 10.dp),
padRows = padRows,
rowHeight = rowHeight
)
VerticalDivider()
CyclicIntWheel(
initial = currentDateTime.time.minute,
range = 0..59,
onChange = { newMinute ->
currentDateTime = LocalDateTime(
currentDateTime.date, currentDateTime.time.replace(minute = newMinute)
)
},
modifier = Modifier.padding(horizontal = 10.dp),
padRows = padRows,
rowHeight = rowHeight
)
}
}
@Preview(showBackground = true)
@Composable
private fun CyclicIntWheelPreview() {
DateTimeWheelsDemoTheme {
CyclicIntWheel(
initial = 19,
range = 0..24,
onChange = { newValue -> newValue.logged("CYCLIC VALUE") },
modifier = Modifier
.padding(vertical = 50.dp, horizontal = 20.dp)
.fillMaxWidth(),
padRows = 1,
rowHeight = 32.dp
)
}
}
@Preview(showBackground = true)
@Composable
private fun DateTimeWheelsPreview() {
DateTimeWheelsDemoTheme {
DateTimeWheels(
initial = Clock.System.now().localDateTime,
onChange = { newValue -> newValue.logged("DATE TIME UPDATE") },
modifier = Modifier
.padding(vertical = 50.dp, horizontal = 20.dp)
.fillMaxWidth(),
padRows = 2,
rowHeight = 36.dp
)
}
}
// extensions
fun LazyListLayoutInfo.normalizedItemPosition(key: Any): Float {
return visibleItemsInfo.firstOrNull { it.key == key }?.let {
val center = (viewportEndOffset + viewportStartOffset - it.size) / 2F
(it.offset.toFloat() - center) / center
} ?: 0F
}
val Instant.localDateTime: LocalDateTime get() = toLocalDateTime(TimeZone.currentSystemDefault())
fun LocalDate.isToday(timezone: TimeZone = TimeZone.currentSystemDefault()): Boolean {
return LocalDate.today(timezone) == this
}
fun LocalDate.Companion.today(timezone: TimeZone = TimeZone.currentSystemDefault()): LocalDate {
return Clock.System.now().toLocalDateTime(timezone).date
}
fun LocalDate.formatted(pattern: String): String {
return DateTimeFormatter.ofPattern(pattern).format(toJavaLocalDate())
}
fun <T> T.logged(label: String = ""): T {
if (label.isBlank()) {
println("🔎 $this")
} else {
println("🔎 $label: $this")
}
return this
}
val TransformOrigin.Companion.RightMid get() = TransformOrigin(1f, 0.5f)
fun Rect.inset(dx: Float, dy: Float): Rect {
return Rect(left + dx, top + dy, right - dx, bottom - dy)
}
fun LocalTime.replace(
hour: Int = this.hour,
minute: Int = this.minute,
second: Int = this.second,
nanosecond: Int = this.nanosecond
): LocalTime {
return LocalTime(hour, minute, second, nanosecond)
}
(added material3 and kotlinx-datetime to my dependencies)myanmarking
09/26/2023, 5:42 PMmyanmarking
09/26/2023, 5:43 PMLaunchedEffect(isScrolling)
can be a snapshotflow. So just one scope in used for the changesmyanmarking
09/26/2023, 5:50 PMmyanmarking
09/26/2023, 5:52 PMval normalized = index % singleRingSize + range.start
val fractionFromCenter = listState.layoutInfo.normalizedItemPosition(index)
val scale = 1f - (fractionFromCenter.absoluteValue * 0.5f).pow(3)
to graphics layer lambda version. maybe use a derivedState. This impl of yours, as the lint suggest, is really badmyanmarking
09/26/2023, 5:52 PMmyanmarking
09/26/2023, 5:53 PMval isScrolling by remember { derivedStateOf { listState.isScrollInProgress } }
LaunchedEffect(isScrolling) {
myanmarking
09/26/2023, 5:54 PMmyanmarking
09/26/2023, 5:55 PM