Add week/month view toggle with collapse animation and bottom card

Add isCollapsed/collapseProgress state to CalendarViewModel for toggling
between month and week views. CalendarMonthPage animates non-selected
weeks out with offset and alpha. Introduce WeekPager for single-week
HorizontalPager and BottomCard with drag-to-collapse gesture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-14 14:28:05 +08:00
parent 624fd8905c
commit e53c3d8705
6 changed files with 239 additions and 32 deletions

View File

@ -25,6 +25,12 @@ class CalendarViewModel {
var selectedDate by mutableStateOf(today)
private set
var isCollapsed by mutableStateOf(false)
private set
var collapseProgress by mutableStateOf(0f)
private set
val currentYear: Int get() = selectedDate.year
@Suppress("DEPRECATION")
val currentMonth: Int get() = selectedDate.monthNumber
@ -33,6 +39,16 @@ class CalendarViewModel {
selectedDate = date
}
fun collapse() {
isCollapsed = true
collapseProgress = 1f
}
fun expand() {
isCollapsed = false
collapseProgress = 0f
}
fun getIsoWeekNumber(date: LocalDate): Int {
val jan4 = LocalDate(date.year, 1, 4)
val jan4DayOfWeek = jan4.dayOfWeek.ordinal

View File

@ -0,0 +1,58 @@
package plus.rua.project.ui
import androidx.compose.animation.core.animate
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Box
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.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import plus.rua.project.CalendarViewModel
@Composable
fun BottomCard(
viewModel: CalendarViewModel,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
Surface(
modifier = modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectVerticalDragGestures { _, dragAmount ->
if (dragAmount < 0 && !viewModel.isCollapsed) {
viewModel.collapse()
}
}
},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 4.dp
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp, bottom = 8.dp)
.clip(RoundedCornerShape(2.dp))
.background(Color.Gray.copy(alpha = 0.4f))
.fillMaxWidth(0.15f)
.height(4.dp)
)
}
}
}

View File

@ -1,12 +1,15 @@
package plus.rua.project.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
@ -20,28 +23,57 @@ fun CalendarMonthPage(
selectedDate: LocalDate,
today: LocalDate,
onDateClick: (LocalDate) -> Unit,
collapseProgress: Float,
modifier: Modifier = Modifier
) {
val days = remember(year, month) {
generateMonthDays(year, month)
}
val weeks = days.chunked(7)
val selectedWeekIndex = remember(weeks, selectedDate) {
weeks.indexOfFirst { week -> week.any { it.date == selectedDate } }
}
Column(modifier = modifier) {
days.chunked(7).forEach { week ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
) {
week.forEach { dayData ->
DayCell(
date = dayData.date,
isCurrentMonth = dayData.isCurrentMonth,
isSelected = dayData.date == selectedDate,
isToday = dayData.date == today,
onClick = { onDateClick(dayData.date) },
modifier = Modifier.weight(1f)
)
weeks.forEachIndexed { weekIndex, week ->
val animatedProgress = animateFloatAsState(
targetValue = collapseProgress,
label = "collapse-$weekIndex"
).value
val isAboveSelected = weekIndex < selectedWeekIndex
val isBelowSelected = weekIndex > selectedWeekIndex
val offsetY = when {
isAboveSelected -> -animatedProgress * 200f
isBelowSelected -> animatedProgress * 200f
else -> 0f
}
val alpha = when {
isAboveSelected || isBelowSelected -> 1f - animatedProgress
else -> 1f
}
if (alpha > 0.01f) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.offset(y = offsetY.dp)
.alpha(alpha)
) {
week.forEach { dayData ->
DayCell(
date = dayData.date,
isCurrentMonth = dayData.isCurrentMonth,
isSelected = dayData.date == selectedDate,
isToday = dayData.date == today,
onClick = { onDateClick(dayData.date) },
modifier = Modifier.weight(1f)
)
}
}
}
}
@ -66,4 +98,4 @@ private fun generateMonthDays(year: Int, month: Int): List<DayData> {
isCurrentMonth = date.monthNumber == month && date.year == year
)
}
}
}

View File

@ -27,21 +27,40 @@ fun CalendarMonthView(
var currentYear by remember { mutableIntStateOf(viewModel.currentYear) }
var currentMonth by remember { mutableIntStateOf(viewModel.currentMonth) }
Column(modifier = modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 16.dp)) {
MonthHeader(
year = currentYear,
month = currentMonth,
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate)
)
WeekdayHeader(modifier = Modifier.fillMaxWidth())
CalendarPager(
selectedDate = viewModel.selectedDate,
today = today,
onDateClick = { date -> viewModel.selectDate(date) },
onMonthChanged = { year, month ->
currentYear = year
currentMonth = month
},
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
MonthHeader(
year = currentYear,
month = currentMonth,
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate)
)
WeekdayHeader(modifier = Modifier.fillMaxWidth())
if (viewModel.isCollapsed) {
WeekPager(
selectedDate = viewModel.selectedDate,
today = today,
onDateClick = { date -> viewModel.selectDate(date) },
onWeekChanged = { weekMonday ->
currentYear = weekMonday.year
@Suppress("DEPRECATION")
currentMonth = weekMonday.monthNumber
}
)
} else {
CalendarPager(
selectedDate = viewModel.selectedDate,
today = today,
onDateClick = { date -> viewModel.selectDate(date) },
onMonthChanged = { year, month ->
currentYear = year
currentMonth = month
},
collapseProgress = viewModel.collapseProgress
)
}
}
BottomCard(
viewModel = viewModel,
modifier = Modifier.weight(1f)
)
}

View File

@ -20,6 +20,7 @@ fun CalendarPager(
today: LocalDate,
onDateClick: (LocalDate) -> Unit,
onMonthChanged: (year: Int, month: Int) -> Unit,
collapseProgress: Float,
modifier: Modifier = Modifier
) {
val initialYearMonth = remember { today.toYearMonth() }
@ -61,7 +62,8 @@ fun CalendarPager(
}
}
}
}
},
collapseProgress = collapseProgress
)
}
}

View File

@ -0,0 +1,80 @@
package plus.rua.project.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.minus
import kotlinx.datetime.plus
private const val START_PAGE = Int.MAX_VALUE / 2
@Composable
fun WeekPager(
selectedDate: LocalDate,
today: LocalDate,
onDateClick: (LocalDate) -> Unit,
onWeekChanged: (LocalDate) -> Unit,
modifier: Modifier = Modifier
) {
val initialWeekMonday = remember { selectedDate.toWeekMonday() }
val pagerState = rememberPagerState(
initialPage = START_PAGE,
pageCount = { Int.MAX_VALUE }
)
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.collect { page ->
val weekMonday = pageToWeekMonday(page, initialWeekMonday)
onWeekChanged(weekMonday)
}
}
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 1,
flingBehavior = PagerDefaults.flingBehavior(state = pagerState),
modifier = modifier
) { page ->
val weekMonday = pageToWeekMonday(page, initialWeekMonday)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
) {
(0 until 7).forEach { dayOffset ->
val date = weekMonday.plus(DatePeriod(days = dayOffset))
DayCell(
date = date,
isCurrentMonth = true,
isSelected = date == selectedDate,
isToday = date == today,
onClick = { onDateClick(date) },
modifier = Modifier.weight(1f)
)
}
}
}
}
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))
}