From fd7f79c3d3fe7cdd2498053bdb34506807586e7f Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 24 Jul 2026 11:45:35 +0800 Subject: [PATCH] refactor(ui): optimize year view performance, animations, and layout --- .../plus/rua/project/ui/CalendarMonthView.kt | 18 +- .../plus/rua/project/ui/YearGridView.kt | 243 +++++++++++------- 2 files changed, 165 insertions(+), 96 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt index 32fd7c0..4f71968 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -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( diff --git a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt index 7eb8345..0f787ac 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt @@ -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?>(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(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?>(null, textMeasurer, colors) { + val titleLayouts = remember(textMeasurer, colors) { val map = HashMap(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?>(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, colors: MiniMonthColors, - dayLayouts: Map?, - titleLayouts: Map?, - weekdayLayouts: Map?, + dayLayouts: Map, + titleLayouts: Map, + weekdayLayouts: Map, 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(