fix(month): drop the expanded grid's outline and make the pull fire sooner (#53)

Three things from the first on-device look.

The selection outline is gone from the expanded grid. Once the cells carry
real event bars it was one mark too many, and it had nothing to say —
every day there is one tap from being selected, and tapping is what closes
the view. MonthGrid/MonthWeekRow lose the parameter again.

Expand and collapse now fire the instant the drag clears the threshold
instead of on release. Waiting for the lift left the screen inert under a
finger that had already asked for the thing, which read as the gesture
being slow to take rather than as a deliberate commit. Paging still waits,
because there the gesture is genuinely undecided until you let go — you can
drag back and settle on a different month. Between expanded and collapsed
there is nothing to change your mind about.

The morph itself runs on the motion scheme's *fast* specs rather than the
page slide's default ones. Paging is a spring under one whole page and
wants room to settle; this answers a drag already committed to, and with a
hundred-odd pieces moving at once a long settle reads as lag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:48:03 +02:00
parent 0df420e551
commit f70b33b864
2 changed files with 63 additions and 39 deletions

View File

@@ -1,13 +1,18 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
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.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import kotlinx.datetime.LocalDate
/**
@@ -56,6 +61,22 @@ internal class MonthMorphScope(
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
/**
* How fast a tagged piece travels between its two homes.
*
* The *fast* spatial spec, not the default one the month/week/day page slide
* uses. Paging is a spring under one whole page and wants room to settle; this is
* a direct answer to a drag the user has already committed to, and at the default
* pace the grid felt like it was catching up rather than responding. It is also
* a hundred-odd pieces moving at once, where a long settle reads as lag.
*/
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun rememberMorphBoundsTransform(): BoundsTransform {
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Rect>()
return remember(spec) { BoundsTransform { _, _ -> spec } }
}
/**
* Tag content that is *the same thing* on both sides — a background pill, a day
* number — so it animates between its two positions and sizes.
@@ -68,6 +89,7 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
this@morphElement.sharedElement(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
boundsTransform = rememberMorphBoundsTransform(),
)
}
}
@@ -92,6 +114,7 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
animatedVisibilityScope = morph.visibility,
enter = fadeIn(),
exit = fadeOut(),
boundsTransform = rememberMorphBoundsTransform(),
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
)
}

View File

@@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -580,12 +581,6 @@ internal fun MonthGrid(
state: MonthUiState.Success,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
/**
* Outlined when set, matching the compact grid's marker. Only the split
* style's expanded form passes one — the paged style has no selection, and
* marking a day there would invent a state it doesn't have (#53).
*/
selected: LocalDate? = null,
) {
Column(
modifier = Modifier
@@ -604,7 +599,6 @@ internal fun MonthGrid(
inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
selected = selected,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
@@ -832,29 +826,29 @@ private fun rememberMonthSwipeModifier(
return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) {
var accum = Offset.Zero
var axis = DragAxis.Undecided
var fired = false
detectDragGestures(
onDragStart = {
accum = Offset.Zero
axis = DragAxis.Undecided
fired = false
},
onDragEnd = {
when (axis) {
DragAxis.Horizontal -> when {
// Only paging waits for the lift; see onDrag.
if (!fired && axis == DragAxis.Horizontal) {
when {
accum.x < -pageThreshold -> onSwipeNext()
accum.x > pageThreshold -> onSwipePrev()
}
DragAxis.Vertical -> when {
accum.y > expandThreshold -> onExpand?.invoke()
accum.y < -expandThreshold -> onCollapse?.invoke()
}
DragAxis.Undecided -> Unit
}
accum = Offset.Zero
axis = DragAxis.Undecided
fired = false
},
onDragCancel = {
accum = Offset.Zero
axis = DragAxis.Undecided
fired = false
},
onDrag = { _, drag ->
accum += drag
@@ -867,6 +861,27 @@ private fun rememberMonthSwipeModifier(
DragAxis.Vertical
}
}
// Expand and collapse fire the instant the drag clears the
// threshold, rather than on release. Waiting for the lift left the
// screen inert under a finger that had already asked for the thing,
// and the answer only arrived once you let go — which read as the
// gesture being slow to take rather than as a deliberate commit.
//
// Paging still waits, because there the pending gesture is
// genuinely undecided: you can drag back the other way and settle
// on a different month. There is nothing between expanded and
// collapsed to change your mind about.
if (!fired && axis == DragAxis.Vertical) {
val commit = when {
accum.y > expandThreshold -> onExpand
accum.y < -expandThreshold -> onCollapse
else -> null
}
if (commit != null) {
fired = true
commit()
}
}
},
)
}
@@ -894,7 +909,7 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
* screen to explain why. [rememberSaveable] carries it across a rotation, which
* is as long as it should live.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun SplitMonthContent(
state: MonthUiState,
@@ -909,7 +924,9 @@ private fun SplitMonthContent(
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
) {
val fadeSpec = rememberCalendarFadeSpec()
// The fast effects spec, matching the morph's fast spatial one — the page
// slide's default pace made the swap trail behind a gesture already committed.
val fadeSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
val reduceMotion = rememberReduceMotion()
var expanded by rememberSaveable { mutableStateOf(false) }
// Back collapses before it does anything else. Expanding replaces the whole
@@ -986,7 +1003,6 @@ private fun SplitMonthBody(
if (expanded) {
SplitMonthExpanded(
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
@@ -1081,11 +1097,14 @@ private fun SplitMonthCollapsed(
* The split style pulled open: the pane is gone and the month gets the whole
* screen in the paged style's own vocabulary — real event bars and pills instead
* of dots. The handle stays, now at the foot of the grid, to pull it back.
*
* No selection marker here, deliberately. Once the cells carry real event bars
* the outline is one mark too many, and it has nothing to say: every day is a tap
* away from being the selected one, and tapping is what closes this view.
*/
@Composable
private fun SplitMonthExpanded(
state: MonthUiState.Success,
selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean,
swipeModifier: Modifier,
@@ -1098,19 +1117,18 @@ private fun SplitMonthExpanded(
Column(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = state to selected,
targetState = state,
modifier = Modifier.weight(1f).then(swipeModifier),
contentKey = { (s, _) -> s.month },
contentKey = { it.month },
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
},
label = "split-expanded-month-transition",
) { (s, sel) ->
) { s ->
MonthGrid(
state = s,
showWeekNumbers = showWeekNumbers,
onOpenDay = onPickDay,
selected = sel,
)
}
SplitExpandHandle(expanded = true, onToggle = onCollapse)
@@ -1572,8 +1590,6 @@ private fun MonthWeekRow(
modifier: Modifier = Modifier,
blankOutside: Boolean = false,
labelMonthOnFirst: Boolean = false,
/** See [MonthGrid]'s `selected`; null for every style but expanded split. */
selected: LocalDate? = null,
) {
val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
@@ -1619,21 +1635,6 @@ private fun MonthWeekRow(
else -> MaterialTheme.colorScheme.surfaceContainerLow
},
shape = CELL_SHAPE,
)
// Scoped to the row's own month for the same reason the
// compact grid scopes it: a boundary week shows the
// neighbour month's dates too, and the marker belongs to
// exactly one of them.
.then(
if (d == selected && inMonth(d)) {
Modifier.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
shape = CELL_SHAPE,
)
} else {
Modifier
},
),
)
}