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:
@@ -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()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user