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.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
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.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -266,8 +265,6 @@ private fun DayContent(
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
@@ -296,20 +293,7 @@ private fun DayContent(
// Whole-page horizontal swipe, one level above the timeline's 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.
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
AnimatedContent(
targetState = state,

View File

@@ -75,8 +75,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
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.Size
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.pointer.pointerInput
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.TodayAction
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.IMPLEMENTED_VIEWS
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 CELL_TOP_PADDING = 6.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
/**
@@ -793,20 +804,21 @@ private enum class DragAxis { Undecided, Horizontal, Vertical }
/**
* 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
* release — the grid doesn't follow the finger, so there is no distance to
* rubber-band against.
* collapses the split style (#53). The grid doesn't follow the finger, so there is
* no distance to rubber-band against — it commits the moment the accumulated drag
* 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
* 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.
* This is the month's own detector rather than that shared one because it needs
* two axes, and the axis is **locked on the first movement and held for the whole
* 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,
* 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. The vertical one is
* larger — swapping the whole layout out deserves a more deliberate pull than
* stepping to the next month.
* The horizontal threshold is the shared one. The vertical 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
* axis unclaimed: the lock still happens, so a vertical drag there does nothing
@@ -820,7 +832,7 @@ private fun rememberMonthSwipeModifier(
onCollapse: (() -> Unit)? = null,
): Modifier {
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() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) {
var accum = Offset.Zero
@@ -833,13 +845,6 @@ private fun rememberMonthSwipeModifier(
fired = false
},
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
axis = DragAxis.Undecided
fired = false
@@ -860,22 +865,23 @@ 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 {
// Both axes commit the instant the drag clears their threshold,
// rather than on release — see [rememberCalendarPageSwipe], which
// does the same for the week and day views.
if (!fired) {
val commit = when (axis) {
DragAxis.Horizontal -> when {
accum.x < -pageThreshold -> onSwipeNext
accum.x > pageThreshold -> onSwipePrev
else -> null
}
DragAxis.Vertical -> when {
accum.y > expandThreshold -> onExpand
accum.y < -expandThreshold -> onCollapse
else -> null
}
DragAxis.Undecided -> null
}
if (commit != null) {
fired = true
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. */
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
// mark crossing the grid rather than as an outline blinking out here and
// 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 fadeSpec = rememberCalendarFadeSpec()
val selection by animateFloatAsState(
val selection = animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = if (reduceMotion) snap() else fadeSpec,
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
// 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
@@ -1331,11 +1343,22 @@ private fun SplitDayCell(
Box(
Modifier
.fillMaxSize()
.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
shape = CELL_SHAPE,
),
.drawBehind {
val alpha = selection.value
if (alpha <= 0f) return@drawBehind
// 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(
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.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
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.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -296,8 +295,6 @@ private fun WeekContent(
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
@@ -329,20 +326,7 @@ private fun WeekContent(
// 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
// gestures coexist without fighting.
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
AnimatedContent(
targetState = state,