feat(month): month view style picker + Split pull-to-expand (#38, #53) #90

Merged
makiolaj merged 36 commits from feat/month-view-style into release/v2.16.0 2026-07-20 21:25:50 +00:00
4 changed files with 151 additions and 81 deletions
Showing only changes of commit beb8536e8a - Show all commits

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,21 +865,22 @@ 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
// 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
@@ -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,