feat(calendar): commit page swipes on the threshold, not on release (#53)

The split style's pull-to-expand fires the moment the drag clears its
threshold, and that turned out to be the better feel — so paging now does
the same everywhere: month, week and day.

Waiting for the lift meant the page sat still under a finger that had
already travelled far enough to ask for it, and the answer only arrived
once you let go, which reads as the view being slow rather than as a
deliberate commit. The trade is that a drag can no longer be taken back by
dragging the other way; once you have moved 24dp deliberately you meant it,
and the page you left is one swipe back.

The three views had three copies of the same detector, so this extracts one
rememberCalendarPageSwipe and the threshold constant beside it. It stays
horizontal-only on purpose: the week and day timelines scroll vertically
underneath, and a two-dimensional detector would claim those drags before
the inner scroll saw them. The month keeps its own axis-locking detector
because the split style needs the vertical axis, and now shares the
threshold.

Also fixes the selection outline shivering rather than fading. Its animated
alpha was read while composing the cell, so every frame of the fade
recomposed all 42 cells at once — the fade was driving the whole grid
through recomposition to change one colour. It is now held as a State and
read inside a drawBehind, which keeps it in the draw phase where it touches
nothing but pixels. That means drawing the rounded rect by hand instead of
Modifier.border, so the corner radius is named alongside CELL_SHAPE to stop
the two drifting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:24:52 +02:00
parent 63ba69729e
commit beb8536e8a
4 changed files with 151 additions and 81 deletions

View File

@@ -0,0 +1,79 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* Drag distance that turns a calendar page, shared by the month, week and day
* views so all three answer a swipe at the same point.
*
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
* changed the month, and a page that turns on an unintended gesture reads as the
* animation misfiring rather than as the gesture being over-eager.
*/
val CALENDAR_SWIPE_THRESHOLD = 24.dp
/**
* The whole-page horizontal swipe: one page per gesture, committed **the moment
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
*
* Waiting for the lift meant the page sat still under a finger that had already
* travelled far enough to ask for it, and the answer only arrived once you let
* go — which reads as the view being slow rather than as a deliberate commit.
* Firing on the threshold is what makes the gesture feel like it is being
* followed. The trade is that a drag can no longer be taken back by dragging the
* other way; in practice, once you have moved 24dp deliberately you meant it, and
* the page you land on is one swipe back.
*
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
* underneath this, and a two-dimensional detector here would claim those drags
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
* detector's slop while a vertical one is consumed below, and the two coexist.
* (The month view's split style needs a vertical axis as well, so it keeps its
* own axis-locking detector rather than using this.)
*/
@Composable
fun rememberCalendarPageSwipe(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
): Modifier {
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
var accum = 0f
// One page per gesture: without this a long drag would keep re-firing
// every time the accumulator crossed the threshold again.
var fired = false
detectHorizontalDragGestures(
onDragStart = {
accum = 0f
fired = false
},
onDragEnd = {
accum = 0f
fired = false
},
onDragCancel = {
accum = 0f
fired = false
},
onHorizontalDrag = { _, drag ->
accum += drag
if (!fired) {
val commit = when {
accum < -threshold -> onSwipeNext
accum > threshold -> onSwipePrev
else -> null
}
if (commit != null) {
fired = true
commit()
}
}
},
)
}
}

View File

