Extract calendar utilities and use derivedStateOf for reactive state

Move shared constants and helper functions into CalendarUtils.kt,
replace manual state synchronization with derivedStateOf in
CalendarViewModel and UI composables.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-15 11:08:31 +08:00
parent 3612efb665
commit ddc852a667
7 changed files with 133 additions and 111 deletions

View File

@ -16,6 +16,7 @@ import kotlinx.datetime.number
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
import kotlin.time.Clock import kotlin.time.Clock
import plus.rua.project.ui.COLLAPSE_THRESHOLD
data class CalendarDay( data class CalendarDay(
val date: LocalDate, val date: LocalDate,
@ -56,11 +57,11 @@ class CalendarViewModel(private val coroutineScope: CoroutineScope) {
} }
} }
// 拖拽超过 50% 时自动折叠到周视图,否则回弹到月视图 // 拖拽超过阈值时自动折叠到周视图,否则回弹到月视图
fun onDragEnd() { fun onDragEnd() {
coroutineScope.launch { coroutineScope.launch {
val current = _collapseAnimatable.value val current = _collapseAnimatable.value
if (current > 0.5f) { if (current > COLLAPSE_THRESHOLD) {
_collapseAnimatable.animateTo( _collapseAnimatable.animateTo(
targetValue = 1f, targetValue = 1f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f) animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f)
@ -83,11 +84,11 @@ class CalendarViewModel(private val coroutineScope: CoroutineScope) {
} }
} }
// 下拉超过 50% 时自动展开到月视图,否则回弹到周视图 // 下拉超过阈值时自动展开到月视图,否则回弹到周视图
fun onExpandDragEnd() { fun onExpandDragEnd() {
coroutineScope.launch { coroutineScope.launch {
val current = _collapseAnimatable.value val current = _collapseAnimatable.value
if (current < 0.5f) { if (current < COLLAPSE_THRESHOLD) {
_collapseAnimatable.animateTo( _collapseAnimatable.animateTo(
targetValue = 0f, targetValue = 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f) animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f)
@ -151,4 +152,4 @@ class CalendarViewModel(private val coroutineScope: CoroutineScope) {
) )
} }
} }
} }

View File

@ -32,7 +32,7 @@ fun BottomCard(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val dragRange = with(density) { 200.dp.toPx() } val dragRange = with(density) { DRAG_RANGE_DP.dp.toPx() }
Surface( Surface(
modifier = modifier modifier = modifier
@ -82,4 +82,4 @@ fun BottomCard(
) )
} }
} }
} }

View File

