@@ -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