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

View File

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