fix(month): drop the expanded grid's outline and make the pull fire sooner (#53)
Three things from the first on-device look. The selection outline is gone from the expanded grid. Once the cells carry real event bars it was one mark too many, and it had nothing to say — every day there is one tap from being selected, and tapping is what closes the view. MonthGrid/MonthWeekRow lose the parameter again. Expand and collapse now fire the instant the drag clears the threshold instead of on release. Waiting for the lift left the screen inert under a finger that had already asked for the thing, which read as the gesture being slow to take rather than as a deliberate commit. Paging still waits, because there the gesture is genuinely undecided until you let go — you can drag back and settle on a different month. Between expanded and collapsed there is nothing to change your mind about. The morph itself runs on the motion scheme's *fast* specs rather than the page slide's default ones. Paging is a spring under one whole page and wants room to settle; this answers a drag already committed to, and with a hundred-odd pieces moving at once a long settle reads as lag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,18 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibilityScope
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.BoundsTransform
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +61,22 @@ internal class MonthMorphScope(
|
|||||||
|
|
||||||
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
|
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How fast a tagged piece travels between its two homes.
|
||||||
|
*
|
||||||
|
* The *fast* spatial spec, not the default one the month/week/day page slide
|
||||||
|
* uses. Paging is a spring under one whole page and wants room to settle; this is
|
||||||
|
* a direct answer to a drag the user has already committed to, and at the default
|
||||||
|
* pace the grid felt like it was catching up rather than responding. It is also
|
||||||
|
* a hundred-odd pieces moving at once, where a long settle reads as lag.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun rememberMorphBoundsTransform(): BoundsTransform {
|
||||||
|
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Rect>()
|
||||||
|
return remember(spec) { BoundsTransform { _, _ -> spec } }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tag content that is *the same thing* on both sides — a background pill, a day
|
* Tag content that is *the same thing* on both sides — a background pill, a day
|
||||||
* number — so it animates between its two positions and sizes.
|
* number — so it animates between its two positions and sizes.
|
||||||
@@ -68,6 +89,7 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
|||||||
this@morphElement.sharedElement(
|
this@morphElement.sharedElement(
|
||||||
sharedContentState = rememberSharedContentState(key),
|
sharedContentState = rememberSharedContentState(key),
|
||||||
animatedVisibilityScope = morph.visibility,
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
boundsTransform = rememberMorphBoundsTransform(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +114,7 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
|||||||
animatedVisibilityScope = morph.visibility,
|
animatedVisibilityScope = morph.visibility,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
|
boundsTransform = rememberMorphBoundsTransform(),
|
||||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Menu
|
|||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -580,12 +581,6 @@ internal fun MonthGrid(
|
|||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
/**
|
|
||||||
* Outlined when set, matching the compact grid's marker. Only the split
|
|
||||||
* style's expanded form passes one — the paged style has no selection, and
|
|
||||||
* marking a day there would invent a state it doesn't have (#53).
|
|
||||||
*/
|
|
||||||
selected: LocalDate? = null,
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -604,7 +599,6 @@ internal fun MonthGrid(
|
|||||||
inMonth = { it.month == month.month && it.year == month.year },
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
selected = selected,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
@@ -832,29 +826,29 @@ private fun rememberMonthSwipeModifier(
|
|||||||
return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) {
|
return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) {
|
||||||
var accum = Offset.Zero
|
var accum = Offset.Zero
|
||||||
var axis = DragAxis.Undecided
|
var axis = DragAxis.Undecided
|
||||||
|
var fired = false
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
onDragStart = {
|
onDragStart = {
|
||||||
accum = Offset.Zero
|
accum = Offset.Zero
|
||||||
axis = DragAxis.Undecided
|
axis = DragAxis.Undecided
|
||||||
|
fired = false
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
when (axis) {
|
// Only paging waits for the lift; see onDrag.
|
||||||
DragAxis.Horizontal -> when {
|
if (!fired && axis == DragAxis.Horizontal) {
|
||||||
|
when {
|
||||||
accum.x < -pageThreshold -> onSwipeNext()
|
accum.x < -pageThreshold -> onSwipeNext()
|
||||||
accum.x > pageThreshold -> onSwipePrev()
|
accum.x > pageThreshold -> onSwipePrev()
|
||||||
}
|
}
|
||||||
DragAxis.Vertical -> when {
|
|
||||||
accum.y > expandThreshold -> onExpand?.invoke()
|
|
||||||
accum.y < -expandThreshold -> onCollapse?.invoke()
|
|
||||||
}
|
|
||||||
DragAxis.Undecided -> Unit
|
|
||||||
}
|
}
|
||||||
accum = Offset.Zero
|
accum = Offset.Zero
|
||||||
axis = DragAxis.Undecided
|
axis = DragAxis.Undecided
|
||||||
|
fired = false
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
accum = Offset.Zero
|
accum = Offset.Zero
|
||||||
axis = DragAxis.Undecided
|
axis = DragAxis.Undecided
|
||||||
|
fired = false
|
||||||
},
|
},
|
||||||
onDrag = { _, drag ->
|
onDrag = { _, drag ->
|
||||||
accum += drag
|
accum += drag
|
||||||
@@ -867,6 +861,27 @@ private fun rememberMonthSwipeModifier(
|
|||||||
DragAxis.Vertical
|
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
|
||||||
|
}
|
||||||
|
if (commit != null) {
|
||||||
|
fired = true
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -894,7 +909,7 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
|
|||||||
* screen to explain why. [rememberSaveable] carries it across a rotation, which
|
* screen to explain why. [rememberSaveable] carries it across a rotation, which
|
||||||
* is as long as it should live.
|
* is as long as it should live.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitMonthContent(
|
private fun SplitMonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
@@ -909,7 +924,9 @@ private fun SplitMonthContent(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateEvent: (LocalDate) -> Unit,
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
// The fast effects spec, matching the morph's fast spatial one — the page
|
||||||
|
// slide's default pace made the swap trail behind a gesture already committed.
|
||||||
|
val fadeSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||||
// Back collapses before it does anything else. Expanding replaces the whole
|
// Back collapses before it does anything else. Expanding replaces the whole
|
||||||
@@ -986,7 +1003,6 @@ private fun SplitMonthBody(
|
|||||||
if (expanded) {
|
if (expanded) {
|
||||||
SplitMonthExpanded(
|
SplitMonthExpanded(
|
||||||
state = state,
|
state = state,
|
||||||
selected = selected,
|
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
swipeModifier = swipeModifier,
|
swipeModifier = swipeModifier,
|
||||||
@@ -1081,11 +1097,14 @@ private fun SplitMonthCollapsed(
|
|||||||
* The split style pulled open: the pane is gone and the month gets the whole
|
* The split style pulled open: the pane is gone and the month gets the whole
|
||||||
* screen in the paged style's own vocabulary — real event bars and pills instead
|
* screen in the paged style's own vocabulary — real event bars and pills instead
|
||||||
* of dots. The handle stays, now at the foot of the grid, to pull it back.
|
* of dots. The handle stays, now at the foot of the grid, to pull it back.
|
||||||
|
*
|
||||||
|
* No selection marker here, deliberately. Once the cells carry real event bars
|
||||||
|
* the outline is one mark too many, and it has nothing to say: every day is a tap
|
||||||
|
* away from being the selected one, and tapping is what closes this view.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitMonthExpanded(
|
private fun SplitMonthExpanded(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
selected: LocalDate,
|
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
swipeModifier: Modifier,
|
swipeModifier: Modifier,
|
||||||
@@ -1098,19 +1117,18 @@ private fun SplitMonthExpanded(
|
|||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state to selected,
|
targetState = state,
|
||||||
modifier = Modifier.weight(1f).then(swipeModifier),
|
modifier = Modifier.weight(1f).then(swipeModifier),
|
||||||
contentKey = { (s, _) -> s.month },
|
contentKey = { it.month },
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
|
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
|
||||||
},
|
},
|
||||||
label = "split-expanded-month-transition",
|
label = "split-expanded-month-transition",
|
||||||
) { (s, sel) ->
|
) { s ->
|
||||||
MonthGrid(
|
MonthGrid(
|
||||||
state = s,
|
state = s,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onPickDay,
|
onOpenDay = onPickDay,
|
||||||
selected = sel,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SplitExpandHandle(expanded = true, onToggle = onCollapse)
|
SplitExpandHandle(expanded = true, onToggle = onCollapse)
|
||||||
@@ -1572,8 +1590,6 @@ private fun MonthWeekRow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
blankOutside: Boolean = false,
|
blankOutside: Boolean = false,
|
||||||
labelMonthOnFirst: Boolean = false,
|
labelMonthOnFirst: Boolean = false,
|
||||||
/** See [MonthGrid]'s `selected`; null for every style but expanded split. */
|
|
||||||
selected: LocalDate? = null,
|
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||||
@@ -1619,21 +1635,6 @@ private fun MonthWeekRow(
|
|||||||
else -> MaterialTheme.colorScheme.surfaceContainerLow
|
else -> MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
},
|
},
|
||||||
shape = CELL_SHAPE,
|
shape = CELL_SHAPE,
|
||||||
)
|
|
||||||
// Scoped to the row's own month for the same reason the
|
|
||||||
// compact grid scopes it: a boundary week shows the
|
|
||||||
// neighbour month's dates too, and the marker belongs to
|
|
||||||
// exactly one of them.
|
|
||||||
.then(
|
|
||||||
if (d == selected && inMonth(d)) {
|
|
||||||
Modifier.border(
|
|
||||||
width = 1.5.dp,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
shape = CELL_SHAPE,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user