refactor(ui): optimize year view performance, animations, and layout

This commit is contained in:
xfy 2026-07-24 11:45:35 +08:00
parent d7406ef652
commit fd7f79c3d3
2 changed files with 165 additions and 96 deletions

View File

@ -235,16 +235,11 @@ fun CalendarMonthView(
targetState = isYearView,
label = "month_year_transition",
transitionSpec = {
val spatialEasing = FastOutSlowInEasing
val enter = scaleIn(
initialScale = 0.92f,
animationSpec = tween(380, easing = spatialEasing)
) + fadeIn(tween(380, easing = spatialEasing))
val exit = scaleOut(
targetScale = 0.92f,
animationSpec = tween(380, easing = spatialEasing)
) + fadeOut(tween(380, easing = spatialEasing))
enter togetherWith exit
fadeIn(
animationSpec = spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioNoBouncy)
) togetherWith fadeOut(
animationSpec = spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioNoBouncy)
)
},
modifier = Modifier.fillMaxSize()
) { yearViewActive ->
@ -397,6 +392,9 @@ fun CalendarMonthView(
BackHandler(enabled = isMenuExpanded) {
isMenuExpanded = false
}
BackHandler(enabled = isYearView && !isMenuExpanded) {
viewModel.toggleYearView()
}
// Scrim半透明遮罩在菜单展开时淡入拦截点击关闭菜单增强渐变视觉与聚焦感
AnimatedVisibility(

View File

@ -1,14 +1,23 @@
package plus.rua.project.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -17,29 +26,32 @@ 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.ui.platform.testTag
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
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.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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 androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import com.tyme.lunar.LunarYear
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
@ -49,6 +61,9 @@ import kotlinx.datetime.number
import kotlinx.datetime.plus
import plus.rua.project.composeTraceBeginSection
import plus.rua.project.composeTraceEndSection
import kotlin.math.roundToInt
import plus.rua.project.composeTraceBeginSection
import plus.rua.project.composeTraceEndSection
private val WEEKDAY_LABELS = listOf("", "", "", "", "", "", "")
@ -105,8 +120,8 @@ fun YearGridView(
val textMeasurer = rememberTextMeasurer()
val dayTextStyle = remember { TextStyle(fontSize = 8.sp, lineHeight = 12.sp) }
// 延迟文本测量到下一帧,避免首帧阻塞
val dayLayouts by produceState<Map<Int, androidx.compose.ui.text.TextLayoutResult>?>(null, textMeasurer, dayTextStyle, colors) {
// P0: 使用 remember 同步预测量所有可能的字符样式,消除 produceState 首帧空白/闪烁
val dayLayouts = remember(textMeasurer, dayTextStyle, colors) {
val days = 1..31
val colorTypes = listOf(0 to colors.day, 1 to colors.todayText, 2 to colors.otherMonth)
val map = HashMap<Int, androidx.compose.ui.text.TextLayoutResult>(32 * 3)
@ -115,10 +130,10 @@ fun YearGridView(
map[d * 3 + type] = textMeasurer.measure(d.toString(), dayTextStyle.copy(color = c))
}
}
value = map
map
}
val titleLayouts by produceState<Map<Int, androidx.compose.ui.text.TextLayoutResult>?>(null, textMeasurer, colors) {
val titleLayouts = remember(textMeasurer, colors) {
val map = HashMap<Int, androidx.compose.ui.text.TextLayoutResult>(13 * 2)
(1..12).forEach { month ->
val text = "${month}"
@ -131,15 +146,19 @@ fun YearGridView(
TextStyle(fontSize = 10.sp, color = colors.titleNormal)
)
}
value = map
map
}
val weekdayLayouts by produceState<Map<String, androidx.compose.ui.text.TextLayoutResult>?>(null, textMeasurer, colors) {
value = WEEKDAY_LABELS.associateWith { label ->
val weekdayLayouts = remember(textMeasurer, colors) {
WEEKDAY_LABELS.associateWith { label ->
textMeasurer.measure(label, TextStyle(fontSize = 8.sp, color = colors.weekday))
}
}
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val gridRows = if (isLandscape) 3 else 4
val gridCols = if (isLandscape) 4 else 3
Column(
modifier = modifier
.fillMaxSize()
@ -147,33 +166,36 @@ fun YearGridView(
horizontalAlignment = Alignment.CenterHorizontally
) {
// 4×3 月历网格
// 弹性平分纵向空间,横竖屏自适应
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 4.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.SpaceEvenly
) {
(0 until 4).forEach { row ->
(0 until gridRows).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,
days = monthDays[month - 1],
colors = colors,
dayLayouts = dayLayouts,
titleLayouts = titleLayouts,
weekdayLayouts = weekdayLayouts,
onClick = { onMonthClick(month) },
modifier = Modifier.weight(1f).then(monthModifier(month))
)
(0 until gridCols).forEach { col ->
val month = row * gridCols + col + 1
if (month <= 12) {
MiniMonth(
year = year,
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).then(monthModifier(month))
)
}
}
}
}
@ -195,9 +217,9 @@ private fun MiniMonth(
today: LocalDate,
days: List<MiniDayData>,
colors: MiniMonthColors,
dayLayouts: Map<Int, androidx.compose.ui.text.TextLayoutResult>?,
titleLayouts: Map<Int, androidx.compose.ui.text.TextLayoutResult>?,
weekdayLayouts: Map<String, androidx.compose.ui.text.TextLayoutResult>?,
dayLayouts: Map<Int, androidx.compose.ui.text.TextLayoutResult>,
titleLayouts: Map<Int, androidx.compose.ui.text.TextLayoutResult>,
weekdayLayouts: Map<String, androidx.compose.ui.text.TextLayoutResult>,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@ -211,43 +233,77 @@ private fun MiniMonth(
(titleHeightPx + titleToWeekdayGapPx + weekdayHeightPx + dayRowCount * dayCellHeightPx).toDp()
}
var isPressed by remember { mutableStateOf(false) }
val pressedScale by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1.0f,
animationSpec = spring(stiffness = Spring.StiffnessHigh),
label = "mini_month_press_scale"
)
val containsToday = remember(days, today) {
days.any { it.isCurrentMonth && it.date == today }
}
val semanticsDesc = remember(year, month, containsToday) {
"$year$month" + if (containsToday) ",包含今天" else ""
}
val containerShape = RoundedCornerShape(12.dp)
val selectedBgColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
val selectedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
val containerModifier = if (isSelected) {
Modifier
.background(color = selectedBgColor, shape = containerShape)
.border(width = 1.dp, color = selectedBorderColor, shape = containerShape)
} else Modifier
Column(
modifier = modifier
.padding(2.dp)
.then(containerModifier)
.graphicsLayer {
scaleX = pressedScale
scaleY = pressedScale
}
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
}
)
}
.clickable(onClick = onClick)
.padding(vertical = 2.dp)
.padding(vertical = 4.dp, horizontal = 2.dp)
.semantics {
contentDescription = "$year$month"
contentDescription = semanticsDesc
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) {
val dl = dayLayouts ?: return@Canvas
val tl = titleLayouts ?: return@Canvas
val wl = weekdayLayouts ?: return@Canvas
val cellWidth = size.width / 7f
// 1. 绘制标题
val titleLayout = tl[month * 2 + (if (isSelected) 1 else 0)]!!
drawText(
textLayoutResult = titleLayout,
topLeft = Offset(
(size.width - titleLayout.size.width) / 2f,
0f
// 1. 绘制标题(像素对齐避免抗锯齿模糊)
val titleLayout = titleLayouts[month * 2 + (if (isSelected) 1 else 0)]
if (titleLayout != null) {
val titleX = ((size.width - titleLayout.size.width) / 2f).roundToInt().toFloat()
drawText(
textLayoutResult = titleLayout,
topLeft = Offset(titleX, 0f)
)
)
}
// 2. 绘制星期行
val weekdayY = titleHeightPx + titleToWeekdayGapPx
WEEKDAY_LABELS.forEachIndexed { i, label ->
val layout = wl[label]!!
drawText(
textLayoutResult = layout,
topLeft = Offset(
i * cellWidth + (cellWidth - layout.size.width) / 2f,
weekdayY + (weekdayHeightPx - layout.size.height) / 2f
weekdayLayouts[label]?.let { layout ->
val x = (i * cellWidth + (cellWidth - layout.size.width) / 2f).roundToInt().toFloat()
val y = (weekdayY + (weekdayHeightPx - layout.size.height) / 2f).roundToInt().toFloat()
drawText(
textLayoutResult = layout,
topLeft = Offset(x, y)
)
)
}
}
// 3. 绘制日期网格
@ -256,8 +312,8 @@ private fun MiniMonth(
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 centerX = (col * cellWidth + cellWidth / 2f).roundToInt().toFloat()
val centerY = (dayGridY + row * dayCellHeightPx + dayCellHeightPx / 2f).roundToInt().toFloat()
val isToday = dayData.date == today && dayData.isCurrentMonth
val dayNum = if (dayData.isCurrentMonth) dayData.date.day else 0
@ -266,14 +322,9 @@ private fun MiniMonth(
isToday -> 1
else -> 0
}
val textColor: Color = when (colorType) {
2 -> colors.otherMonth
1 -> colors.todayText
else -> colors.day
}
if (isToday) {
val radius = cellWidth.coerceAtMost(dayCellHeightPx) / 2f * 0.8f
val radius = (cellWidth.coerceAtMost(dayCellHeightPx) / 2f * 0.8f).roundToInt().toFloat()
drawCircle(
color = colors.todayBg,
radius = radius,
@ -282,12 +333,12 @@ private fun MiniMonth(
}
if (dayNum > 0) {
dl[dayNum * 3 + colorType]?.let { layoutResult ->
dayLayouts[dayNum * 3 + colorType]?.let { layoutResult ->
drawText(
textLayoutResult = layoutResult,
topLeft = Offset(
x = centerX - layoutResult.size.width / 2f,
y = centerY - layoutResult.size.height / 2f
x = (centerX - layoutResult.size.width / 2f).roundToInt().toFloat(),
y = (centerY - layoutResult.size.height / 2f).roundToInt().toFloat()
)
)
}
@ -341,14 +392,12 @@ fun YearHeader(
Row(verticalAlignment = Alignment.CenterVertically) {
AnimatedContent(
targetState = year,
label = "year_header_title",
transitionSpec = {
if (targetState > initialState) {
slideInVertically(tween(250)) { -it } togetherWith
slideOutVertically(tween(250)) { it }
} else {
slideInVertically(tween(250)) { it } togetherWith
slideOutVertically(tween(250)) { -it }
}
val isForward = targetState > initialState
val slideIn = slideInVertically(tween(260)) { height -> if (isForward) -height else height } + fadeIn(tween(180))
val slideOut = slideOutVertically(tween(260)) { height -> if (isForward) height else -height } + fadeOut(tween(180))
slideIn togetherWith slideOut
}
) { y ->
Text(
@ -359,20 +408,42 @@ fun YearHeader(
)
}
Spacer(modifier = Modifier.weight(1f))
val showThisYear = year != currentYear
val thisYearAlpha by animateFloatAsState(
targetValue = if (showThisYear) 1f else 0f,
animationSpec = tween(200)
)
AnimatedVisibility(
visible = year != currentYear,
enter = fadeIn(tween(200)) + scaleIn(spring(stiffness = Spring.StiffnessMedium), initialScale = 0.7f),
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.8f),
label = "this_year_button_visibility"
) {
Surface(
onClick = { onYearChange(currentYear) },
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
) {
Text(
text = "今年",
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
}
AnimatedContent(
targetState = year,
label = "year_header_lunar",
transitionSpec = {
val isForward = targetState > initialState
val slideIn = slideInVertically(tween(260)) { height -> if (isForward) -height else height } + fadeIn(tween(180))
val slideOut = slideOutVertically(tween(260)) { height -> if (isForward) height else -height } + fadeOut(tween(180))
slideIn togetherWith slideOut
},
modifier = Modifier.padding(top = 4.dp)
) { y ->
Text(
text = "今年",
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
modifier = Modifier
.graphicsLayer { alpha = thisYearAlpha }
.clip(RoundedCornerShape(12.dp))
.clickable(enabled = showThisYear) { onYearChange(currentYear) }
.padding(horizontal = 10.dp, vertical = 4.dp)
text = lunarYearLabel(y),
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
style = MaterialTheme.typography.bodySmall
)
}
AnimatedContent(