Jetpack Compose自定义周历:获取滚动位置与禁用事件槽点击
Android Jetpack Compose 可滚动周历问题解决
问题概述
- 开发支持超7天滚动的周历时,滚动布局无法获取当前位置以同步显示对应日期
- 需要实现已有事件的时间槽禁用点击功能
相关代码
Schedule 核心组件
@Composable fun Schedule( bookings: List<Booking>, modifier: Modifier = Modifier, bookingContent: @Composable (booking: Booking) -> Unit = { BookingCard(booking = it) }, onEmptyViewClicked: (Int, Int, Int) -> Unit ) { val hourHeight = 64.dp Layout( content = { bookings.sortedBy { it.start.localDateTime } .forEach { booking -> Box(modifier = Modifier.bookingData(booking)) { bookingContent(booking) } } }, modifier = modifier .pointerInput(Unit) { detectTapGestures { val x = it.x.toDp() val y = it.y.toDp() val time = y.value.toInt() / hourHeight.value val selectedDate = time.toInt() println("X: $x, Y: $y") println("selected date $selectedDate") println("Time: $time") val days = selectedDate / 24 val startHour = selectedDate - (24 * days) onEmptyViewClicked(days, startHour, selectedDate) } } .drawBehind { repeat(167) { drawLine( color = Color.LightGray, start = Offset(0f, (it + 1) * hourHeight.toPx()), end = Offset(size.width, (it + 1) * hourHeight.toPx()), strokeWidth = 1.dp.toPx() ) } }, ) { measureables, constraints -> val height = hourHeight.roundToPx() * 168 val placeablesWithBookings = measureables.map { measurable -> val booking = measurable.parentData as Booking val bookingDurationMinutes = ChronoUnit.MINUTES.between(booking.start.localDateTime, booking.end.localDateTime) val eventHeight = ((bookingDurationMinutes / 60f) * hourHeight.toPx()).roundToInt() val placeable = measurable.measure( constraints.copy( minHeight = eventHeight, maxHeight = eventHeight ) ) Pair(placeable, booking) } layout(constraints.maxWidth, height) { placeablesWithBookings.forEach { (placeable, booking) -> val dayOffset = ChronoUnit.DAYS.between( LocalDate.now(), booking.start.localDateTime.toLocalDate() ) val bookingOffsetMinutes = ChronoUnit.MINUTES.between( LocalTime.MIN, booking.start.localDateTime.toLocalTime() ) val bookingY = (((bookingOffsetMinutes / 60f) * hourHeight.toPx()) + (dayOffset * hourHeight.roundToPx() * 24)).roundToInt() placeable.place(0, bookingY) } } } } private class BookingDataModifier( val booking: Booking, ) : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?) = booking } private fun Modifier.bookingData(booking: Booking) = this.then(BookingDataModifier(booking))
BookingCard 预订卡片
@Composable fun BookingCard( booking: Booking, modifier: Modifier = Modifier, ) { Column( modifier = modifier .fillMaxSize() .padding(end = 2.dp, bottom = 2.dp) .background( color = booking.color ?: Color.Green, shape = RoundedCornerShape(4.dp) ) .padding(4.dp) ) { Text( text = "${ booking.start.localDateTime.toLocalTime() } - ${booking.end.localDateTime.toLocalTime()}", style = MaterialTheme.typography.labelMedium, color = Color.White ) Text( text = booking.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = Color.White ) if (booking.description != null) { Text( text = booking.description, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White ) } } }
稳定类 BookingTime
@Stable data class BookingTime(val localDateTime: LocalDateTime)
测试预订数据
val bookings = listOf<Booking>( Booking( id = 1, name = "Dr Gilbert", start = BookingTime(LocalDateTime.now().plusHours(1)), end = BookingTime(LocalDateTime.now().plusHours(2)) ), Booking( id = 2, name = "Dr Michel", start = BookingTime(LocalDateTime.now().plusDays(1)), end = BookingTime(LocalDateTime.now().plusDays(1).plusHours(2)) ), Booking( id = 3, name = "Dr Nancy", start = BookingTime(LocalDateTime.now().plusDays(2)), end = BookingTime(LocalDateTime.now().plusDays(2).plusHours(2)) ), Booking( id = 4, name = "Dr Brian", start = BookingTime(LocalDateTime.now().plusDays(3)), end = BookingTime(LocalDateTime.now().plusDays(3).plusHours(2)) ), Booking( id = 5, name = "Dr Antoine", start = BookingTime(LocalDateTime.now().plusHours(3)), end = BookingTime(LocalDateTime.now().plusHours(5)) ) )
Booking 数据类
data class Booking( val id: Id, val name: String, val start: BookingTime, val end: BookingTime, val description: String? = null, val color: Color? = null )
ScheduleHeader 日期头部
@Composable fun ScheduleHeader( minDate: LocalDate = LocalDate.now(), maxDate: LocalDate = LocalDate.now().plusDays(6), dayWidth: Dp = 60.dp, modifier: Modifier = Modifier, dayHeader: @Composable (day: LocalDate) -> Unit = { DayLabel(day = it) } ) { LazyRow( modifier = modifier .fillMaxWidth() .padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { val numDays = ChronoUnit.DAYS.between(minDate, maxDate).toInt() + 1 items(numDays) { i -> dayHeader(minDate.plusDays(i.toLong())) } } }
主展示组件 ScheduleView
@Composable fun ScheduleView(){ val verticalScrollState = rememberScrollState() Column(modifier = Modifier.fillMaxSize()) { ScheduleHeader() Schedule( bookings = bookings, modifier = Modifier .verticalScroll(verticalScrollState) , onEmptyViewClicked = { } ) } }
解决方案
1. 获取滚动位置同步日期显示
通过ScrollState的滚动偏移量计算当前可见日期,可同步到头部组件:
步骤1:传递滚动状态到Schedule组件
修改Schedule添加滚动状态参数:
@Composable fun Schedule( bookings: List<Booking>, scrollState: ScrollState, modifier: Modifier = Modifier, bookingContent: @Composable (booking: Booking) -> Unit = { BookingCard(booking = it) }, onEmptyViewClicked: (Int, Int, Int) -> Unit ) { val hourHeightPx = with(LocalDensity.current) { 64.dp.toPx() } val dayHeightPx = hourHeightPx * 24 // 计算当前滚动对应的天数偏移 val currentDayOffset = (scrollState.value / dayHeightPx).toInt() val currentDate = LocalDate.now().plusDays(currentDayOffset.toLong()) // 可将currentDate传递给Header用于高亮或同步滚动 }
步骤2:在ScheduleView中传递状态
@Composable fun ScheduleView(){ val verticalScrollState = rememberScrollState() val currentDayOffset = (verticalScrollState.value / (64.dp.toPx() *24)).toInt() Column(modifier = Modifier.fillMaxSize()) { ScheduleHeader(currentDayOffset = currentDayOffset) Schedule( bookings = bookings, scrollState = verticalScrollState, modifier = Modifier.verticalScroll(verticalScrollState), onEmptyViewClicked = { } ) } }
步骤3:同步Header横向滚动
修改ScheduleHeader跟随纵向滚动的日期:
@Composable fun ScheduleHeader( currentDayOffset: Int, minDate: LocalDate = LocalDate.now(), maxDate: LocalDate = LocalDate.now().plusDays(6), dayWidth: Dp = 60.dp, modifier: Modifier = Modifier, dayHeader: @Composable (day: LocalDate) -> Unit = { DayLabel(day = it) } ) { val lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = currentDayOffset) // 自动滚动到当前日期 LaunchedEffect(currentDayOffset) { lazyListState.animateScrollToItem(currentDayOffset) } LazyRow( state = lazyListState, modifier = modifier.fillMaxWidth().padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { val numDays = ChronoUnit.DAYS.between(minDate, maxDate).toInt() + 1 items(numDays) { i -> dayHeader(minDate.plusDays(i.toLong())) } } }
2. 禁用已有事件区域的点击
通过存储预订位置信息,判断点击坐标是否落在事件区域内,仅空白区域触发点击回调:
步骤1:存储预订位置数据
在Layout的测量阶段记录每个预订的上下边界:
// 定义位置数据类 data class BookingBounds(val topPx: Float, val bottomPx: Float) @Composable fun Schedule( // ... 其他参数 ) { val hourHeight = 64.dp val bookingBounds = remember { mutableListOf<BookingBounds>() } Layout( // ... 内容块 ) { measureables, constraints -> val height = hourHeight.roundToPx() * 168 bookingBounds.clear() val placeablesWithBookings = measureables.map { measurable -> val booking = measurable.parentData as Booking val bookingDurationMinutes = ChronoUnit.MINUTES.between(booking.start.localDateTime, booking.end.localDateTime) val eventHeight = ((bookingDurationMinutes / 60f) * hourHeight.toPx()).roundToInt() val placeable = measurable.measure(constraints.copy(minHeight = eventHeight, maxHeight = eventHeight)) // 计算并存储预订位置 val dayOffset = ChronoUnit.DAYS.between(LocalDate.now(), booking.start.localDateTime.toLocalDate()) val bookingOffsetMinutes = ChronoUnit.MINUTES.between(LocalTime.MIN, booking.start.localDateTime.toLocalTime()) val topPx = (((bookingOffsetMinutes / 60f) * hourHeight.toPx()) + (dayOffset * hourHeight.roundToPx() * 24)) bookingBounds.add(BookingBounds(topPx, topPx + placeable.height)) Pair(placeable, booking) } // ... 布局逻辑 } }
步骤2:判断点击位置是否在事件内
修改pointerInput逻辑:
.pointerInput(bookingBounds) { detectTapGestures { offset -> val tapY = offset.y // 检查点击是否落在任意预订区域 val isTapOnBooking = bookingBounds.any { tapY >= it.topPx && tapY <= it.bottomPx } if (!isTapOnBooking) { // 原有空白点击逻辑 val y = tapY.toDp() val time = y.value.toInt() / hourHeight.value val selectedDate = time.toInt() val days = selectedDate / 24 val startHour = selectedDate - (24 * days) onEmptyViewClicked(days, startHour, selectedDate) } } }
内容的提问来源于stack exchange,提问作者EliodeBeirut
相关产品推荐
相关产品推荐

