Wire year view into CalendarMonthView, MonthHeader and ViewModel

- CalendarViewModel: year view state and animation methods
- CalendarMonthView: graphicsLayer zoom overlay, BottomCard hiding
- MonthHeader: toggle year view on click, "今天" button
This commit is contained in:
meyou 2026-05-16 16:29:37 +08:00
parent 995693cb5d
commit 731a1bb6a1
3 changed files with 164 additions and 42 deletions

View File

@ -1,7 +1,9 @@
package plus.rua.project package plus.rua.project
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -61,6 +63,16 @@ class CalendarViewModel(
val currentYear: Int get() = selectedDate.year val currentYear: Int get() = selectedDate.year
var isYearView by mutableStateOf(false)
private set
private val _yearViewAnimatable = Animatable(0f)
val yearViewProgress: Float get() = _yearViewAnimatable.value
@Suppress("DEPRECATION") // monthNumber 无替代 API
var yearViewYear by mutableStateOf(today.year)
private set
/** /**
* 选中指定日期 * 选中指定日期
* *
@ -70,6 +82,51 @@ class CalendarViewModel(
selectedDate = date selectedDate = date
} }
/**
* 切换年视图仅在展开态可用
*/
fun toggleYearView() {
if (isCollapsed) return
coroutineScope.launch {
if (isYearView) {
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
isYearView = false
} else {
isYearView = true
_yearViewAnimatable.snapTo(0f)
_yearViewAnimatable.animateTo(
1f, tween(400, easing = FastOutSlowInEasing)
)
}
}
}
/**
* 从年视图选择月份后返回月视图
*/
@Suppress("DEPRECATION") // monthNumber 无替代 API
fun selectMonthFromYearView(month: Int) {
val date = if (yearViewYear == today.year && today.month.number == month) today
else LocalDate(yearViewYear, month, 1)
selectedDate = date
coroutineScope.launch {
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
isYearView = false
}
}
fun incrementYear() {
yearViewYear++
}
fun decrementYear() {
yearViewYear--
}
/** /**
* 展开状态下拖拽折叠delta 正值推动 progress 1折叠方向 * 展开状态下拖拽折叠delta 正值推动 progress 1折叠方向
* *

View File

@ -19,6 +19,8 @@ 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.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -34,10 +36,10 @@ import kotlin.math.abs
import kotlin.time.Clock import kotlin.time.Clock
/** /**
* 日历主界面包含月/周视图切换和折叠动画 * 日历主界面包含月/周视图切换折叠动画和年视图缩放转场
* *
* 折叠时日历从月视图收缩为周视图1BottomCard 同步上移填充空间 * 折叠时日历从月视图收缩为周视图1BottomCard 同步上移填充空间
* 支持动态行数4/5/6滑动切换月份时 BottomCard 跟手移动 * 点击月份标题切换年视图以当前月为锚点缩放转场
* *
* @param modifier 外部布局修饰符 * @param modifier 外部布局修饰符
*/ */
@ -59,11 +61,11 @@ fun CalendarMonthView(
var rowHeightPx by remember { mutableIntStateOf(0) } var rowHeightPx by remember { mutableIntStateOf(0) }
var screenWidthPx by remember { mutableIntStateOf(0) } var screenWidthPx by remember { mutableIntStateOf(0) }
var screenHeightPx by remember { mutableIntStateOf(0) } var screenHeightPx by remember { mutableIntStateOf(0) }
var calendarContentHeightPx by remember { mutableIntStateOf(0) }
val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE }) val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE })
// 折叠态 WeekPager 切月时,持续同步 CalendarPager 的 pagerState // 折叠态 WeekPager 切月时,持续同步 CalendarPager 的 pagerState
// 避免展开时 CalendarPager 首帧显示旧月份导致闪白
LaunchedEffect(viewModel.selectedDate) { LaunchedEffect(viewModel.selectedDate) {
@Suppress("DEPRECATION") // monthNumber 无替代 API @Suppress("DEPRECATION") // monthNumber 无替代 API
val targetPage = yearMonthToPage( val targetPage = yearMonthToPage(
@ -76,6 +78,7 @@ fun CalendarMonthView(
} }
val collapseProgress = viewModel.collapseProgress val collapseProgress = viewModel.collapseProgress
val yearProgress = viewModel.yearViewProgress
val headerHeightPx = monthHeaderHeightPx + weekdayHeaderHeightPx val headerHeightPx = monthHeaderHeightPx + weekdayHeaderHeightPx
val rowPaddingPx = with(density) { ROW_PADDING_DP.dp.toPx() }.toInt() val rowPaddingPx = with(density) { ROW_PADDING_DP.dp.toPx() }.toInt()
val cardGapPx = with(density) { val cardGapPx = with(density) {
@ -86,8 +89,6 @@ fun CalendarMonthView(
).dp.toPx() ).dp.toPx()
}.toInt() }.toInt()
// 翻页时在相邻月份行数之间插值,使 BottomCard 高度平滑过渡
// abs(fraction) > 阈值时启用插值,避免静止时的浮点抖动
val interpolatedWeeks by remember { val interpolatedWeeks by remember {
derivedStateOf { derivedStateOf {
val fraction = pagerState.currentPageOffsetFraction val fraction = pagerState.currentPageOffsetFraction
@ -103,9 +104,6 @@ fun CalendarMonthView(
} }
} }
// 预估行高DayCell aspectRatio=1宽度 = (screenWidth - horizontalPadding) / 7
// 加上 Row 的 vertical padding (6dp × 2)
// 用于 rowHeightPx 尚未测量时的 fallback避免首次布局高度为 0
val estimatedRowHeightPx = if (screenWidthPx > 0) { val estimatedRowHeightPx = if (screenWidthPx > 0) {
val cellWidth = val cellWidth =
(screenWidthPx - with(density) { (HORIZONTAL_PADDING_DP * 2).dp.toPx() }) / 7 (screenWidthPx - with(density) { (HORIZONTAL_PADDING_DP * 2).dp.toPx() }) / 7
@ -114,14 +112,8 @@ fun CalendarMonthView(
} else 0 } else 0
val effectiveRowHeightPx = if (rowHeightPx > 0) rowHeightPx else estimatedRowHeightPx val effectiveRowHeightPx = if (rowHeightPx > 0) rowHeightPx else estimatedRowHeightPx
val effectiveWeeks = interpolatedWeeks val effectiveWeeks = interpolatedWeeks
// 折叠时网格高度公式(与 CalendarMonthPage 一致):
// collapseProgress=0 展开时 gridH = rowH × weekscollapseProgress=1 折叠时 gridH = rowH × 1
// 中间态gridH = rowH × (1 + (weeks-1) × (1-collapseProgress))
// 直接计算而非 derivedStateOfeffectiveRowHeightPx 依赖 rowHeightPx state
// derivedStateOf 无法追踪非 State 局部变量rowHeightPx 从 0 变为测量值时 gridHeightPx 不会更新
val gridHeightPx = if (effectiveRowHeightPx > 0) { val gridHeightPx = if (effectiveRowHeightPx > 0) {
val rowH = effectiveRowHeightPx.toFloat() val rowH = effectiveRowHeightPx.toFloat()
if (collapseProgress > OFFSET_FRACTION_THRESHOLD) { if (collapseProgress > OFFSET_FRACTION_THRESHOLD) {
@ -131,12 +123,10 @@ fun CalendarMonthView(
} }
} else 0 } else 0
// BottomCard 高度 = 屏幕剩余空间(屏幕高度 - 日历区域高度)
val calendarAreaHeightPx = headerHeightPx + gridHeightPx + rowPaddingPx + cardGapPx val calendarAreaHeightPx = headerHeightPx + gridHeightPx + rowPaddingPx + cardGapPx
val cardHeightPx = val cardHeightPx =
if (screenHeightPx > 0 && calendarAreaHeightPx > 0) screenHeightPx - calendarAreaHeightPx else 0 if (screenHeightPx > 0 && calendarAreaHeightPx > 0) screenHeightPx - calendarAreaHeightPx else 0
// 行高已知时约束 pager 高度防止内容溢出;否则让 pager 自由扩展以触发首次行高测量
val pagerModifier = if (rowHeightPx > 0 && gridHeightPx > 0) { val pagerModifier = if (rowHeightPx > 0 && gridHeightPx > 0) {
Modifier Modifier
.height(with(density) { gridHeightPx.toDp() }) .height(with(density) { gridHeightPx.toDp() })
@ -145,6 +135,18 @@ fun CalendarMonthView(
Modifier Modifier
} }
// 年视图锚点缩放:当前月在 4×3 网格中的归一化位置
val anchorPivotX = ((currentMonth - 1) % 3 + 0.5f) / 3f
val anchorPivotY = ((currentMonth - 1) / 3 + 0.5f) / 4f
// 月视图层缩放:从 1f 缩小到 ~0.3f(年网格单格 vs 月视图大小比)
val monthScale = 1f - yearProgress * 0.7f
val monthAlpha = (1f - yearProgress * 1.4f).coerceIn(0f, 1f)
// 年视图层缩放:从 ~3.3f 放大到 1f
val yearScale = lerp(3.3f, 1f, yearProgress)
val yearAlpha = ((yearProgress - 0.2f) / 0.8f).coerceIn(0f, 1f)
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -154,12 +156,25 @@ fun CalendarMonthView(
screenHeightPx = size.height screenHeightPx = size.height
} }
) { ) {
Column(modifier = Modifier.padding(horizontal = HORIZONTAL_PADDING_DP.dp)) { // 月视图层
Column(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = monthScale
scaleY = monthScale
alpha = monthAlpha
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
}
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
MonthHeader( MonthHeader(
year = currentYear, year = currentYear,
month = currentMonth, month = currentMonth,
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate), weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate),
onClick = { showToday = viewModel.selectedDate != today,
onToggleYearView = { viewModel.toggleYearView() },
onToday = {
viewModel.selectDate(today) viewModel.selectDate(today)
@Suppress("DEPRECATION") // monthNumber 无替代 API @Suppress("DEPRECATION") // monthNumber 无替代 API
val targetPage = yearMonthToPage( val targetPage = yearMonthToPage(
@ -180,8 +195,6 @@ fun CalendarMonthView(
weekdayHeaderHeightPx = size.height weekdayHeaderHeightPx = size.height
} }
) )
// 完全折叠且无动画时切换到 WeekPager单行高效渲染
// 否则使用 CalendarPager含折叠动画和下拉恢复过程
if (viewModel.isCollapsed && viewModel.collapseProgress >= 1f) { if (viewModel.isCollapsed && viewModel.collapseProgress >= 1f) {
WeekPager( WeekPager(
selectedDate = viewModel.selectedDate, selectedDate = viewModel.selectedDate,
@ -193,11 +206,9 @@ fun CalendarMonthView(
today in weekMonday..weekSunday -> today today in weekMonday..weekSunday -> today
weekMonday.month != weekSunday.month -> { weekMonday.month != weekSunday.month -> {
if (weekMonday < viewModel.selectedDate) { if (weekMonday < viewModel.selectedDate) {
// 后退到跨月周如从5月回到4月27-5月3选较晚月份1号留在当月
@Suppress("DEPRECATION") // monthNumber 无替代 API @Suppress("DEPRECATION") // monthNumber 无替代 API
LocalDate(weekSunday.year, weekSunday.month.number, 1) LocalDate(weekSunday.year, weekSunday.month.number, 1)
} else { } else {
// 前进到跨月周如从4月前进到4月27-5月3选该周周一留在上个月
weekMonday weekMonday
} }
} }
@ -213,8 +224,7 @@ fun CalendarMonthView(
today = today, today = today,
onDateClick = { date -> viewModel.selectDate(date) }, onDateClick = { date -> viewModel.selectDate(date) },
onMonthChanged = { year, month -> onMonthChanged = { year, month ->
// 优先选中当月内的今天否则选中该月1号 @Suppress("DEPRECATION") // monthNumber 无替代 API
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
val date = if (year == today.year && today.month.number == month) today val date = if (year == today.year && today.month.number == month) today
else LocalDate(year, month, 1) else LocalDate(year, month, 1)
viewModel.selectDate(date) viewModel.selectDate(date)
@ -231,7 +241,41 @@ fun CalendarMonthView(
} }
} }
// 拖拽范围 = 折叠时日历实际高度变化量 (weeks-1)×rowHeight使手指移动与视觉变化 1:1 对应 // 年视图层
if (viewModel.isYearView || yearProgress > 0.01f) {
YearGridView(
year = viewModel.yearViewYear,
selectedMonth = if (viewModel.yearViewYear == currentYear) currentMonth else 0,
onMonthClick = { month ->
viewModel.selectMonthFromYearView(month)
// 同步 CalendarPager 到目标月份
@Suppress("DEPRECATION") // monthNumber 无替代 API
val targetPage = yearMonthToPage(
viewModel.yearViewYear, month,
today.year, today.month.number
)
if (targetPage != pagerState.currentPage) {
coroutineScope.launch { pagerState.scrollToPage(targetPage) }
}
},
onYearChange = { newYear ->
if (newYear > viewModel.yearViewYear) viewModel.incrementYear()
else viewModel.decrementYear()
},
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = yearScale
scaleY = yearScale
alpha = yearAlpha
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
}
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
)
}
// BottomCard年视图时隐藏
if (yearProgress < 0.01f) {
val dragRangeMinPx = with(density) { DRAG_RANGE_MIN_DP.dp.toPx() } val dragRangeMinPx = with(density) { DRAG_RANGE_MIN_DP.dp.toPx() }
val dragRangePx = if (effectiveRowHeightPx > 0) { val dragRangePx = if (effectiveRowHeightPx > 0) {
maxOf((effectiveWeeks - 1) * effectiveRowHeightPx.toFloat(), dragRangeMinPx) maxOf((effectiveWeeks - 1) * effectiveRowHeightPx.toFloat(), dragRangeMinPx)
@ -251,3 +295,4 @@ fun CalendarMonthView(
} }
} }
} }
}

View File

@ -13,12 +13,15 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** /**
* 月份标题栏显示"年月"文字和 ISO 周号 * 月份标题栏显示"年月"文字和 ISO 周号
@ -26,6 +29,9 @@ import androidx.compose.ui.unit.dp
* @param year 年份 * @param year 年份
* @param month 月份1-12 * @param month 月份1-12
* @param weekNumber 当前 ISO 周号 * @param weekNumber 当前 ISO 周号
* @param showToday 是否显示今天按钮 selectedDate today
* @param onToggleYearView 点击标题切换年视图
* @param onToday 点击今天按钮跳转今天
* @param modifier 外部布局修饰符 * @param modifier 外部布局修饰符
*/ */
@Composable @Composable
@ -33,14 +39,16 @@ fun MonthHeader(
year: Int, year: Int,
month: Int, month: Int,
weekNumber: Int, weekNumber: Int,
onClick: (() -> Unit)? = null, showToday: Boolean,
onToggleYearView: () -> Unit,
onToday: (() -> Unit)? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 14.dp, horizontal = 12.dp) .padding(vertical = 14.dp, horizontal = 12.dp)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), .clickable(onClick = onToggleYearView),
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
AnimatedContent( AnimatedContent(
@ -79,5 +87,17 @@ fun MonthHeader(
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
} }
Spacer(modifier = Modifier.weight(1f))
if (showToday && onToday != null) {
Text(
text = "今天",
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onToday)
.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
} }
} }