trace 显示 TextStringSimpleNode::measure 累计 100+ms,是 measure 阶段 主要开销。MiniMonth 中 12 个标题 Text + 84 个星期标签 Text = 96 个 Text 组件。 修复: 1. YearGridView 中预计算 titleLayouts + weekdayLayouts 2. MiniMonth 改为单个 Canvas 统一绘制标题 + 星期行 + 日期 3. 消除所有 Text 组件,TextStringSimpleNode::measure 归零
363 lines
13 KiB
Kotlin
363 lines
13 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.platform.LocalDensity
|
||
import androidx.compose.ui.text.TextStyle
|
||
import androidx.compose.ui.text.drawText
|
||
import androidx.compose.ui.text.font.FontWeight
|
||
import androidx.compose.ui.text.rememberTextMeasurer
|
||
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("一", "二", "三", "四", "五", "六", "日")
|
||
|
||
private data class MiniMonthColors(
|
||
val titleSelected: Color,
|
||
val titleNormal: Color,
|
||
val weekday: Color,
|
||
val day: Color,
|
||
val otherMonth: Color,
|
||
val todayBg: Color,
|
||
val todayText: Color
|
||
)
|
||
|
||
/**
|
||
* 年视图 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")
|
||
|
||
// P0-F: 主题色在 YearGridView 级别一次性读取并缓存
|
||
val colorScheme = MaterialTheme.colorScheme
|
||
val colors = remember(colorScheme) {
|
||
MiniMonthColors(
|
||
titleSelected = colorScheme.primary,
|
||
titleNormal = colorScheme.onSurface,
|
||
weekday = colorScheme.onSurface.copy(alpha = 0.4f),
|
||
day = colorScheme.onSurface.copy(alpha = 0.6f),
|
||
otherMonth = colorScheme.onSurface.copy(alpha = 0.2f),
|
||
todayBg = colorScheme.primaryContainer,
|
||
todayText = colorScheme.onPrimaryContainer
|
||
)
|
||
}
|
||
|
||
// P0-F: 预计算全年 12 个月的日期数据,翻年时复用
|
||
val monthDays = remember(year) {
|
||
(1..12).map { generateMiniMonthDays(year, it) }
|
||
}
|
||
|
||
// P0-G: 共享 TextMeasurer
|
||
val textMeasurer = rememberTextMeasurer()
|
||
val dayTextStyle = remember { TextStyle(fontSize = 8.sp, lineHeight = 12.sp) }
|
||
|
||
// P0-D: 预测量 1..31 × 3 种颜色 = 93 个 TextLayoutResult
|
||
val dayLayouts = remember(textMeasurer, dayTextStyle, colors) {
|
||
val days = 1..31
|
||
val colorList = listOf(colors.day, colors.todayText, colors.otherMonth)
|
||
days.flatMap { d ->
|
||
colorList.map { c ->
|
||
(d to c) to textMeasurer.measure(d.toString(), dayTextStyle.copy(color = c))
|
||
}
|
||
}.toMap()
|
||
}
|
||
|
||
// P0-H: 预测量月份标题(选中/非选中两种颜色)
|
||
val titleLayouts = remember(textMeasurer, colors) {
|
||
(1..12).flatMap { month ->
|
||
val text = "${month}月"
|
||
listOf(
|
||
(month to true) to textMeasurer.measure(
|
||
text,
|
||
TextStyle(fontSize = 10.sp, color = colors.titleSelected, fontWeight = FontWeight.Bold)
|
||
),
|
||
(month to false) to textMeasurer.measure(
|
||
text,
|
||
TextStyle(fontSize = 10.sp, color = colors.titleNormal)
|
||
)
|
||
)
|
||
}.toMap()
|
||
}
|
||
|
||
// P0-H: 预测量星期标签
|
||
val weekdayLayouts = remember(textMeasurer, colors) {
|
||
WEEKDAY_LABELS.associate { label ->
|
||
label to textMeasurer.measure(label, TextStyle(fontSize = 8.sp, color = colors.weekday))
|
||
}
|
||
}
|
||
|
||
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(
|
||
month = month,
|
||
isSelected = month == selectedMonth,
|
||
today = today,
|
||
days = monthDays[month - 1],
|
||
colors = colors,
|
||
dayLayouts = dayLayouts,
|
||
titleLayouts = titleLayouts,
|
||
weekdayLayouts = weekdayLayouts,
|
||
onClick = { onMonthClick(month) },
|
||
modifier = Modifier.weight(1f)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
composeTraceEndSection()
|
||
}
|
||
|
||
/**
|
||
* 精简版月历:月份标题 + 星期行 + 日期数字网格,全部 Canvas 绘制。
|
||
*
|
||
* 消除 Text 组件避免 TextStringSimpleNode::measure 开销。
|
||
*/
|
||
@Composable
|
||
private fun MiniMonth(
|
||
month: Int,
|
||
isSelected: Boolean,
|
||
today: LocalDate,
|
||
days: List<MiniDayData>,
|
||
colors: MiniMonthColors,
|
||
dayLayouts: Map<Pair<Int, Color>, androidx.compose.ui.text.TextLayoutResult>,
|
||
titleLayouts: Map<Pair<Int, Boolean>, androidx.compose.ui.text.TextLayoutResult>,
|
||
weekdayLayouts: Map<String, androidx.compose.ui.text.TextLayoutResult>,
|
||
onClick: () -> Unit,
|
||
modifier: Modifier = Modifier
|
||
) {
|
||
val density = LocalDensity.current
|
||
val dayRowCount = days.size / 7
|
||
val titleHeightPx = with(density) { 14.sp.toPx() }
|
||
val weekdayHeightPx = with(density) { 12.sp.toPx() }
|
||
val dayCellHeightPx = with(density) { (12.sp.toPx() + 4.dp.toPx()) }
|
||
val totalHeight = with(density) {
|
||
(titleHeightPx + weekdayHeightPx + dayRowCount * dayCellHeightPx).toDp()
|
||
}
|
||
|
||
Column(
|
||
modifier = modifier
|
||
.padding(2.dp)
|
||
.clickable(onClick = onClick)
|
||
.padding(vertical = 2.dp),
|
||
horizontalAlignment = Alignment.CenterHorizontally
|
||
) {
|
||
Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) {
|
||
val cellWidth = size.width / 7f
|
||
|
||
// 1. 绘制标题
|
||
val titleLayout = titleLayouts[month to isSelected]!!
|
||
drawText(
|
||
textLayoutResult = titleLayout,
|
||
topLeft = Offset(
|
||
(size.width - titleLayout.size.width) / 2f,
|
||
0f
|
||
)
|
||
)
|
||
|
||
// 2. 绘制星期行
|
||
val weekdayY = titleHeightPx
|
||
WEEKDAY_LABELS.forEachIndexed { i, label ->
|
||
val layout = weekdayLayouts[label]!!
|
||
drawText(
|
||
textLayoutResult = layout,
|
||
topLeft = Offset(
|
||
i * cellWidth + (cellWidth - layout.size.width) / 2f,
|
||
weekdayY + (weekdayHeightPx - layout.size.height) / 2f
|
||
)
|
||
)
|
||
}
|
||
|
||
// 3. 绘制日期网格
|
||
val dayGridY = titleHeightPx + weekdayHeightPx
|
||
|
||
days.forEachIndexed { index, dayData ->
|
||
val row = index / 7
|
||
val col = index % 7
|
||
val centerX = col * cellWidth + cellWidth / 2f
|
||
val centerY = dayGridY + row * dayCellHeightPx + dayCellHeightPx / 2f
|
||
|
||
val isToday = dayData.date == today && dayData.isCurrentMonth
|
||
val dayNum = if (dayData.isCurrentMonth) dayData.date.day else 0
|
||
val textColor: Color = when {
|
||
!dayData.isCurrentMonth -> colors.otherMonth
|
||
isToday -> colors.todayText
|
||
else -> colors.day
|
||
}
|
||
|
||
if (isToday) {
|
||
val radius = cellWidth.coerceAtMost(dayCellHeightPx) / 2f * 0.8f
|
||
drawCircle(
|
||
color = colors.todayBg,
|
||
radius = radius,
|
||
center = Offset(centerX, centerY)
|
||
)
|
||
}
|
||
|
||
if (dayNum > 0) {
|
||
dayLayouts[dayNum to textColor]?.let { layoutResult ->
|
||
drawText(
|
||
textLayoutResult = layoutResult,
|
||
topLeft = Offset(
|
||
x = centerX - layoutResult.size.width / 2f,
|
||
y = centerY - layoutResult.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)
|
||
)
|
||
}
|
||
}
|