meyou fab0a5eba8
避免月/年视图切换时整棵树销毁:共存 + Modifier.alpha 控制
Perfetto trace 显示 Compose:onForgotten 耗时 600ms,根因是
if(!isYearView)/if(isYearView) 条件渲染导致整棵子树在切换时被销毁重建。

修复:
1. 月视图和年视图始终共存于组合树中
2. 通过 Modifier.alpha() 控制可见性和触摸事件分发
3. graphicsLayer 仅保留 scale 动画,alpha 移出到 Modifier 层
4. 简化 toggleYearView():移除 withFrameNanos/animJob.join() 的复杂协程逻辑

两个视图通过 yearViewProgress 驱动的交叉淡入淡出同步切换,
消除 onForgotten 的组件销毁开销。
2026-05-18 23:05:07 +08:00

495 lines
21 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package plus.rua.project.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
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.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.todayIn
import plus.rua.project.CalendarViewModel
import kotlin.math.abs
import kotlin.time.Clock
/**
* 日历主界面,包含月/周视图切换、折叠动画和年视图缩放转场。
*
* 折叠时日历从月视图收缩为周视图1行BottomCard 同步上移填充空间。
* 通过左下角 FAB 菜单切换月/年视图,以当前月为锚点缩放转场。
*
* @param modifier 外部布局修饰符
*/
@Composable
fun CalendarMonthView(
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
val viewModel = remember { CalendarViewModel(coroutineScope) }
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
val currentYear by remember { derivedStateOf { viewModel.selectedDate.year } }
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
val currentMonth by remember { derivedStateOf { viewModel.selectedDate.month.number } }
val density = LocalDensity.current
var monthHeaderHeightPx by remember { mutableIntStateOf(0) }
var weekdayHeaderHeightPx by remember { mutableIntStateOf(0) }
var rowHeightPx by remember { mutableIntStateOf(0) }
var screenWidthPx by remember { mutableIntStateOf(0) }
var screenHeightPx by remember { mutableIntStateOf(0) }
var calendarContentHeightPx by remember { mutableIntStateOf(0) }
var isMenuExpanded by remember { mutableStateOf(false) }
// 视图切换时自动关闭菜单
LaunchedEffect(viewModel.isYearView) {
isMenuExpanded = false
}
val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE })
// 年视图分页器
val yearPagerState = rememberPagerState(
initialPage = START_PAGE,
pageCount = { Int.MAX_VALUE }
)
// 进入年视图时同步 yearPagerState 到当前年
LaunchedEffect(viewModel.isYearView) {
if (viewModel.isYearView) {
if (yearPagerState.currentPage != START_PAGE) {
yearPagerState.scrollToPage(START_PAGE)
}
}
}
// 年视图翻页时同步 yearViewYear
LaunchedEffect(yearPagerState) {
snapshotFlow { yearPagerState.settledPage }.collect { page ->
val offset = page - START_PAGE
val targetYear = viewModel.selectedDate.year + offset
if (targetYear != viewModel.yearViewYear) {
viewModel.yearViewYear = targetYear
}
}
}
// 折叠态 WeekPager 切月时,持续同步 CalendarPager 的 pagerState
LaunchedEffect(viewModel.selectedDate) {
@Suppress("DEPRECATION") // monthNumber 无替代 API
val targetPage = yearMonthToPage(
viewModel.selectedDate.year, viewModel.selectedDate.month.number,
today.year, today.month.number
)
if (targetPage != pagerState.currentPage) {
pagerState.animateScrollToPage(targetPage)
}
}
val collapseProgress = viewModel.collapseProgress
val headerHeightPx = monthHeaderHeightPx + weekdayHeaderHeightPx
// 预计算固定 dp→px避免每帧重复 density 转换
val cardGapExpandedPx = remember { with(density) { CARD_GAP_EXPANDED_DP.dp.toPx() } }
val cardGapCollapsedPx = remember { with(density) { CARD_GAP_COLLAPSED_DP.dp.toPx() } }
val rowPaddingPx = remember { with(density) { ROW_PADDING_DP.dp.toPx() } }.toInt()
val cardGapPx = lerp(cardGapExpandedPx, cardGapCollapsedPx, collapseProgress).toInt()
val interpolatedWeeks by remember {
derivedStateOf {
val fraction = pagerState.currentPageOffsetFraction
if (abs(fraction) > OFFSET_FRACTION_THRESHOLD) {
val cp = pagerState.currentPage
val baseWeeks = calculateWeeksCountForPage(cp, today)
val targetPage = cp + if (fraction > 0) 1 else -1
val targetWeeks = calculateWeeksCountForPage(targetPage, today)
lerp(baseWeeks.toFloat(), targetWeeks.toFloat(), abs(fraction))
} else {
calculateWeeksCountForPage(pagerState.currentPage, today).toFloat()
}
}
}
// 预计算固定 dp→px避免每帧重复 density 转换
val horizontalPaddingPx = remember { with(density) { (HORIZONTAL_PADDING_DP * 2).dp.toPx() } }
val rowPadding2Px = remember { with(density) { (ROW_PADDING_DP * 2).dp.toPx() } }
val estimatedRowHeightPx = if (screenWidthPx > 0) {
val cellWidth = (screenWidthPx - horizontalPaddingPx) / 7
(cellWidth + rowPadding2Px).toInt()
} else 0
val effectiveRowHeightPx = if (rowHeightPx > 0) rowHeightPx else estimatedRowHeightPx
val effectiveWeeks = interpolatedWeeks
val gridHeightPx = if (effectiveRowHeightPx > 0) {
val rowH = effectiveRowHeightPx.toFloat()
if (collapseProgress > OFFSET_FRACTION_THRESHOLD) {
(rowH * (1 + (effectiveWeeks - 1) * (1f - collapseProgress))).toInt()
} else {
(rowH * effectiveWeeks).toInt()
}
} else 0
val calendarAreaHeightPx = headerHeightPx + gridHeightPx + rowPaddingPx + cardGapPx
val cardHeightPx =
if (screenHeightPx > 0 && calendarAreaHeightPx > 0) screenHeightPx - calendarAreaHeightPx else 0
val pagerModifier = if (rowHeightPx > 0 && gridHeightPx > 0) {
Modifier
.height(with(density) { gridHeightPx.toDp() })
.clipToBounds()
} else {
Modifier
}
// 年视图锚点缩放:当前月在 4×3 网格中的归一化位置
val anchorPivotX = ((currentMonth - 1) % 3 + 0.5f) / 3f
val anchorPivotY = ((currentMonth - 1) / 3 + 0.5f) / 4f
Box(
modifier = modifier
.fillMaxSize()
.statusBarsPadding()
.onSizeChanged { size ->
screenWidthPx = size.width
screenHeightPx = size.height
}
) {
// 月视图层:始终存在于组合树中,通过 alpha 控制可见性/触摸,避免 isYearView
// 切换时触发整棵树销毁Compose:onForgotten 600ms。scale 动画保留在 graphicsLayer。
val monthProgress = 1f - viewModel.yearViewProgress
val layoutReady = rowHeightPx > 0
val monthAlpha = if (layoutReady) monthProgress.coerceIn(0f, 1f) else 0f
Box(
modifier = Modifier
.fillMaxSize()
.alpha(monthAlpha)
.graphicsLayer {
val scale = lerp(0.3f, 1f, monthProgress)
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
}
) {
val dragRangeMinPx = with(density) { DRAG_RANGE_MIN_DP.dp.toPx() }
val dragRangePx = if (effectiveRowHeightPx > 0) {
maxOf((effectiveWeeks - 1) * effectiveRowHeightPx.toFloat(), dragRangeMinPx)
} else {
dragRangeMinPx
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
MonthHeader(
year = currentYear,
month = currentMonth,
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate),
showToday = viewModel.selectedDate != today,
onToday = {
viewModel.selectDate(today)
},
modifier = Modifier.onSizeChanged { size ->
monthHeaderHeightPx = size.height
}
)
WeekdayHeader(
modifier = Modifier.fillMaxWidth().padding(bottom = ROW_PADDING_DP.dp)
.onSizeChanged { size ->
weekdayHeaderHeightPx = size.height
}
)
if (viewModel.isCollapsed && viewModel.collapseProgress >= 1f) {
WeekPager(
selectedDate = viewModel.selectedDate,
today = today,
onDateClick = { date -> viewModel.selectDate(date) },
onWeekChanged = { weekMonday ->
val weekSunday = weekMonday.plus(DatePeriod(days = 6))
val date = when {
today in weekMonday..weekSunday -> today
weekMonday.month != weekSunday.month -> {
if (weekMonday < viewModel.selectedDate) {
@Suppress("DEPRECATION") // monthNumber 无替代 API
LocalDate(weekSunday.year, weekSunday.month.number, 1)
} else {
weekMonday
}
}
else -> weekMonday
}
viewModel.selectDate(date)
},
shiftKindAt = { date -> viewModel.shiftKindAt(date) },
showLegalHoliday = viewModel.showLegalHoliday,
modifier = pagerModifier
)
} else {
CalendarPager(
selectedDate = viewModel.selectedDate,
today = today,
onDateClick = { date -> viewModel.selectDate(date) },
onMonthChanged = { year, month ->
@Suppress("DEPRECATION") // monthNumber 无替代 API
val date =
if (year == today.year && today.month.number == month) today
else LocalDate(year, month, 1)
viewModel.selectDate(date)
},
collapseProgress = viewModel.collapseProgress,
rowHeightPx = rowHeightPx,
effectiveWeeks = effectiveWeeks,
shiftKindAt = { date -> viewModel.shiftKindAt(date) },
showLegalHoliday = viewModel.showLegalHoliday,
onRowHeightMeasured = { h ->
if (h > 0) rowHeightPx = h
},
pagerState = pagerState,
modifier = pagerModifier
)
}
}
if (cardHeightPx > 0) {
BottomCard(
viewModel = viewModel,
dragRangePx = dragRangePx,
modifier = Modifier
.fillMaxWidth()
.height(with(density) { cardHeightPx.toDp() })
.align(Alignment.BottomCenter)
)
}
}
// 年视图层:始终存在于组合树中,通过 alpha 控制可见性/触摸。
val yearProgress = viewModel.yearViewProgress
val yearAlpha = yearProgress.coerceIn(0f, 1f)
Column(
modifier = Modifier
.fillMaxSize()
.alpha(yearAlpha)
.graphicsLayer {
val scale = lerp(3.3f, 1f, yearProgress)
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
}
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
YearHeader(
year = viewModel.yearViewYear,
onYearChange = { newYear ->
val offset = newYear - viewModel.yearViewYear
val targetPage = yearPagerState.currentPage + offset
if (targetPage != yearPagerState.currentPage) {
coroutineScope.launch { yearPagerState.animateScrollToPage(targetPage) }
}
}
)
HorizontalPager(
state = yearPagerState,
beyondViewportPageCount = 0,
flingBehavior = PagerDefaults.flingBehavior(state = yearPagerState),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { page ->
val pageOffset = abs(yearPagerState.currentPageOffsetFraction)
val isCurrentPage = page == yearPagerState.currentPage
val crossFadeAlpha = if (isCurrentPage) {
1f - pageOffset
} else {
pageOffset
}
val pageYear = viewModel.selectedDate.year + (page - START_PAGE)
YearGridView(
year = pageYear,
selectedMonth = if (pageYear == currentYear) currentMonth else 0,
today = today,
onMonthClick = { month ->
viewModel.selectMonthFromYearView(month)
@Suppress("DEPRECATION") // monthNumber 无替代 API
val targetPage = yearMonthToPage(
viewModel.yearViewYear, month,
today.year, today.month.number
)
if (targetPage != pagerState.currentPage) {
coroutineScope.launch { pagerState.scrollToPage(targetPage) }
}
},
modifier = Modifier.alpha(crossFadeAlpha)
)
}
}
// FAB 浮动按钮
FloatingActionButton(
onClick = { isMenuExpanded = !isMenuExpanded },
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 24.dp, bottom = 32.dp),
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
MenuIcon()
}
// Scrim菜单展开时覆盖全屏点击关闭
AnimatedVisibility(
visible = isMenuExpanded,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200))
) {
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { isMenuExpanded = false }
}
.background(Color.Black.copy(alpha = 0.32f))
)
}
// 缩放动画菜单
AnimatedVisibility(
visible = isMenuExpanded,
enter = scaleIn(
initialScale = 0.2f,
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
transformOrigin = TransformOrigin(0f, 1f)
) + fadeIn(tween(150)),
exit = scaleOut(
targetScale = 0.2f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
transformOrigin = TransformOrigin(0f, 1f)
) + fadeOut(tween(100)),
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 24.dp, bottom = 32.dp + 56.dp + 8.dp)
) {
Card(
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(modifier = Modifier.width(140.dp)) {
MenuItem(
text = "月视图",
selected = !viewModel.isYearView,
onClick = {
isMenuExpanded = false
if (viewModel.isYearView) viewModel.toggleYearView()
}
)
MenuItem(
text = "年视图",
selected = viewModel.isYearView,
onClick = {
isMenuExpanded = false
if (!viewModel.isYearView) viewModel.toggleYearView()
}
)
}
}
}
}
}
@Composable
private fun MenuIcon(modifier: Modifier = Modifier) {
Canvas(modifier = modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
val lineSpacing = 4.dp.toPx()
val totalHeight = strokeWidth * 3 + lineSpacing * 2
val startY = (size.height - totalHeight) / 2
repeat(3) { i ->
drawLine(
color = Color.White,
start = Offset(0f, startY + i * (strokeWidth + lineSpacing)),
end = Offset(size.width, startY + i * (strokeWidth + lineSpacing)),
strokeWidth = strokeWidth
)
}
}
}
@Composable
private fun MenuItem(
text: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.then(
if (selected) Modifier.background(
MaterialTheme.colorScheme.primaryContainer,
RoundedCornerShape(8.dp)
) else Modifier
)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Text(
text = text,
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge
)
}
}