xfy fbb7904880 Unify height calculation with effectiveWeeks and fix swipe interpolation continuity
Pass effectiveWeeks from CalendarMonthView through CalendarPager to
CalendarMonthPage so both use the same formula H*(1+(weeks-1)*(1-p)).
Fix interpolatedWeeks to anchor on settledPage instead of currentPage,
preventing direction/fraction discontinuity when currentPage jumps during
swipe transitions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 02:03:21 +08:00

218 lines
9.6 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.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.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.plus
import kotlinx.datetime.todayIn
import kotlin.math.abs
import kotlin.time.Clock
import plus.rua.project.CalendarViewModel
private const val START_PAGE = Int.MAX_VALUE / 2
private const val ROW_PADDING_DP = 4
private const val TAG = "CalMonthView"
/**
* 日历主界面,包含月/周视图切换和折叠动画。
*
* 折叠时日历从月视图收缩为周视图1行BottomCard 同步上移填充空间。
* 支持动态行数4/5/6行滑动切换月份时 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()) }
var currentYear by remember { mutableIntStateOf(viewModel.currentYear) }
var currentMonth by remember { mutableIntStateOf(viewModel.currentMonth) }
val density = LocalDensity.current
var monthHeaderHeightPx by remember { mutableIntStateOf(0) }
var weekdayHeaderHeightPx by remember { mutableIntStateOf(0) }
var rowHeightPx by remember { mutableIntStateOf(0) }
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
var currentWeeksCount by remember { mutableIntStateOf(calculateWeeksCount(today.year, today.monthNumber)) }
var screenWidthPx by remember { mutableIntStateOf(0) }
var screenHeightPx by remember { mutableIntStateOf(0) }
val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE })
val p = viewModel.collapseProgress
val headerHeightPx = monthHeaderHeightPx + weekdayHeaderHeightPx
val rowPaddingPx = with(density) { ROW_PADDING_DP.dp.toPx() }.toInt()
// 滑动偏移插值行数
// 始终以 settledPage 为锚点currentPage - settledPage 确定方向(-1/0/+1
// abs(offsetFraction) 为过渡进度。
// 这样在 currentPage 跳变前后,方向和进度都是连续的:
// 跳变前: sp=8月, cp=8月, diff=0, offsetFraction>0 → 目标9月, fraction 0→0.5
// 跳变后: sp=8月, cp=9月, diff=+1 → 目标9月, fraction 0.5→0
val offsetFraction by remember { derivedStateOf { pagerState.currentPageOffsetFraction } }
val interpolatedWeeks = if (abs(offsetFraction) > 0.01f) {
val sp = pagerState.settledPage
val diff = pagerState.currentPage - sp // -1, 0, or +1
val targetPage = if (diff != 0) sp + diff else sp + if (offsetFraction > 0) 1 else -1
val baseWeeks = calculateWeeksCountForPage(sp, today)
val targetWeeks = calculateWeeksCountForPage(targetPage, today)
lerp(baseWeeks.toFloat(), targetWeeks.toFloat(), abs(offsetFraction))
} else {
currentWeeksCount.toFloat()
}
// 预估行高DayCell aspectRatio=1宽度 = (screenWidth - horizontalPadding) / 7
// 加上 Row 的 vertical padding (4dp × 2)
val estimatedRowHeightPx = if (screenWidthPx > 0) {
val cellWidth = (screenWidthPx - with(density) { 32.dp.toPx() }) / 7
val rowPadding = with(density) { 8.dp.toPx() }
(cellWidth + rowPadding).toInt()
} else 0
val effectiveRowHeightPx = if (rowHeightPx > 0) rowHeightPx else estimatedRowHeightPx
// 折叠时网格高度公式(与 CalendarMonthPage 一致):
// gridH = rowH × (1 + (weeks-1) × (1-p))
val effectiveWeeks = interpolatedWeeks
val gridHeightPx = if (effectiveRowHeightPx > 0) {
val rowH = effectiveRowHeightPx.toFloat()
if (p > 0.01f) {
(rowH * (1 + (effectiveWeeks - 1) * (1f - p))).toInt()
} else {
(rowH * effectiveWeeks).toInt()
}
} else 0
val calendarAreaHeightPx = headerHeightPx + gridHeightPx + rowPaddingPx
val cardHeightPx = if (screenHeightPx > 0 && calendarAreaHeightPx > 0) screenHeightPx - calendarAreaHeightPx else 0
println("[$TAG] p=$p rowH=$rowHeightPx estRowH=$estimatedRowHeightPx effRowH=$effectiveRowHeightPx " +
"headerH=$headerHeightPx gridH=$gridHeightPx calAreaH=$calendarAreaHeightPx " +
"screenH=$screenHeightPx cardH=$cardHeightPx " +
"currentWeeks=$currentWeeksCount interpolatedWeeks=$interpolatedWeeks effectiveWeeks=$effectiveWeeks " +
"offsetFraction=$offsetFraction currentPage=${pagerState.currentPage} settledPage=${pagerState.settledPage}")
// 当 rowHeightPx 已知时,用计算的高度约束 pager否则让 pager 自由扩展以测量行高
val pagerModifier = if (rowHeightPx > 0 && gridHeightPx > 0) {
Modifier
.height(with(density) { gridHeightPx.toDp() })
.clipToBounds()
} else {
Modifier
}
Box(
modifier = modifier
.fillMaxSize()
.statusBarsPadding()
.onSizeChanged { size ->
screenWidthPx = size.width
screenHeightPx = size.height
}
) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
MonthHeader(
year = currentYear,
month = currentMonth,
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate),
modifier = Modifier.onSizeChanged { size ->
monthHeaderHeightPx = size.height
}
)
WeekdayHeader(
modifier = Modifier.fillMaxWidth().onSizeChanged { size ->
weekdayHeaderHeightPx = size.height
}.padding(bottom = ROW_PADDING_DP.dp)
)
// 完全折叠且无动画时显示 WeekPager否则显示 CalendarPager含下拉恢复过程
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 = if (today >= weekMonday && today <= weekSunday) today else weekMonday
viewModel.selectDate(date)
currentYear = date.year
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
currentMonth = date.monthNumber
}
)
} else {
CalendarPager(
selectedDate = viewModel.selectedDate,
today = today,
onDateClick = { date -> viewModel.selectDate(date) },
onMonthChanged = { year, month ->
val date = if (year == today.year && today.monthNumber == month) today
else LocalDate(year, month, 1)
viewModel.selectDate(date)
currentYear = year
currentMonth = month
},
collapseProgress = viewModel.collapseProgress,
rowHeightPx = rowHeightPx,
effectiveWeeks = effectiveWeeks,
onWeeksChanged = { weeks ->
currentWeeksCount = weeks
},
onRowHeightMeasured = { h ->
if (h > 0 && rowHeightPx == 0) rowHeightPx = h
},
pagerState = pagerState,
modifier = pagerModifier
)
}
}
if (cardHeightPx > 0) {
BottomCard(
viewModel = viewModel,
modifier = Modifier
.fillMaxWidth()
.height(with(density) { cardHeightPx.toDp() })
.align(Alignment.BottomCenter)
)
}
}
}
private fun lerp(start: Float, end: Float, fraction: Float): Float = start + (end - start) * fraction
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
private fun calculateWeeksCountForPage(page: Int, today: LocalDate): Int {
val initialYear = today.year
val initialMonth = today.monthNumber
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)
}