@ -111,7 +111,7 @@ fun CalendarMonthPage(
else Modifier else Modifier
) )
.offset(y = yOffsetDp) .offset(y = yOffsetDp)
.padding(vertical = 4.dp) .padding(vertical = ROW_PADDING_DP.dp)
.then( .then(
if (weekIndex == 0 && rowHeightPx == 0) { if (weekIndex == 0 && rowHeightPx == 0) {
Modifier.onSizeChanged { size -> Modifier.onSizeChanged { size ->
@ -157,4 +157,4 @@ private fun generateMonthDays(year: Int, month: Int): List<DayData> {
isCurrentMonth = date.month.number == month && date.year == year isCurrentMonth = date.month.number == month && date.year == year
) )
} }
} }

View File

@ -31,10 +31,6 @@ import kotlin.math.abs
import kotlin.time.Clock import kotlin.time.Clock
import plus.rua.project.CalendarViewModel import plus.rua.project.CalendarViewModel
private const val START_PAGE = Int.MAX_VALUE / 2
private const val ROW_PADDING_DP = 4
private const val HORIZONTAL_PADDING_DP = 16
/** /**
* 日历主界面包含月/周视图切换和折叠动画 * 日历主界面包含月/周视图切换和折叠动画
* *
@ -50,8 +46,8 @@ fun CalendarMonthView(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val viewModel = remember { CalendarViewModel(coroutineScope) } val viewModel = remember { CalendarViewModel(coroutineScope) }
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
var currentYear by remember { mutableIntStateOf(viewModel.currentYear) } val currentYear by remember { derivedStateOf { viewModel.selectedDate.year } }
var currentMonth by remember { mutableIntStateOf(viewModel.currentMonth) } val currentMonth by remember { derivedStateOf { viewModel.selectedDate.month.number } }
val density = LocalDensity.current val density = LocalDensity.current
var monthHeaderHeightPx by remember { mutableIntStateOf(0) } var monthHeaderHeightPx by remember { mutableIntStateOf(0) }
@ -66,24 +62,19 @@ fun CalendarMonthView(
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 interpolatedWeeks by remember {
// 以 currentPage 为基准页offsetFraction 表示基准页与可视区域左边缘的偏移: derivedStateOf {
// offsetFraction > 0基准页偏右可视区域露出下一页page+1 val fraction = pagerState.currentPageOffsetFraction
// offsetFraction < 0基准页偏左可视区域露出上一页page-1 if (abs(fraction) > OFFSET_FRACTION_THRESHOLD) {
// 过渡进度 = abs(offsetFraction),目标页 = page ± 1。 val cp = pagerState.currentPage
// 当 currentPage 跳变(如从 Jul 跳到 Aug基准页行数也随之跳变 val baseWeeks = calculateWeeksCountForPage(cp, today)
// 但 abs(offsetFraction) 同时从 ~0.5 降到 ~0.5(连续),所以插值结果连续: val targetPage = cp + if (fraction > 0) 1 else -1
// 跳变前: cp=Jul(5行), off=+0.49 → base=5, target=Aug(6), lerp(5,6,0.49)=5.49 val targetWeeks = calculateWeeksCountForPage(targetPage, today)
// 跳变后: cp=Aug(6行), off=-0.47 → base=6, target=Jul(5), lerp(6,5,0.47)=5.47 ← 连续! lerp(baseWeeks.toFloat(), targetWeeks.toFloat(), abs(fraction))
val offsetFraction by remember { derivedStateOf { pagerState.currentPageOffsetFraction } } } else {
val interpolatedWeeks = if (abs(offsetFraction) > 0.01f) { calculateWeeksCountForPage(pagerState.currentPage, today).toFloat()
val cp = pagerState.currentPage }
val baseWeeks = calculateWeeksCountForPage(cp, today) }
val targetPage = cp + if (offsetFraction > 0) 1 else -1
val targetWeeks = calculateWeeksCountForPage(targetPage, today)
lerp(baseWeeks.toFloat(), targetWeeks.toFloat(), abs(offsetFraction))
} else {
calculateWeeksCountForPage(pagerState.currentPage, today).toFloat()
} }
// 预估行高DayCell aspectRatio=1宽度 = (screenWidth - horizontalPadding) / 7 // 预估行高DayCell aspectRatio=1宽度 = (screenWidth - horizontalPadding) / 7
@ -100,14 +91,18 @@ fun CalendarMonthView(
// gridH = rowH × (1 + (weeks-1) × (1-p)) // gridH = rowH × (1 + (weeks-1) × (1-p))
val effectiveWeeks = interpolatedWeeks val effectiveWeeks = interpolatedWeeks
val gridHeightPx = if (effectiveRowHeightPx > 0) { val gridHeightPx by remember {
val rowH = effectiveRowHeightPx.toFloat() derivedStateOf {
if (p > 0.01f) { if (effectiveRowHeightPx > 0) {
(rowH * (1 + (effectiveWeeks - 1) * (1f - p))).toInt() val rowH = effectiveRowHeightPx.toFloat()
} else { if (p > OFFSET_FRACTION_THRESHOLD) {
(rowH * effectiveWeeks).toInt() (rowH * (1 + (effectiveWeeks - 1) * (1f - p))).toInt()
} else {
(rowH * effectiveWeeks).toInt()
}
} else 0
} }
} else 0 }
val calendarAreaHeightPx = headerHeightPx + gridHeightPx + rowPaddingPx val calendarAreaHeightPx = headerHeightPx + gridHeightPx + rowPaddingPx
val cardHeightPx = if (screenHeightPx > 0 && calendarAreaHeightPx > 0) screenHeightPx - calendarAreaHeightPx else 0 val cardHeightPx = if (screenHeightPx > 0 && calendarAreaHeightPx > 0) screenHeightPx - calendarAreaHeightPx else 0
@ -154,8 +149,6 @@ fun CalendarMonthView(
val weekSunday = weekMonday.plus(DatePeriod(days = 6)) val weekSunday = weekMonday.plus(DatePeriod(days = 6))
val date = if (today in weekMonday..weekSunday) today else weekMonday val date = if (today in weekMonday..weekSunday) today else weekMonday
viewModel.selectDate(date) viewModel.selectDate(date)
currentYear = date.year
currentMonth = date.month.number
} }
) )
} else { } else {
@ -167,14 +160,12 @@ fun CalendarMonthView(
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)
currentYear = year
currentMonth = month
}, },
collapseProgress = viewModel.collapseProgress, collapseProgress = viewModel.collapseProgress,
rowHeightPx = rowHeightPx, rowHeightPx = rowHeightPx,
effectiveWeeks = effectiveWeeks, effectiveWeeks = effectiveWeeks,
onRowHeightMeasured = { h -> onRowHeightMeasured = { h ->
if (h > 0 && rowHeightPx == 0) rowHeightPx = h if (h > 0) rowHeightPx = h
}, },
pagerState = pagerState, pagerState = pagerState,
modifier = pagerModifier modifier = pagerModifier
@ -192,16 +183,4 @@ fun CalendarMonthView(
) )
} }
} }
} }
private fun lerp(start: Float, end: Float, fraction: Float): Float = start + (end - start) * fraction
private fun calculateWeeksCountForPage(page: Int, today: LocalDate): Int {
val initialYear = today.year
val initialMonth = today.month.number
val offset = page - START_PAGE
val totalMonths = initialYear * 12 + (initialMonth - 1) + offset
val year = totalMonths / 12
val month = totalMonths % 12 + 1
return calculateWeeksCount(year, month)
}

View File

@ -11,14 +11,9 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.minus
import kotlinx.datetime.number import kotlinx.datetime.number
/** 无限分页中心页,用于 HorizontalPager 的起始位置 */
private const val START_PAGE = Int.MAX_VALUE / 2
/** /**
* 月度日历分页器HorizontalPager 实现无限左右滑动切换月份 * 月度日历分页器HorizontalPager 实现无限左右滑动切换月份
* *
@ -43,13 +38,14 @@ fun CalendarPager(
pagerState: PagerState, pagerState: PagerState,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val initialYearMonth = remember { today.toYearMonth() } val initialYear = remember { today.year }
val initialMonth = remember { today.month.number }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// Sync settled page to onMonthChanged (skip initial emission to preserve "today" selection) // Sync settled page to onMonthChanged (skip initial emission to preserve "today" selection)
LaunchedEffect(pagerState) { LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.drop(1).collect { page -> snapshotFlow { pagerState.settledPage }.drop(1).collect { page ->
val yearMonth = pageToYearMonth(page, initialYearMonth) val yearMonth = pageToYearMonth(page, initialYear, initialMonth)
onMonthChanged(yearMonth.first, yearMonth.second) onMonthChanged(yearMonth.first, yearMonth.second)
} }
} }
@ -60,7 +56,7 @@ fun CalendarPager(
flingBehavior = PagerDefaults.flingBehavior(state = pagerState), flingBehavior = PagerDefaults.flingBehavior(state = pagerState),
modifier = modifier modifier = modifier
) { page -> ) { page ->
val (year, month) = pageToYearMonth(page, initialYearMonth) val (year, month) = pageToYearMonth(page, initialYear, initialMonth)
CalendarMonthPage( CalendarMonthPage(
year = year, year = year,
month = month, month = month,
@ -69,9 +65,10 @@ fun CalendarPager(
onDateClick = { date -> onDateClick = { date ->
onDateClick(date) onDateClick(date)
// If clicking a date in a different month, scroll to that page // If clicking a date in a different month, scroll to that page
val clickedYearMonth = date.toYearMonth() val clickedYear = date.year
if (clickedYearMonth != pageToYearMonth(page, initialYearMonth)) { val clickedMonth = date.month.number
val targetPage = yearMonthToPage(clickedYearMonth, initialYearMonth) if (clickedYear != year || clickedMonth != month) {
val targetPage = yearMonthToPage(clickedYear, clickedMonth, initialYear, initialMonth)
if (targetPage != pagerState.currentPage) { if (targetPage != pagerState.currentPage) {
coroutineScope.launch { coroutineScope.launch {
pagerState.animateScrollToPage(targetPage) pagerState.animateScrollToPage(targetPage)
@ -85,29 +82,4 @@ fun CalendarPager(
onRowHeightMeasured = onRowHeightMeasured onRowHeightMeasured = onRowHeightMeasured
) )
} }
} }
private fun LocalDate.toYearMonth(): Pair<Int, Int> = Pair(year, month.number)
// 页码→年月:偏移量 + 初始月份的绝对月数,再拆分回年月
private fun pageToYearMonth(page: Int, initial: Pair<Int, Int>): Pair<Int, Int> {
val offset = page - START_PAGE
val totalMonths = initial.first * 12 + (initial.second - 1) + offset
return Pair(totalMonths / 12, totalMonths % 12 + 1)
}
// 年月→页码:目标与初始的绝对月数差 + 起始页
private fun yearMonthToPage(yearMonth: Pair<Int, Int>, initial: Pair<Int, Int>): Int {
val targetTotal = yearMonth.first * 12 + (yearMonth.second - 1)
val initialTotal = initial.first * 12 + (initial.second - 1)
return START_PAGE + (targetTotal - initialTotal)
}
// 计算月份在日历网格中需要的行数4/5/6
internal fun calculateWeeksCount(year: Int, month: Int): Int {
val firstOfMonth = LocalDate(year, month, 1)
val offset = firstOfMonth.dayOfWeek.ordinal
val nextMonth = if (month == 12) LocalDate(year + 1, 1, 1) else LocalDate(year, month + 1, 1)
val daysInMonth = nextMonth.minus(DatePeriod(days = 1)).day
return ((offset + daysInMonth - 1) / 7) + 1
}

View File

@ -0,0 +1,86 @@
package plus.rua.project.ui
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.plus
/** 无限分页中心页,用于 HorizontalPager 的起始位置 */
const val START_PAGE = Int.MAX_VALUE / 2
/** 折叠判定阈值progress > 此值时折叠,< 此值时展开 */
const val COLLAPSE_THRESHOLD = 0.5f
/** 滑动偏移插值阈值abs(offsetFraction) > 此值时启用插值 */
const val OFFSET_FRACTION_THRESHOLD = 0.01f
/** 行内 vertical padding (dp) */
const val ROW_PADDING_DP = 4
/** 日历网格水平 padding (dp) */
const val HORIZONTAL_PADDING_DP = 16
/** BottomCard 拖拽手势范围 (dp) */
const val DRAG_RANGE_DP = 200
/** 线性插值 */
fun lerp(start: Float, end: Float, fraction: Float): Float = start + (end - start) * fraction
/**
* 计算月份在日历网格中需要的行数4/5/6
*/
fun calculateWeeksCount(year: Int, month: Int): Int {
val firstOfMonth = LocalDate(year, month, 1)
val offset = firstOfMonth.dayOfWeek.ordinal
val nextMonth = if (month == 12) LocalDate(year + 1, 1, 1) else LocalDate(year, month + 1, 1)
val daysInMonth = nextMonth.minus(DatePeriod(days = 1)).day
return ((offset + daysInMonth - 1) / 7) + 1
}
/**
* 根据 pager 页码计算该页月份的行数
*/
fun calculateWeeksCountForPage(page: Int, today: LocalDate): Int {
val initialYear = today.year
val initialMonth = today.month.number
val offset = page - START_PAGE
val totalMonths = initialYear * 12 + (initialMonth - 1) + offset
val year = totalMonths / 12
val month = totalMonths % 12 + 1
return calculateWeeksCount(year, month)
}
/**
* 页码转年月
*/
fun pageToYearMonth(page: Int, initialYear: Int, initialMonth: Int): Pair<Int, Int> {
val offset = page - START_PAGE
val totalMonths = initialYear * 12 + (initialMonth - 1) + offset
return Pair(totalMonths / 12, totalMonths % 12 + 1)
}
/**
* 年月转页码
*/
fun yearMonthToPage(year: Int, month: Int, initialYear: Int, initialMonth: Int): Int {
val targetTotal = year * 12 + (month - 1)
val initialTotal = initialYear * 12 + (initialMonth - 1)
return START_PAGE + (targetTotal - initialTotal)
}
/**
* 获取日期所在周的周一
*/
fun LocalDate.toWeekMonday(): LocalDate {
val dayOfWeekOrdinal = dayOfWeek.ordinal
return minus(DatePeriod(days = dayOfWeekOrdinal))
}
/**
* 根据 pager 页码计算该页对应的周周一日期
*/
fun pageToWeekMonday(page: Int, initial: LocalDate): LocalDate {
val offset = page - START_PAGE
return initial.plus(DatePeriod(days = offset * 7))
}

View File

@ -9,20 +9,14 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.datetime.DatePeriod import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
/** 无限分页中心页,用于 HorizontalPager 的起始位置 */
private const val START_PAGE = Int.MAX_VALUE / 2
/** /**
* 周视图分页器折叠状态下显示选中日期所在周支持左右滑动切换周 * 周视图分页器折叠状态下显示选中日期所在周支持左右滑动切换周
* *
@ -78,14 +72,4 @@ fun WeekPager(
} }
} }
} }
}
private fun LocalDate.toWeekMonday(): LocalDate {
val dayOfWeekOrdinal = dayOfWeek.ordinal // Monday=0 ... Sunday=6
return minus(DatePeriod(days = dayOfWeekOrdinal))
}
private fun pageToWeekMonday(page: Int, initial: LocalDate): LocalDate {
val offset = page - START_PAGE
return initial.plus(DatePeriod(days = offset * 7))
} }