You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.13 13:53:10