refactor(ui): optimize year view performance, animations, and layout
This commit is contained in:
parent
d7406ef652
commit
fd7f79c3d3
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user