@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -79,6 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -266,8 +265,6 @@ private fun DayContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
@@ -296,20 +293,7 @@ private fun DayContent(
// Whole-page horizontal swipe, one level above the timeline's vertical // Whole-page horizontal swipe, one level above the timeline's vertical
// scroll: a horizontal drag crosses this detector's slop, while a vertical // scroll: a horizontal drag crosses this detector's slop, while a vertical
// drag is consumed by the inner scroll first — the two gestures coexist. // drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = Modifier.pointerInput(Unit) { val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
AnimatedContent( AnimatedContent(
targetState = state, targetState = state,

View File

@@ -75,8 +75,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -100,6 +104,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
@@ -556,7 +561,13 @@ private val WEEK_NUMBER_GUTTER = 40.dp
private val DAY_NUMBER_GAP = 4.dp private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp private val CELL_TOP_PADDING = 6.dp
private val CELL_GAP = 2.dp private val CELL_GAP = 2.dp
private val CELL_SHAPE = RoundedCornerShape(12.dp) /** Named separately because the split style's selection outline draws its own
* rounded rect and has to match this radius exactly. */
private val CELL_CORNER = 12.dp
private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
/** Width of the split style's selected-day outline. */
private val SPLIT_SELECTION_STROKE = 1.5.dp
private const val MAX_EVENT_ROWS = 3 private const val MAX_EVENT_ROWS = 3
/** /**
@@ -793,20 +804,21 @@ private enum class DragAxis { Undecided, Horizontal, Vertical }
/** /**
* The month grid's drag gesture: horizontal pages the month, vertical expands or * The month grid's drag gesture: horizontal pages the month, vertical expands or
* collapses the split style (#53). Accumulates and commits past a threshold on * collapses the split style (#53). The grid doesn't follow the finger, so there is
* release — the grid doesn't follow the finger, so there is no distance to * no distance to rubber-band against — it commits the moment the accumulated drag
* rubber-band against. * clears the threshold, once per gesture, exactly as
* [rememberCalendarPageSwipe][de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe]
* does for the week and day views.
* *
* The axis is **locked on the first movement and held for the whole gesture**, so * This is the month's own detector rather than that shared one because it needs
* a drag can page or expand but never both. Two independent detectors on one * two axes, and the axis is **locked on the first movement and held for the whole
* surface would each see their own component of a diagonal drag and both fire. * gesture** so a drag can page or expand but never both. Two independent
* detectors on one surface would each see their own component of a diagonal drag
* and both fire.
* *
* The horizontal threshold matches the week and day views'. It used to be 6dp, * The horizontal threshold is the shared one. The vertical is larger — swapping
* which is inside the distance a tap wanders: brushing the grid changed the * the whole layout out deserves a more deliberate pull than stepping to the next
* month, and a page that turns on an unintended gesture reads as the animation * month.
* misfiring rather than as the gesture being over-eager. The vertical one is
* larger — swapping the whole layout out deserves a more deliberate pull than
* stepping to the next month.
* *
* [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical * [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical
* axis unclaimed: the lock still happens, so a vertical drag there does nothing * axis unclaimed: the lock still happens, so a vertical drag there does nothing
@@ -820,7 +832,7 @@ private fun rememberMonthSwipeModifier(
onCollapse: (() -> Unit)? = null, onCollapse: (() -> Unit)? = null,
): Modifier { ): Modifier {
val density = LocalDensity.current val density = LocalDensity.current
val pageThreshold = with(density) { MONTH_SWIPE_THRESHOLD.toPx() } val pageThreshold = with(density) { CALENDAR_SWIPE_THRESHOLD.toPx() }
val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() } val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) { return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) {
var accum = Offset.Zero var accum = Offset.Zero
@@ -833,13 +845,6 @@ private fun rememberMonthSwipeModifier(
fired = false fired = false
}, },
onDragEnd = { onDragEnd = {
// Only paging waits for the lift; see onDrag.
if (!fired && axis == DragAxis.Horizontal) {
when {
accum.x < -pageThreshold -> onSwipeNext()
accum.x > pageThreshold -> onSwipePrev()
}
}
accum = Offset.Zero accum = Offset.Zero
axis = DragAxis.Undecided axis = DragAxis.Undecided
fired = false fired = false
@@ -860,22 +865,23 @@ private fun rememberMonthSwipeModifier(
DragAxis.Vertical DragAxis.Vertical
} }
} }
// Expand and collapse fire the instant the drag clears the // Both axes commit the instant the drag clears their threshold,
// threshold, rather than on release. Waiting for the lift left the // rather than on release — see [rememberCalendarPageSwipe], which
// screen inert under a finger that had already asked for the thing, // does the same for the week and day views.
// and the answer only arrived once you let go — which read as the if (!fired) {
// gesture being slow to take rather than as a deliberate commit. val commit = when (axis) {
// DragAxis.Horizontal -> when {
// Paging still waits, because there the pending gesture is accum.x < -pageThreshold -> onSwipeNext
// genuinely undecided: you can drag back the other way and settle accum.x > pageThreshold -> onSwipePrev
// on a different month. There is nothing between expanded and else -> null
// collapsed to change your mind about. }
if (!fired && axis == DragAxis.Vertical) { DragAxis.Vertical -> when {
val commit = when {
accum.y > expandThreshold -> onExpand accum.y > expandThreshold -> onExpand
accum.y < -expandThreshold -> onCollapse accum.y < -expandThreshold -> onCollapse
else -> null else -> null
} }
DragAxis.Undecided -> null
}
if (commit != null) { if (commit != null) {
fired = true fired = true
commit() commit()
@@ -886,9 +892,6 @@ private fun rememberMonthSwipeModifier(
} }
} }
/** Drag distance that commits a month change, matching the week and day views. */
private val MONTH_SWIPE_THRESHOLD = 24.dp
/** Drag distance that commits an expand/collapse — deliberately longer than a page. */ /** Drag distance that commits an expand/collapse — deliberately longer than a page. */
private val MONTH_EXPAND_THRESHOLD = 48.dp private val MONTH_EXPAND_THRESHOLD = 48.dp
@@ -1298,13 +1301,22 @@ private fun SplitDayCell(
// Each cell fades its own outline, so moving the selection reads as one // Each cell fades its own outline, so moving the selection reads as one
// mark crossing the grid rather than as an outline blinking out here and // mark crossing the grid rather than as an outline blinking out here and
// reappearing there. Snapped under reduced motion. // reappearing there. Snapped under reduced motion.
//
// Held as a State and read *inside the draw lambda below*, never unwrapped
// here. Read in composition it made every frame of the fade recompose all
// 42 cells at once, which is what made the outline shiver rather than fade.
// Kept in the draw phase, the animation touches nothing but the pixels.
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
val selection by animateFloatAsState( val selection = animateFloatAsState(
targetValue = if (isSelected) 1f else 0f, targetValue = if (isSelected) 1f else 0f,
animationSpec = if (reduceMotion) snap() else fadeSpec, animationSpec = if (reduceMotion) snap() else fadeSpec,
label = "split-day-selection", label = "split-day-selection",
) )
val outlineColor = MaterialTheme.colorScheme.primary
val density = LocalDensity.current
val outlineStroke = with(density) { SPLIT_SELECTION_STROKE.toPx() }
val outlineRadius = with(density) { CELL_CORNER.toPx() }
// Background and content are separate layers, mirroring how the paged row is // Background and content are separate layers, mirroring how the paged row is
// built — and so the pill can be tagged for the morph on its own. Tagged as // built — and so the pill can be tagged for the morph on its own. Tagged as
// one piece with its contents inside, the dots would be nested shared // one piece with its contents inside, the dots would be nested shared
@@ -1331,11 +1343,22 @@ private fun SplitDayCell(
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.border( .drawBehind {
width = 1.5.dp, val alpha = selection.value
color = MaterialTheme.colorScheme.primary.copy(alpha = selection), if (alpha <= 0f) return@drawBehind
shape = CELL_SHAPE, // Inset by half the stroke: a stroke straddles the path it
), // follows, so drawn on the bounds themselves the outer half
// would fall outside the cell and be clipped to a hairline
// along the edges.
val inset = outlineStroke / 2f
drawRoundRect(
color = outlineColor.copy(alpha = alpha),
topLeft = Offset(inset, inset),
size = Size(size.width - outlineStroke, size.height - outlineStroke),
cornerRadius = CornerRadius(outlineRadius - inset),
style = Stroke(width = outlineStroke),
)
},
) )
Column( Column(
modifier = Modifier.fillMaxSize().padding(top = 4.dp), modifier = Modifier.fillMaxSize().padding(top = 4.dp),

View File

@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -94,6 +92,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -296,8 +295,6 @@ private fun WeekContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
@@ -329,20 +326,7 @@ private fun WeekContent(
// vertical scroll: a horizontal drag only crosses *this* detector's slop, // vertical scroll: a horizontal drag only crosses *this* detector's slop,
// while a vertical drag is consumed by the inner scroll first — so the two // while a vertical drag is consumed by the inner scroll first — so the two
// gestures coexist without fighting. // gestures coexist without fighting.
val swipeModifier = Modifier.pointerInput(Unit) { val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
AnimatedContent( AnimatedContent(
targetState = state, targetState = state,