meyou c996d026cc Replace year view month cells with mini calendar grids
Each month in the 4x3 year grid now shows a compact calendar with
day numbers, matching the iOS Calendar year view style. Today is
highlighted with a filled circle. Selected month title uses primary color.
2026-05-16 16:35:56 +08:00

300 lines
12 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.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.statusBarsPadding
import androidx.compose.foundation.pager.rememberPagerState
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.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.platform.LocalDensity
import androidx.compose.ui.unit.dp
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 kotlinx.coroutines.launch
import plus.rua.project.CalendarViewModel
import kotlin.math.abs
import kotlin.time.Clock
/**
* 日历主界面,包含月/周视图切换、折叠动画和年视图缩放转场。
*
* 折叠时日历从月视图收缩为周视图1行BottomCard 同步上移填充空间。
* 点击月份标题切换年视图,以当前月为锚点缩放转场。
*
* @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) }
val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE })
// 折叠态 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.scrollToPage(targetPage)
}
}
val collapseProgress = viewModel.collapseProgress
val yearProgress = viewModel.yearViewProgress
val headerHeightPx = monthHeaderHeightPx + weekdayHeaderHeightPx
val rowPaddingPx = with(density) { ROW_PADDING_DP.dp.toPx() }.toInt()
val cardGapPx = with(density) {
lerp(
CARD_GAP_EXPANDED_DP.toFloat(),
CARD_GAP_COLLAPSED_DP.toFloat(),
collapseProgress
).dp.toPx()
}.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()
}
}
}
val estimatedRowHeightPx = if (screenWidthPx > 0) {
val cellWidth =
(screenWidthPx - with(density) { (HORIZONTAL_PADDING_DP * 2).dp.toPx() }) / 7
val rowPadding = with(density) { (ROW_PADDING_DP * 2).dp.toPx() }
(cellWidth + rowPadding).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
// 月视图层缩放:从 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(
modifier = modifier
.fillMaxSize()
.statusBarsPadding()
.onSizeChanged { size ->
screenWidthPx = size.width
screenHeightPx = size.height
}
) {
// 月视图层
Column(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = monthScale
scaleY = monthScale
alpha = monthAlpha
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
}
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
MonthHeader(
year = currentYear,
month = currentMonth,
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate),
showToday = viewModel.selectedDate != today,
onToggleYearView = { viewModel.toggleYearView() },
onToday = {
viewModel.selectDate(today)
@Suppress("DEPRECATION") // monthNumber 无替代 API
val targetPage = yearMonthToPage(
today.year, today.month.number,
today.year, today.month.number
)
if (targetPage != pagerState.currentPage) {
coroutineScope.launch { pagerState.animateScrollToPage(targetPage) }
}
},
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)
},
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,
onRowHeightMeasured = { h ->
if (h > 0) rowHeightPx = h
},
pagerState = pagerState,
modifier = pagerModifier
)
}
}
// 年视图层
if (viewModel.isYearView || yearProgress > 0.01f) {
YearGridView(
year = viewModel.yearViewYear,
selectedMonth = if (viewModel.yearViewYear == currentYear) currentMonth else 0,
today = today,
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 dragRangePx = if (effectiveRowHeightPx > 0) {
maxOf((effectiveWeeks - 1) * effectiveRowHeightPx.toFloat(), dragRangeMinPx)
} else {
dragRangeMinPx
}
if (cardHeightPx > 0) {
BottomCard(
viewModel = viewModel,
dragRangePx = dragRangePx,
modifier = Modifier
.fillMaxWidth()
.height(with(density) { cardHeightPx.toDp() })
.align(Alignment.BottomCenter)
)
}
}
}
}