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) var selectedDate by mutableStateOf(today)
private set private set
var isCollapsed by mutableStateOf(false)
private set
var collapseProgress by mutableStateOf(0f)
private set
val currentYear: Int get() = selectedDate.year val currentYear: Int get() = selectedDate.year
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val currentMonth: Int get() = selectedDate.monthNumber val currentMonth: Int get() = selectedDate.monthNumber
@ -33,6 +39,16 @@ class CalendarViewModel {
selectedDate = date selectedDate = date
} }
fun collapse() {
isCollapsed = true
collapseProgress = 1f
}
fun expand() {
isCollapsed = false
collapseProgress = 0f
}
fun getIsoWeekNumber(date: LocalDate): Int { fun getIsoWeekNumber(date: LocalDate): Int {
val jan4 = LocalDate(date.year, 1, 4) val jan4 = LocalDate(date.year, 1, 4)
val jan4DayOfWeek = jan4.dayOfWeek.ordinal 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 package plus.rua.project.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.datetime.DatePeriod import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@ -20,28 +23,57 @@ fun CalendarMonthPage(
selectedDate: LocalDate, selectedDate: LocalDate,
today: LocalDate, today: LocalDate,
onDateClick: (LocalDate) -> Unit, onDateClick: (LocalDate) -> Unit,
collapseProgress: Float,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val days = remember(year, month) { val days = remember(year, month) {
generateMonthDays(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) { Column(modifier = modifier) {
days.chunked(7).forEach { week -> weeks.forEachIndexed { weekIndex, week ->
Row( val animatedProgress = animateFloatAsState(
modifier = Modifier targetValue = collapseProgress,
.fillMaxWidth() label = "collapse-$weekIndex"
.padding(vertical = 2.dp) ).value
) {
week.forEach { dayData -> val isAboveSelected = weekIndex < selectedWeekIndex
DayCell( val isBelowSelected = weekIndex > selectedWeekIndex
date = dayData.date,
isCurrentMonth = dayData.isCurrentMonth, val offsetY = when {
isSelected = dayData.date == selectedDate, isAboveSelected -> -animatedProgress * 200f
isToday = dayData.date == today, isBelowSelected -> animatedProgress * 200f
onClick = { onDateClick(dayData.date) }, else -> 0f
modifier = Modifier.weight(1f) }
)
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 isCurrentMonth = date.monthNumber == month && date.year == year
) )
} }
} }

View File

@ -27,21 +27,40 @@ fun CalendarMonthView(
var currentYear by remember { mutableIntStateOf(viewModel.currentYear) } var currentYear by remember { mutableIntStateOf(viewModel.currentYear) }
var currentMonth by remember { mutableIntStateOf(viewModel.currentMonth) } var currentMonth by remember { mutableIntStateOf(viewModel.currentMonth) }
Column(modifier = modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 16.dp)) { Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
MonthHeader( Column(modifier = Modifier.padding(horizontal = 16.dp)) {
year = currentYear, MonthHeader(
month = currentMonth, year = currentYear,
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate) month = currentMonth,
) weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate)
WeekdayHeader(modifier = Modifier.fillMaxWidth()) )
CalendarPager( WeekdayHeader(modifier = Modifier.fillMaxWidth())
selectedDate = viewModel.selectedDate, if (viewModel.isCollapsed) {
today = today, WeekPager(
onDateClick = { date -> viewModel.selectDate(date) }, selectedDate = viewModel.selectedDate,
onMonthChanged = { year, month -> today = today,
currentYear = year onDateClick = { date -> viewModel.selectDate(date) },
currentMonth = month 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) modifier = Modifier.weight(1f)
) )
} }

View File

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