CMP for OpenHarmony:DatePicker 日期选择器的“纯 commonMain 日历”实现——月切换/选日网格/多入口写回(项目实战代码)

仓库地址:通过网盘分享的文件:cmp_openharmony.zip
链接: https://pan.baidu.com/s/15rN1LvJ0KENMkYZfLq_R1Q?pwd=nhqe 提取码: nhqe

日期选择器在工程里常见的两条路线:

  • 直接用平台 DatePicker(快,但跨平台一致性与可控性受限)
  • 自己实现一个“日历网格 + 选中状态 + 多入口控制”(慢一点,但能把交互与状态完全工程化)

本文基于本仓库真实代码,给出第二种路线:不依赖 java.time / Calendar,在 commonMain 里实现一个可运行的日历型 DatePicker,并把“选择日期”做成可被 Modal / Drawer / Popover / BottomSheet 多入口统一控制的状态机。


1. 代码位置:示例页在哪

本文所有 Kotlin 代码均来自下面文件(复制路径即可定位):

  • composeApp/src/commonMain/kotlin/com/tencent/compose/sample/date/MultiPopupDatePickerDemoPage.kt

2. 先定数据模型:SimpleDate 只做三件事(year/month/day + 可打印)

示例页使用一个最小的日期结构体(节选,保持项目原样):

private data class SimpleDate(val year: Int, val month: Int, val day: Int) {
    override fun toString(): String =
        year.toString().padStart(4, '0') + "-" +
            month.toString().padStart(2, '0') + "-" +
            day.toString().padStart(2, '0')
}

这段代码的目的:

  • 避免 java.time.LocalDate(它不一定在 Kotlin/Native 环境可用)。
  • 把日期当作纯数据:只包含年/月/日,不含时区、时分秒,便于在 UI 层做“选中/对比/显示”。
  • toString()padStart 拼出稳定格式,避免 Kotlin/Native 下 String.format 不可用造成构建失败。

3. 页面状态:显示月份 vs 已选日期(两套状态要分清)

示例页把 DatePicker 拆成两层状态:

  • 显示层:当前正在看的年月(用于渲染网格)
  • 业务层:当前选中的日期(可能为空)

对应代码(节选,保持项目原样):

val today = remember { nowDateUtc() }

var displayYear by remember { mutableIntStateOf(today.year) }
var displayMonth by remember { mutableIntStateOf(today.month) }

var selectedDate by remember { mutableStateOf<SimpleDate?>(today) }

解释:

  • displayYear/displayMonth 决定“网格画哪一个月”。切换月份只需要改它们。
  • selectedDate 才是最终写回的业务结果。
  • 这两者分开后,你可以支持更复杂的交互:
    • 用户在 Modal 里先切月浏览,但不一定立刻写回
    • 用户清空选择,但仍然可以继续浏览其它月份

4. 月份归一化:解决“+1 月跨年 / -1 月跨年”

当你允许按钮做 displayMonth + 1displayMonth - 1 时,必须处理月份越界。示例用一个纯函数把年月归一化(节选,保持项目原样):

private fun normalizeYearMonth(year: Int, month: Int): Pair<Int, Int> {
    val zeroBased = month - 1
    val addYears = floor(zeroBased / 12.0).toInt()
    var m = zeroBased - addYears * 12
    if (m < 0) m += 12
    return Pair(year + addYears, m + 1)
}

解释:

  • 这里把 month 先转为 0 基,再按 12 做整除/取余。
  • 无论传入的是 013-5,最终都会被折算到合法的 1..12
  • 好处是:按钮逻辑可以很简单(只做 month±1),边界交给归一化函数。

5. 日历网格:三步走(每月天数、首日星期、填充格子)

网格渲染的关键数据是:

  • 当月多少天(含闰年)
  • 该月 1 号是星期几(用来决定前面要空几格)

示例在 DateGrid 里先算出网格尺寸(节选,保持项目原样):

val days = daysInMonth(year, month)
val firstWeekdayIndex = weekdayIndexMondayBased(year, month, 1)
val totalCells = ((firstWeekdayIndex + days + 6) / 7) * 7

解释:

  • firstWeekdayIndex周一为 0的索引,这样表头可以写“一二三四五六日”。
  • totalCells 用向上取整到整周:
    • (firstWeekdayIndex + days) 是“从第一个日期格到最后一个日期格”的总跨度
    • +6)/7*7 把它补齐到 7 的倍数,保证每行 7 个格子。

6. 日期点击:只写回一个 SimpleDate(选中态由比较推导)

日历每个日期格的点击写回(节选,保持项目原样):

val date = SimpleDate(year, month, dayNumber)
val isSelected = selectedDate == date
val isToday = today == date

.clickable(
    indication = null,
    interactionSource = remember { MutableInteractionSource() }
) { onSelect(date) }

解释:

  • 选中态 不需要 额外维护 var isSelected,直接 selectedDate == date 推导。
  • “今天”标记同样由 today == date 推导。
  • onSelect(date) 是数据流收口点:
    • 主区网格点击:直接写 selectedDate = date
    • Modal 网格点击:写 pendingDate = date(先不影响主状态)

