如何使用Jetpack Compose实现自定义日历组件展示功能
Jetpack Compose 自定义日历组件实现方案
目前Jetpack Compose官方暂未提供类似XML体系下的原生日历组件,我们可以通过自定义Composable实现符合需求的日历功能,预期实现效果如下:
核心需求
- 默认展示单月完整日期列表,点击左右箭头可切换前后月份
- 服务端返回的指定日期使用特殊颜色标记,当前系统日期使用独立样式高亮
实现代码
首先需要引入kotlin datetime库用于日期计算,在模块级build.gradle中添加依赖:
implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0"
核心日历组件代码示例:
import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlinx.datetime.Clock import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.plus import kotlinx.datetime.minus import kotlinx.datetime.dayOfMonth import kotlinx.datetime.dayOfWeek import kotlinx.datetime.month import kotlinx.datetime.year // 日历状态类 data class CalendarState( val currentDisplayMonth: LocalDate, val markedDates: Set<LocalDate>, val today: LocalDate = Clock.System.now().let { LocalDate(it, kotlinx.datetime.TimeZone.currentSystemDefault()) } ) @Composable fun CustomCalendar( markedDates: Set<LocalDate>, onDateClick: (LocalDate) -> Unit ) { var currentMonth by remember { mutableStateOf(LocalDate(Clock.System.now(), kotlinx.datetime.TimeZone.currentSystemDefault()).let { LocalDate(it.year, it.month, 1) }) } val calendarState = remember(currentMonth, markedDates) { CalendarState(currentMonth, markedDates) } Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { // 月份切换栏 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = { currentMonth = currentMonth.minus(1, DateTimeUnit.MONTH) }) { Icon(Icons.Default.KeyboardArrowLeft, contentDescription = "上个月") } Text(text = "${currentMonth.year}年${currentMonth.month.value}月", fontWeight = FontWeight.Bold) IconButton(onClick = { currentMonth = currentMonth.plus(1, DateTimeUnit.MONTH) }) { Icon(Icons.Default.KeyboardArrowRight, contentDescription = "下个月") } } // 星期表头 LazyVerticalGrid(columns = GridCells.Fixed(7), modifier = Modifier.padding(vertical = 8.dp)) { items(7) { index -> val weekDay = when(index) { 0 -> "日" 1 -> "一" 2 -> "二" 3 -> "三" 4 -> "四" 5 -> "五" else -> "六" } Box(modifier = Modifier.aspectRatio(1f), contentAlignment = Alignment.Center) { Text(text = weekDay, color = Color.Gray, fontWeight = FontWeight.Medium) } } } // 日期网格 val daysInMonth = currentMonth.lengthOfMonth() val firstDayOfMonth = currentMonth.dayOfWeek val startOffset = if (firstDayOfMonth == DayOfWeek.SUNDAY) 0 else firstDayOfMonth.value LazyVerticalGrid(columns = GridCells.Fixed(7)) { // 前补空白 items(startOffset) { Box(modifier = Modifier.aspectRatio(1f)) } // 当月日期 items(daysInMonth) { dayIndex -> val date = LocalDate(currentMonth.year, currentMonth.month, dayIndex + 1) val isToday = date == calendarState.today val isMarked = date in calendarState.markedDates Box( modifier = Modifier .aspectRatio(1f) .padding(4.dp) .background( color = when { isToday -> Color.Blue.copy(alpha = 0.7f) isMarked -> Color.Green.copy(alpha = 0.3f) else -> Color.Transparent }, shape = CircleShape ) .clickable { onDateClick(date) }, contentAlignment = Alignment.Center ) { Text( text = (dayIndex + 1).toString(), color = if (isToday) Color.White else Color.Black ) } } } } } // 扩展方法:获取当月天数 fun LocalDate.lengthOfMonth(): Int { return when (month.value) { 1, 3, 5, 7, 8, 10, 12 -> 31 4, 6, 9, 11 -> 30 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28 else -> throw IllegalArgumentException("Invalid month") } }
使用方式
直接在需要展示日历的位置调用CustomCalendar组件,传入服务端返回的标记日期集合和日期点击回调即可:
// 示例:模拟服务端返回的标记日期 val markedDates = remember { setOf( LocalDate(2024, 5, 10), LocalDate(2024, 5, 15), LocalDate(2024, 5, 20) ) } CustomCalendar( markedDates = markedDates, onDateClick = { clickedDate -> // 处理日期点击逻辑 } )
关键逻辑说明
- 月份切换:通过
remember维护当前展示的年月状态,点击左右箭头时增减月份并重新计算当月日期列表 - 日期布局:先计算当月第一天对应的星期偏移量,在日期网格前补对应数量的空白占位,保证日期和星期表头对应
- 特殊样式匹配:渲染每个日期时分别和当前系统日期、服务端返回的标记日期集合比对,匹配对应规则则应用指定的背景和文字样式
内容的提问来源于stack exchange,提问作者Jéluchu
相关产品推荐
相关产品推荐

