@@ -0,0 +1,98 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What lets the split style's compact grid *become* the full month rather than be
|
||||||
|
* swapped for it (#53).
|
||||||
|
*
|
||||||
|
* The two grids are separate composables — the compact one draws dots, the
|
||||||
|
* expanded one draws the paged style's bars and pills — so nothing about them is
|
||||||
|
* shared by construction. Tagging the pieces that mean the same thing on both
|
||||||
|
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
|
||||||
|
* the other: a day's cell grows, its number rides along, and each dot travels out
|
||||||
|
* to the bar it stood for.
|
||||||
|
*
|
||||||
|
* It travels as a composition local rather than as parameters because the pieces
|
||||||
|
* sit five levels below where the scopes exist, and because a null default is
|
||||||
|
* exactly the right meaning for everyone else: the paged, continuous, and dense
|
||||||
|
* styles share the same row and cell composables, provide nothing, and pay
|
||||||
|
* nothing. Reduced motion provides nothing either, which leaves the plain
|
||||||
|
* cross-fade underneath.
|
||||||
|
*/
|
||||||
|
internal sealed interface MonthMorphKey {
|
||||||
|
/** A day's background pill — the structural anchor the rest rides on. */
|
||||||
|
data class Cell(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
data class DayNumber(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event *on one day*. Keyed by date as well as instance because a
|
||||||
|
* multi-day event has a dot on every day it covers but only one bar, drawn
|
||||||
|
* from where it starts: the start day's dot is the one that becomes the bar,
|
||||||
|
* and the rest are left unmatched on purpose. They fade where they stand
|
||||||
|
* while the bar sweeps out over them, which is the honest reading — one of
|
||||||
|
* them could not become the bar without the others teleporting into it.
|
||||||
|
*/
|
||||||
|
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
|
||||||
|
|
||||||
|
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
|
||||||
|
data object Handle : MonthMorphKey
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
internal class MonthMorphScope(
|
||||||
|
val shared: SharedTransitionScope,
|
||||||
|
val visibility: AnimatedVisibilityScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
||||||
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphElement.sharedElement(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag content that means the same thing but *is drawn differently* on each side —
|
||||||
|
* a 5dp dot and a titled bar — so only the bounds are shared and the contents
|
||||||
|
* cross-fade inside them.
|
||||||
|
*
|
||||||
|
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
|
||||||
|
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
|
||||||
|
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
|
||||||
|
* simply clips it while there is no room, so what grows is the pill, not the type.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||||
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphBounds.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
@@ -891,6 +893,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)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitMonthContent(
|
private fun SplitMonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
@@ -906,6 +909,7 @@ private fun SplitMonthContent(
|
|||||||
onCreateEvent: (LocalDate) -> Unit,
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||||
// The swipe wraps the grid rather than living inside it: mid-transition
|
// The swipe wraps the grid rather than living inside it: mid-transition
|
||||||
// there are two grids, and the gesture belongs to neither. The pane is left
|
// there are two grids, and the gesture belongs to neither. The pane is left
|
||||||
@@ -920,31 +924,76 @@ private fun SplitMonthContent(
|
|||||||
when (state) {
|
when (state) {
|
||||||
MonthUiState.Loading -> MonthGridLoading()
|
MonthUiState.Loading -> MonthGridLoading()
|
||||||
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
|
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
is MonthUiState.Success -> AnimatedContent(
|
is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) {
|
||||||
|
AnimatedContent(
|
||||||
targetState = expanded,
|
targetState = expanded,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
// Both branches fill the same box — the grid grows into exactly the
|
// Both branches fill the same box — the grid grows into exactly the
|
||||||
// room the pane gives up — so there is no size change to contain.
|
// room the pane gives up — so there is no size change to contain.
|
||||||
|
// The travel between them is the shared elements' job, not a slide.
|
||||||
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
|
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
|
||||||
label = "split-expand-transition",
|
label = "split-expand-transition",
|
||||||
) { isExpanded ->
|
) { isExpanded ->
|
||||||
if (isExpanded) {
|
CompositionLocalProvider(
|
||||||
|
// Null under reduced motion: nothing is tagged, nothing
|
||||||
|
// travels, and the cross-fade above is the whole transition.
|
||||||
|
LocalMonthMorph provides if (reduceMotion) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
SplitMonthBody(
|
||||||
|
expanded = isExpanded,
|
||||||
|
state = state,
|
||||||
|
selected = selected,
|
||||||
|
slideDir = slideDir,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
|
swipeModifier = swipeModifier,
|
||||||
|
onSelectDay = onSelectDay,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
onSetExpanded = { expanded = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two faces of the split style, sharing one set of morph tags. */
|
||||||
|
@Composable
|
||||||
|
private fun SplitMonthBody(
|
||||||
|
expanded: Boolean,
|
||||||
|
state: MonthUiState.Success,
|
||||||
|
selected: LocalDate,
|
||||||
|
slideDir: Int,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
|
swipeModifier: Modifier,
|
||||||
|
onSelectDay: (LocalDate) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
|
onSetExpanded: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
if (expanded) {
|
||||||
SplitMonthExpanded(
|
SplitMonthExpanded(
|
||||||
state = state,
|
state = state,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
swipeModifier = swipeModifier,
|
swipeModifier = swipeModifier,
|
||||||
// A tap in the expanded grid picks the day and drops back,
|
// A tap in the expanded grid picks the day and drops back, which
|
||||||
// which gives the expanded month a job — a chooser you dip
|
// gives the expanded month a job — a chooser you dip into — rather
|
||||||
// into — rather than a mode you can get stranded in. The
|
// than a mode you can get stranded in. The collapse then runs with
|
||||||
// collapse then runs with the selection already set, so the
|
// the selection already set, so the pane arrives showing the day
|
||||||
// pane arrives showing the day you picked.
|
// you picked.
|
||||||
onPickDay = {
|
onPickDay = {
|
||||||
onSelectDay(it)
|
onSelectDay(it)
|
||||||
expanded = false
|
onSetExpanded(false)
|
||||||
},
|
},
|
||||||
onCollapse = { expanded = false },
|
onCollapse = { onSetExpanded(false) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
SplitMonthCollapsed(
|
SplitMonthCollapsed(
|
||||||
@@ -957,11 +1006,9 @@ private fun SplitMonthContent(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
onExpand = { expanded = true },
|
onExpand = { onSetExpanded(true) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The split style at rest: compact grid, handle, then the selected day's events. */
|
/** The split style at rest: compact grid, handle, then the selected day's events. */
|
||||||
@@ -1093,6 +1140,9 @@ private fun SplitExpandHandle(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
|
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
|
||||||
|
// Tagged too, so it slides from the pane's seam down to the foot
|
||||||
|
// of the grid rather than blinking out at one and in at the other.
|
||||||
|
.morphElement(MonthMorphKey.Handle)
|
||||||
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1234,19 +1284,28 @@ private fun SplitDayCell(
|
|||||||
animationSpec = if (reduceMotion) snap() else fadeSpec,
|
animationSpec = if (reduceMotion) snap() else fadeSpec,
|
||||||
label = "split-day-selection",
|
label = "split-day-selection",
|
||||||
)
|
)
|
||||||
|
// 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
|
||||||
|
// elements within a shared element and travel twice.
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.selectable(selected = isSelected, onClick = onClick),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.morphElement(MonthMorphKey.Cell(date))
|
||||||
.clip(CELL_SHAPE)
|
.clip(CELL_SHAPE)
|
||||||
.background(background)
|
.background(background)
|
||||||
.border(
|
.border(
|
||||||
width = 1.5.dp,
|
width = 1.5.dp,
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
|
||||||
shape = CELL_SHAPE,
|
shape = CELL_SHAPE,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.selectable(selected = isSelected, onClick = onClick),
|
|
||||||
contentAlignment = Alignment.TopCenter,
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(top = 4.dp),
|
modifier = Modifier.fillMaxSize().padding(top = 4.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -1255,10 +1314,10 @@ private fun SplitDayCell(
|
|||||||
date = date,
|
date = date,
|
||||||
isToday = isToday,
|
isToday = isToday,
|
||||||
inMonth = inMonth,
|
inMonth = inMonth,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().morphElement(MonthMorphKey.DayNumber(date)),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
SplitDots(events = events, hidden = hidden, dark = dark)
|
SplitDots(date = date, events = events, hidden = hidden, dark = dark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1272,7 +1331,7 @@ private fun SplitDayCell(
|
|||||||
* bar with no dot to grow out of.
|
* bar with no dot to grow out of.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitDots(events: List<EventInstance>, hidden: Int, dark: Boolean) {
|
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
|
||||||
if (events.isEmpty()) return
|
if (events.isEmpty()) return
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
Row(
|
Row(
|
||||||
@@ -1280,9 +1339,13 @@ private fun SplitDots(events: List<EventInstance>, hidden: Int, dark: Boolean) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
events.forEach { event ->
|
events.forEach { event ->
|
||||||
|
// The dot keeps its own circle and the bar its 4dp corners; they
|
||||||
|
// cross-fade inside shared bounds, and at the dot's size the two
|
||||||
|
// radii are a fraction of a pixel apart.
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(SPLIT_DOT_SIZE)
|
.size(SPLIT_DOT_SIZE)
|
||||||
|
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1540,6 +1603,7 @@ private fun MonthWeekRow(
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.morphElement(MonthMorphKey.Cell(d))
|
||||||
.background(
|
.background(
|
||||||
color = when {
|
color = when {
|
||||||
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
|
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
|
||||||
@@ -1586,7 +1650,9 @@ private fun MonthWeekRow(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.morphElement(MonthMorphKey.DayNumber(d)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1615,7 +1681,17 @@ private fun MonthWeekRow(
|
|||||||
)
|
)
|
||||||
.width(colW * cols)
|
.width(colW * cols)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||||
|
// Anchored on the day the bar starts *in this row*,
|
||||||
|
// which is where its dot sat. A bar carried in from
|
||||||
|
// the previous week starts at column 0, and column
|
||||||
|
// 0's dot is the one that grows into it.
|
||||||
|
.morphBounds(
|
||||||
|
MonthMorphKey.Event(
|
||||||
|
date = week.days[span.startCol],
|
||||||
|
instanceId = span.event.instanceId,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Single-day timed pills + overflow, per column. Pills fill the
|
// Single-day timed pills + overflow, per column. Pills fill the
|
||||||
@@ -1643,7 +1719,8 @@ private fun MonthWeekRow(
|
|||||||
)
|
)
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||||
|
.morphBounds(MonthMorphKey.Event(d, ev.instanceId)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||||
|
|||||||
Reference in New Issue
Block a user