- MiniMonth 日期网格改 Canvas 绘制,单页 Composable 从 ~600 降到 ~120 - 年视图 beyondViewportPageCount 首帧 0、首帧后恢复 1,避免一次组合 36 个 MiniMonth - toggleYearView 先启动动画再翻转 isYearView,月视图缩小与年视图组合交错 - 添加 ComposeTrace 跨平台 trace 工具用于性能分析 MonthView→YearView 总耗时 1732ms → 1033ms (↓40%),首帧 onMeasure 902ms → 129ms (↓86%)
297 lines
10 KiB
Kotlin
297 lines
10 KiB
Kotlin
package plus.rua.project.ui
|
||
|
||
import androidx.compose.animation.AnimatedContent
|
||
import androidx.compose.animation.core.tween
|
||
import androidx.compose.animation.slideInVertically
|
||
import androidx.compose.animation.slideOutVertically
|
||
import androidx.compose.animation.togetherWith
|
||
import androidx.compose.foundation.Canvas
|
||
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.fillMaxSize
|
||
import androidx.compose.foundation.layout.fillMaxWidth
|
||
import androidx.compose.foundation.layout.height
|
||
import androidx.compose.foundation.layout.padding
|
||
import androidx.compose.foundation.shape.CircleShape
|
||
import androidx.compose.material3.MaterialTheme
|
||
import androidx.compose.material3.Text
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.runtime.remember
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.draw.clip
|
||
import androidx.compose.ui.geometry.Offset
|
||
import androidx.compose.ui.graphics.Color
|
||
import androidx.compose.ui.text.drawText
|
||
import androidx.compose.ui.text.font.FontWeight
|
||
import androidx.compose.ui.text.rememberTextMeasurer
|
||
import androidx.compose.ui.text.style.TextAlign
|
||
import androidx.compose.ui.text.TextStyle
|
||
import androidx.compose.ui.platform.LocalDensity
|
||
import androidx.compose.ui.unit.dp
|
||
import androidx.compose.ui.unit.sp
|
||
import kotlinx.datetime.DatePeriod
|
||
import kotlinx.datetime.LocalDate
|
||
import kotlinx.datetime.minus
|
||
import kotlinx.datetime.number
|
||
import kotlinx.datetime.plus
|
||
import plus.rua.project.composeTraceBeginSection
|
||
import plus.rua.project.composeTraceEndSection
|
||
|
||
private val WEEKDAY_LABELS = listOf("一", "二", "三", "四", "五", "六", "日")
|
||
|
||
/**
|
||
* 年视图 4×3 月历网格。
|
||
*
|
||
* @param year 显示的年份
|
||
* @param selectedMonth 当前选中月份(1-12)
|
||
* @param today 今天的日期
|
||
* @param onMonthClick 月份点击回调
|
||
* @param modifier 外部布局修饰符
|
||
*/
|
||
@Composable
|
||
fun YearGridView(
|
||
year: Int,
|
||
selectedMonth: Int,
|
||
today: LocalDate,
|
||
onMonthClick: (Int) -> Unit,
|
||
modifier: Modifier = Modifier
|
||
) {
|
||
composeTraceBeginSection("YearGridView:$year")
|
||
Column(
|
||
modifier = modifier.fillMaxSize(),
|
||
horizontalAlignment = Alignment.CenterHorizontally
|
||
) {
|
||
// 4×3 月历网格
|
||
Column(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.weight(1f)
|
||
.padding(horizontal = 4.dp),
|
||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||
) {
|
||
(0 until 4).forEach { row ->
|
||
Row(
|
||
modifier = Modifier.fillMaxWidth(),
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
(0 until 3).forEach { col ->
|
||
val month = row * 3 + col + 1
|
||
MiniMonth(
|
||
year = year,
|
||
month = month,
|
||
isSelected = month == selectedMonth,
|
||
today = today,
|
||
onClick = { onMonthClick(month) },
|
||
modifier = Modifier.weight(1f)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
composeTraceEndSection()
|
||
}
|
||
|
||
/**
|
||
* 精简版月历:月份标题 + 星期行 + 日期数字网格。
|
||
*/
|
||
@Composable
|
||
private fun MiniMonth(
|
||
year: Int,
|
||
month: Int,
|
||
isSelected: Boolean,
|
||
today: LocalDate,
|
||
onClick: () -> Unit,
|
||
modifier: Modifier = Modifier
|
||
) {
|
||
val days = remember(year, month) { generateMiniMonthDays(year, month) }
|
||
val titleColor = if (isSelected) {
|
||
MaterialTheme.colorScheme.primary
|
||
} else {
|
||
MaterialTheme.colorScheme.onSurface
|
||
}
|
||
val weekdayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||
val dayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||
val otherMonthColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
|
||
val todayBgColor = MaterialTheme.colorScheme.primaryContainer
|
||
val todayTextColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||
|
||
val textMeasurer = rememberTextMeasurer()
|
||
val dayTextStyle = remember {
|
||
TextStyle(fontSize = 8.sp, lineHeight = 12.sp)
|
||
}
|
||
|
||
Column(
|
||
modifier = modifier
|
||
.padding(2.dp)
|
||
.clickable(onClick = onClick)
|
||
.padding(vertical = 2.dp),
|
||
horizontalAlignment = Alignment.CenterHorizontally
|
||
) {
|
||
// 月份标题
|
||
Text(
|
||
text = "${month}月",
|
||
color = titleColor,
|
||
fontSize = 10.sp,
|
||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||
textAlign = TextAlign.Center
|
||
)
|
||
// 星期行
|
||
Row(
|
||
modifier = Modifier.fillMaxWidth(),
|
||
horizontalArrangement = Arrangement.SpaceEvenly
|
||
) {
|
||
WEEKDAY_LABELS.forEach { label ->
|
||
Text(
|
||
text = label,
|
||
color = weekdayColor,
|
||
fontSize = 8.sp,
|
||
textAlign = TextAlign.Center,
|
||
modifier = Modifier.weight(1f)
|
||
)
|
||
}
|
||
}
|
||
// 日期网格 — Canvas 绘制
|
||
val density = LocalDensity.current
|
||
val dayRowCount = days.size / 7
|
||
val canvasHeight = with(density) { (dayRowCount * (12.sp.toPx() + 4.dp.toPx())).toDp() }
|
||
Canvas(modifier = Modifier.fillMaxWidth().height(canvasHeight)) {
|
||
val cellWidth = size.width / 7f
|
||
val rowHeightPx = size.height / dayRowCount
|
||
|
||
days.forEachIndexed { index, dayData ->
|
||
val row = index / 7
|
||
val col = index % 7
|
||
val centerX = col * cellWidth + cellWidth / 2f
|
||
val centerY = row * rowHeightPx + rowHeightPx / 2f
|
||
|
||
val isToday = dayData.date == today && dayData.isCurrentMonth
|
||
val text = if (dayData.isCurrentMonth) dayData.date.day.toString() else ""
|
||
val textColor: Color = when {
|
||
!dayData.isCurrentMonth -> otherMonthColor
|
||
isToday -> todayTextColor
|
||
else -> dayColor
|
||
}
|
||
|
||
if (isToday) {
|
||
val radius = cellWidth.coerceAtMost(rowHeightPx) / 2f * 0.8f
|
||
drawCircle(
|
||
color = todayBgColor,
|
||
radius = radius,
|
||
center = Offset(centerX, centerY)
|
||
)
|
||
}
|
||
|
||
if (text.isNotEmpty()) {
|
||
val measured = textMeasurer.measure(
|
||
text = text,
|
||
style = dayTextStyle.copy(color = textColor)
|
||
)
|
||
drawText(
|
||
textLayoutResult = measured,
|
||
topLeft = Offset(
|
||
x = centerX - measured.size.width / 2f,
|
||
y = centerY - measured.size.height / 2f
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private data class MiniDayData(
|
||
val date: LocalDate,
|
||
val isCurrentMonth: Boolean
|
||
)
|
||
|
||
@Suppress("DEPRECATION") // monthNumber 无替代 API
|
||
private fun generateMiniMonthDays(year: Int, month: Int): List<MiniDayData> {
|
||
composeTraceBeginSection("generateMiniMonthDays:$year-$month")
|
||
val firstOfMonth = LocalDate(year, month, 1)
|
||
val offset = firstOfMonth.dayOfWeek.ordinal
|
||
val startDate = firstOfMonth.minus(DatePeriod(days = offset))
|
||
val nextMonth = if (month == 12) LocalDate(year + 1, 1, 1) else LocalDate(year, month + 1, 1)
|
||
val daysInMonth = nextMonth.minus(DatePeriod(days = 1)).day
|
||
val rows = ((offset + daysInMonth - 1) / 7) + 1
|
||
val totalDays = rows * 7
|
||
|
||
val result = (0 until totalDays).map { i ->
|
||
val date = startDate.plus(DatePeriod(days = i))
|
||
MiniDayData(
|
||
date = date,
|
||
isCurrentMonth = date.month.number == month && date.year == year
|
||
)
|
||
}
|
||
composeTraceEndSection()
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 年视图标题栏,显示年份文字和左右导航箭头。
|
||
*
|
||
* 年份切换时文字有垂直滑动过渡动画,方向由新旧年份大小决定。
|
||
*
|
||
* @param year 当前年份
|
||
* @param onYearChange 年份切换回调
|
||
* @param modifier 外部布局修饰符
|
||
*/
|
||
@Composable
|
||
fun YearHeader(
|
||
year: Int,
|
||
onYearChange: (Int) -> Unit,
|
||
modifier: Modifier = Modifier
|
||
) {
|
||
Row(
|
||
modifier = modifier
|
||
.fillMaxWidth()
|
||
.padding(vertical = 8.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Text(
|
||
text = "‹",
|
||
fontSize = 24.sp,
|
||
fontWeight = FontWeight.Bold,
|
||
color = MaterialTheme.colorScheme.primary,
|
||
modifier = Modifier
|
||
.clip(CircleShape)
|
||
.clickable { onYearChange(year - 1) }
|
||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||
)
|
||
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||
AnimatedContent(
|
||
targetState = year,
|
||
transitionSpec = {
|
||
if (targetState > initialState) {
|
||
slideInVertically(tween(250)) { -it } togetherWith
|
||
slideOutVertically(tween(250)) { it }
|
||
} else {
|
||
slideInVertically(tween(250)) { it } togetherWith
|
||
slideOutVertically(tween(250)) { -it }
|
||
}
|
||
}
|
||
) { y ->
|
||
Text(
|
||
text = "${y}年",
|
||
style = MaterialTheme.typography.titleLarge,
|
||
fontWeight = FontWeight.Bold
|
||
)
|
||
}
|
||
}
|
||
Text(
|
||
text = "›",
|
||
fontSize = 24.sp,
|
||
fontWeight = FontWeight.Bold,
|
||
color = MaterialTheme.colorScheme.primary,
|
||
modifier = Modifier
|
||
.clip(CircleShape)
|
||
.clickable { onYearChange(year + 1) }
|
||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||
)
|
||
}
|
||
}
|