7. Modal:先选后写回(pendingDate 作为编辑缓冲区)

如果你不希望“点一下就立刻更改业务日期”,可以用 Modal 做一个写回边界。示例用 pendingDate 实现(节选,保持项目原样):

var pendingDate by remember { mutableStateOf(selectedDate) }

LaunchedEffect(showModal) {
    if (showModal) pendingDate = selectedDate
}

解释:

  • pendingDate 只在弹窗内变化。
  • LaunchedEffect(showModal) 的作用是:每次打开弹窗都从主状态同步一份,避免上次残留。

写回按钮(节选,保持项目原样):

Button(
    onClick = {
        selectedDate = pendingDate
        showModal = false
        lastResult = "Modal:写回 ${selectedDate?.toString() ?: \"无\"}"
    }
) { Text(text = "写回", color = Color.White) }

解释:

  • “写回”发生在确认按钮,形成明确的编辑边界。
  • selectedDate 只在这里被真正更新,方便你后续接入校验、二次确认或埋点。

8. 多入口控制:Drawer / Popover / BottomSheet 只是不同粒度的操作面板

8.1 Drawer:更适合放月份翻页与清空/跳今天

抽屉接入点(节选,保持项目原样):

onPrevMonth = {
    setDisplay(displayYear, displayMonth - 1)
    lastResult = "抽屉:上个月"
},
onNextMonth = {
    setDisplay(displayYear, displayMonth + 1)
    lastResult = "抽屉:下个月"
},
onJumpToToday = { jumpToToday("抽屉") },
onClear = { clear("抽屉") }

解释:

  • Drawer 的定位是“控制面板”,适合放 翻页与快捷动作
  • jumpToToday/clear 作为函数收口,避免同一逻辑在各入口重复。

8.2 Popover:轻量动作(今天/清空/回到本月)

Popover 的动作分发(节选,保持项目原样):

when (action) {
    "今天" -> jumpToToday("Popover")
    "清空" -> clear("Popover")
    "回到本月" -> setDisplay(today.year, today.month)
    "关闭" -> Unit
}

解释:

  • Popover 不适合承载复杂 UI,但非常适合放几个“轻量按钮”。
  • “回到本月”只改显示层,不强行改 selectedDate,这是一个典型的“浏览动作”。

8.3 BottomSheet:离散快捷项(选 1 号/15 号、上下月、今天/清空)

BottomSheet 的动作分发(节选,保持项目原样):

when (label) {
    "上个月" -> setDisplay(displayYear, displayMonth - 1)
    "下个月" -> setDisplay(displayYear, displayMonth + 1)
    "选 1 号" -> selectedDate = SimpleDate(displayYear, displayMonth, 1)
    "选 15 号" -> {
        val maxDay = daysInMonth(displayYear, displayMonth)
        selectedDate = SimpleDate(displayYear, displayMonth, minOf(15, maxDay))
    }
    "今天" -> jumpToToday("BottomSheet")
    "清空" -> clear("BottomSheet")
    "关闭" -> Unit
}

解释:

  • BottomSheet 适合“明确选项列表”。
  • “选 15 号”有一个边界处理:minOf(15, maxDay),避免 2 月没有 15 的极端情况(示例仍然写了兜底)。

9. 跨平台注意点:为什么用 getTimeMillis() + 纯算法取“今天”

示例用 kotlin.system.getTimeMillis() 获取当前时间,再转成 UTC 天数并还原年月日(节选,保持项目原样):

private fun nowDateUtc(): SimpleDate {
    val epochMs = getTimeMillis()
    val days = floor(epochMs / 86_400_000.0).toLong()
    return civilFromDays(days)
}

解释:

  • getTimeMillis() 在 commonMain 可用,避免 System.currentTimeMillis() 在 OHOS 构建中的兼容性问题。
  • 这里按 UTC 天数粗略折算,适合 Demo 与“日期选择”这种只关心年月日的场景。
  • civilFromDays 使用纯数学算法从 days 还原年月日,保证全平台一致。

10. 如何在工程里直接体验该示例

建议做法与其它 Demo 一样:打开入口文件并临时切换渲染页面。

  • 入口文件:composeApp/src/commonMain/kotlin/com/tencent/compose/sample/mainpage/MainPage.kt
  • 将入口渲染切换到:com.tencent.compose.sample.date.MultiPopupDatePickerDemoPage()

(这里不贴固定代码块,避免入口频繁切换导致文内代码与仓库不一致。)


11. 自检清单:DatePicker 工程里最容易踩的坑

  • 显示月份与已选日期要分离:否则浏览月份会污染业务选中值。
  • 月切换必须做归一化:避免 month=0/13 的越界。
  • 网格必须基于真实“首日星期”:否则每月日期会对不齐。
  • Modal 要用 pending 作为写回边界:避免误触导致业务日期立刻变化。
  • 跨平台不要依赖 java.time:commonMain 里建议用纯算法或明确的平台抽象。

在这里插入图片描述

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

社区链接

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