CMP for OpenHarmony:DatePicker 日期选择器的“纯 commonMain 日历”实现——月切换/选日网格/多入口写回(项目实战代码)
仓库地址:通过网盘分享的文件:cmp_openharmony.zip链接: https://pan.baidu.com/s/15rN1LvJ0KENMkYZfLq_R1Q?pwd=nhqe 提取码: nhqejava.timeCalendar,在commonMain里实现一个可运行的日历型 DatePicker,并把“选择日期”做成可被 Modal / Drawer / Popover / Bot
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 + 1 或 displayMonth - 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 做整除/取余。 - 无论传入的是
0、13、-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
更多推荐



所有评论(0)