Morph events between views instead of fading (#184)

This commit is contained in:
2026-09-27 11:21:32 +02:00
parent 33c61202d9
commit f7805cb0b5
10 changed files with 301 additions and 45 deletions
@@ -3,10 +3,10 @@ package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@@ -27,12 +27,18 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
@@ -72,6 +78,7 @@ import kotlin.time.Clock
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.)
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CalendarHost(
modifier: Modifier = Modifier,
@@ -341,6 +348,9 @@ fun CalendarHost(
}
val slideSpec = rememberCalendarSlideSpec()
// Overlays leave from the predictive-back preview (#184): shrinking on from
// where the gesture let go instead of snapping back and sliding off.
val overlayExit = predictiveBackExit()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
@@ -355,19 +365,31 @@ fun CalendarHost(
Box(modifier = modifier.fillMaxSize()) {
// Switching between the peer views (month/week/day/agenda) is lateral
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
// view keeps the directional slide. What both views show morphs across
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
val viewSwitch = fadeThrough()
val reduceMotion = rememberReduceMotion()
val morphBounds = rememberViewMorphBoundsTransform()
val morphFade = rememberCalendarFadeSpec()
CompositionLocalProvider(
LocalEventMove provides moveScope,
// The failure state's way out when every calendar is switched off
// (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides { showCalendars = true },
) {
SharedTransitionLayout(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },
label = "view-switch",
) { currentView ->
CompositionLocalProvider(
LocalViewMorph provides if (reduceMotion) {
null
} else {
ViewMorphScope(this@SharedTransitionLayout, this@AnimatedContent, morphBounds, morphFade)
},
) {
when (currentView) {
CalendarView.Week -> WeekScreen(
selectedView = currentView,
@@ -430,6 +452,8 @@ fun CalendarHost(
todayInToolbar = todayInToolbar,
)
}
}
}
}
}
@@ -442,7 +466,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
SearchScreen(
onBack = { showSearch = false },
@@ -455,7 +479,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
activeKey?.let { key ->
EventDetailScreen(
@@ -483,7 +507,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = createDateIso != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
(createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen(
@@ -500,7 +524,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = editKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
(editKey ?: heldEditKey)?.let { key ->
EventEditScreen(
@@ -520,7 +544,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
SettingsScreen(
onBack = { showSettings = false },
@@ -577,7 +601,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
CalendarsScreen(
onBack = { showCalendars = false },
@@ -589,7 +613,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
// Settings can open this screen without the manager underneath, so
// its failure states' "Manage calendars" way out has to open the
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -420,7 +422,7 @@ private fun AgendaList(
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
@@ -0,0 +1,115 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
/**
* What the views share when switching between them (#184): an event visible in
* both grows from where it sat into where it lands, a date label travels to its
* new seat, and everything else fades through.
*
* An element is tagged with the day it is drawn *from*, not just its instance: a
* multi-day event is drawn once per day, per week row or per timeline column,
* and only one element per key may take part. The segment that starts on the
* same day in both views is the one that travels; the rest fade where they are.
*/
sealed interface ViewMorphKey {
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
data class Day(val date: LocalDate) : ViewMorphKey
companion object {
/** [event]'s occurrence as drawn from [day]. */
fun event(event: EventInstance, day: LocalDate): Event =
Event(event.eventId, event.start.toEpochMilliseconds(), day)
}
}
/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */
@OptIn(ExperimentalSharedTransitionApi::class)
class ViewMorphScope(
val shared: SharedTransitionScope,
val visibility: AnimatedVisibilityScope,
val boundsTransform: BoundsTransform,
val fadeSpec: FiniteAnimationSpec<Float>,
)
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
/**
* Whether a pager page may carry the morph tags: only the settled one. Its
* neighbours are composed ahead and repeat some of its dates, which would both
* claim its keys and fly in from off screen.
*/
fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage
/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */
@Composable
fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) {
val morph = LocalViewMorph.current
CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content)
}
/**
* Tags an event chip, bar, block or row. Its bounds are shared and the two faces
* cross-fade inside them; remeasured rather than scaled, so a title keeps its
* real size and is clipped while there is no room, instead of smearing.
*
* Goes after the element's offset and before its size, so the travelling bounds
* drive the measurement.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
val morph = LocalViewMorph.current ?: return this
return with(morph.shared) {
this@morphEvent.sharedBounds(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
zIndexInOverlay = 1f,
)
}
}
/** Tags a day's number, which is small on both sides, so it scales on its way. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphDay(date: LocalDate): Modifier {
val morph = LocalViewMorph.current ?: return this
return with(morph.shared) {
this@morphDay.sharedBounds(
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date)),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
)
}
}
/** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberViewMorphBoundsTransform(): BoundsTransform {
val spec = MaterialTheme.motionScheme.defaultSpatialSpec<Rect>()
return remember(spec) { BoundsTransform { _, _ -> spec } }
}
@@ -12,6 +12,10 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateMapOf
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
@@ -403,16 +407,18 @@ private fun DayPager(
if (stripHeight != null) stripHeights[page] = stripHeight
onDispose { stripHeights.remove(page) }
}
DayPage(
state = loaded ?: empty,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = page == pagerState.currentPage,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
DayPage(
state = loaded ?: empty,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = page == pagerState.currentPage,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
}
}
@@ -622,6 +628,7 @@ private fun AllDayStrip(
onClick = { onEventClick(span.event) },
modifier = Modifier
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
.morphEvent(ViewMorphKey.event(span.event, state.date))
.width(barWidth)
.height(ALL_DAY_ROW_HEIGHT)
.padding(horizontal = 1.dp, vertical = 1.dp),
@@ -844,6 +851,7 @@ private fun DayColumnCard(
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y + topInset)
.morphEvent(ViewMorphKey.event(block.event, date))
.width(place.width)
.height(blockHeight)
.padding(horizontal = BLOCK_OUTER_INSET),
@@ -10,6 +10,11 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
@@ -669,7 +674,7 @@ private fun MonthPager(
) {
if (static) {
Box(modifier) {
MonthPage(pagerState.currentPage, true, weekStart, monthState, loading, page)
MonthPage(pagerState.currentPage, true, true, weekStart, monthState, loading, page)
}
return
}
@@ -688,7 +693,15 @@ private fun MonthPager(
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { index ->
MonthPage(index, index == pagerState.currentPage, weekStart, monthState, loading, page)
MonthPage(
index = index,
active = index == pagerState.currentPage,
morphActive = morphPageActive(index, pagerState.settledPage),
weekStart = weekStart,
monthState = monthState,
loading = loading,
page = page,
)
}
}
@@ -696,6 +709,7 @@ private fun MonthPager(
private fun MonthPage(
index: Int,
active: Boolean,
morphActive: Boolean,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
loading: @Composable () -> Unit,
@@ -705,9 +719,11 @@ private fun MonthPage(
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
val chipDrag = LocalMonthDrag.current
val morph = LocalMonthMorph.current
val viewMorph = LocalViewMorph.current
CompositionLocalProvider(
LocalMonthDrag provides chipDrag.takeIf { active },
LocalMonthMorph provides morph.takeIf { active },
LocalViewMorph provides viewMorph.takeIf { morphActive },
) {
when (val s = state) {
is MonthUiState.Success -> page(index, s)
@@ -1334,6 +1350,10 @@ private fun SplitMonthContent(
// its month still for the length of the cross-fade.
val leaving = transition.targetState == EnterExitState.PostExit
CompositionLocalProvider(
// The split style morphs within itself and so sits the view
// switch's morph out (#184): one element can't travel in two
// shared transitions at once.
LocalViewMorph provides null,
// Null under reduced motion: nothing is tagged, nothing
// travels, and the cross-fade above is the whole transition.
LocalMonthMorph provides if (reduceMotion) {
@@ -2254,6 +2274,17 @@ private fun MonthWeekRow(
instanceId = span.event.instanceId,
),
)
// A blanked day is drawn again in its own
// month's block, which owns the tag.
.then(
week.days[span.startCol].let { start ->
if (blankOutside && !inMonth(start)) {
Modifier
} else {
Modifier.morphEvent(ViewMorphKey.event(span.event, start))
}
},
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
@@ -2311,6 +2342,13 @@ private fun MonthWeekRow(
y = EVENT_ROW_HEIGHT * freeSlots[i],
)
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
.then(
if (blankOutside && !inMonth(d)) {
Modifier
} else {
Modifier.morphEvent(ViewMorphKey.event(ev, d))
},
)
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
@@ -2576,6 +2614,7 @@ private fun DayNumberCell(
if (isToday) {
Box(
modifier = Modifier
.morphDay(date)
.size(DAY_NUMBER_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape),
contentAlignment = Alignment.Center,
@@ -2596,6 +2635,7 @@ private fun DayNumberCell(
maxLines = 1,
overflow = TextOverflow.Visible,
softWrap = false,
modifier = Modifier.morphDay(date),
)
} else {
Text(
@@ -2603,6 +2643,7 @@ private fun DayNumberCell(
style = MaterialTheme.typography.labelMedium,
color = if (inMonth) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.morphDay(date),
)
}
}
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -66,6 +64,7 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.locale.AppLanguage
/** The settings sub-screens reached from the hub's category rows. */
@@ -90,6 +89,8 @@ fun SettingsScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
val slideSpec = rememberCalendarSlideSpec()
// Leaves from the predictive-back preview rather than sliding off at full size.
val overlayExit = predictiveBackExit()
Box(
modifier = modifier
@@ -106,28 +107,28 @@ fun SettingsScreen(
AnimatedVisibility(
visible = section == SettingsSection.Appearance,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility(
visible = section == SettingsSection.Views,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility(
visible = section == SettingsSection.EventForm,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility(
visible = section == SettingsSection.Notifications,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
NotificationsScreen(
state = state,
@@ -139,21 +140,21 @@ fun SettingsScreen(
AnimatedVisibility(
visible = section == SettingsSection.SpecialDates,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility(
visible = section == SettingsSection.Widgets,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility(
visible = section == SettingsSection.Licences,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
LicencesScreen(onBack = { section = null })
}
@@ -135,6 +135,11 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage
import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -525,17 +530,19 @@ private fun WeekPager(
if (stripHeight != null) stripHeights[page] = stripHeight
onDispose { stripHeights.remove(page) }
}
WeekPage(
state = loaded ?: empty,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = page == pagerState.currentPage,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
WeekPage(
state = loaded ?: empty,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = page == pagerState.currentPage,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
}
}
@@ -776,7 +783,7 @@ private fun WeekDayHeader(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.fillMaxSize(),
modifier = Modifier.morphDay(date).fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
Text(
@@ -790,6 +797,7 @@ private fun WeekDayHeader(
Text(
text = date.day.toString(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.morphDay(date),
)
}
}
@@ -886,6 +894,7 @@ private fun AllDayStrip(
x = colWidth * span.startCol,
y = ALL_DAY_ROW_HEIGHT * span.lane,
)
.morphEvent(ViewMorphKey.event(span.event, state.days[span.startCol]))
.width(colWidth * spanCols)
.height(ALL_DAY_ROW_HEIGHT)
.padding(horizontal = 1.dp, vertical = 1.dp),
@@ -1120,6 +1129,7 @@ private fun DayColumnCard(
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y + topInset)
.morphEvent(ViewMorphKey.event(block.event, date))
.width(place.width)
.height(blockHeight)
.padding(horizontal = BLOCK_OUTER_INSET),
@@ -0,0 +1,50 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ViewMorphKeyTest {
private fun instance(instanceId: Long, eventId: Long = 7, startMillis: Long = 1_000L) = EventInstance(
instanceId = instanceId,
eventId = eventId,
calendarId = 1,
title = "Standup",
start = Instant.fromEpochMilliseconds(startMillis),
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
isAllDay = false,
color = 0,
location = null,
)
private val day = LocalDate(2026, 9, 25)
@Test
fun `the same occurrence on the same day matches across views`() {
// Each view reads its own instances query, so the row id is no anchor.
assertThat(ViewMorphKey.event(instance(instanceId = 1), day))
.isEqualTo(ViewMorphKey.event(instance(instanceId = 99), day))
}
@Test
fun `a multi-day event keys each day it is drawn from apart`() {
assertThat(ViewMorphKey.event(instance(1), day))
.isNotEqualTo(ViewMorphKey.event(instance(1), LocalDate(2026, 9, 26)))
}
@Test
fun `two occurrences of one series stay apart`() {
assertThat(ViewMorphKey.event(instance(1, startMillis = 1_000L), day))
.isNotEqualTo(ViewMorphKey.event(instance(2, startMillis = 2_000L), day))
}
@Test
fun `only the settled page morphs`() {
assertThat(morphPageActive(page = 5, settledPage = 5)).isTrue()
assertThat(morphPageActive(page = 4, settledPage = 5)).isFalse()
assertThat(morphPageActive(page = 6, settledPage = 5)).isFalse()
}
}