Any feedback appreciated... the other day I asked ...
# compose
t
Any feedback appreciated... the other day I asked about DateTime pickers, similar to the older iOS "wheels". I was encouraged to just take a day and write one. I think it took slightly longer, but I made something. It was fun. I'll post code in thread, but I would love to get any feedback on my implementation technique. I'm still tuning the actual visual appearance of it, so don't need that part so much. I'm an older developer (e.g. victim of too much experience from too many different UI frameworks), but largely on my own in this endeavor, so I think it's easy for me to miss obvious things. Or misapply old patterns that "work" but really aren't the idiomatic way of doing things.
Screenshot 2023-09-26 at 09.34.36.png
Copy code
@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)
m
ContentDrawScope has access to density. Just use .toPx. No need to define outside
Copy code
LaunchedEffect(isScrolling)
can be a snapshotflow. So just one scope in used for the changes
idk if compose fixed the bug with Stable value classes, but if they did, you could also wrap your date/time classes into a Stable value class. I also did something like this, and there were substancial recompositions due to date api not stable
also, move this
Copy code
val 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 bad
everyitem, with every scroll will recompose
Also, you dont need this remember+derivedStateOf here:
Copy code
val isScrolling by remember { derivedStateOf { listState.isScrollInProgress } }
   LaunchedEffect(isScrolling) {
the effect will only recompose if the key changes. In this context, remember+derived does nothing
Also, you are doing lots of calculations in composition time. You should find a way to minimize that, not good