Hold the top bar and FAB still across view switches (#184)

This commit is contained in:
2026-09-27 19:28:10 +02:00
parent 7d7d16ca31
commit f9dd7a22b7
8 changed files with 173 additions and 79 deletions
@@ -50,6 +50,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.morphChrome
import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -115,6 +118,9 @@ fun AgendaScreen(
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val drawerState = rememberDrawerState(DrawerValue.Closed)
// The bar and FAB hold still through a view switch, but not over an open drawer.
val chromeMorph = drawerState.currentValue == DrawerValue.Closed &&
drawerState.targetValue == DrawerValue.Closed
val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) }
@@ -173,24 +179,28 @@ fun AgendaScreen(
Scaffold(
modifier = modifier,
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
ViewMorphChrome(chromeMorph) {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
}
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
!todayInToolbar,
todayText = stringResource(R.string.agenda_today_action),
onToday = jumpToToday,
onCreate = { onCreateEvent(anchor, null) },
)
ViewMorphChrome(chromeMorph) {
CalendarFabColumn(
todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
!todayInToolbar,
todayText = stringResource(R.string.agenda_today_action),
onToday = jumpToToday,
onCreate = { onCreateEvent(anchor, null) },
)
}
},
) { innerPadding ->
Column(
@@ -510,11 +520,13 @@ private fun AgendaTopBar(
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
modifier = Modifier
.morphChrome(ChromeSlot.Title)
.padding(start = AppBarSpacing.TitleInset),
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
@@ -523,7 +535,7 @@ private fun AgendaTopBar(
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
@@ -15,6 +15,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -48,7 +49,7 @@ fun CalendarFabColumn(
text = { Text(todayText) },
)
}
FloatingActionButton(onClick = onCreate) {
FloatingActionButton(onClick = onCreate, modifier = Modifier.morphChrome(ChromeSlot.Create)) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_new_title),
@@ -56,6 +56,7 @@ fun CalendarTitleButton(
Row(
modifier = modifier
.morphChrome(ChromeSlot.Title)
.heightIn(min = 48.dp)
.clip(MaterialTheme.shapes.large)
.clickable(
@@ -59,7 +59,9 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) {
val description = stringResource(R.string.today_jump_action)
IconButton(
onClick = onToday,
modifier = Modifier.semantics { contentDescription = description },
modifier = Modifier
.morphChrome(ChromeSlot.Today)
.semantics { contentDescription = description },
) {
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
Box(
@@ -20,6 +20,8 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
@@ -200,6 +202,60 @@ fun TimelineGrounds(
}
}
/**
* The pieces of the top bar and the FAB every view has (#184). Those drawn the
* same in each view hold still through a switch instead of fading out and back
* in; the title and the view pill, which differ, cross-fade in place.
*/
enum class ChromeSlot(val identical: Boolean) {
Menu(identical = true),
Search(identical = true),
Today(identical = true),
Create(identical = true),
Title(identical = false),
Pill(identical = false),
}
/**
* Provides the view morph to a screen's top bar or FAB only while [enabled]:
* with its drawer open or opening, what travels would be drawn over the drawer.
*/
@Composable
fun ViewMorphChrome(enabled: Boolean, content: @Composable () -> Unit) = ViewMorphPage(enabled, content)
/** Tags a piece of the top bar or the FAB; see [ChromeSlot]. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphChrome(slot: ChromeSlot): Modifier {
val morph = LocalViewMorph.current ?: return this
return with(morph.shared) {
val state = rememberSharedContentState(slot)
if (slot.identical) {
this@morphChrome.sharedElement(
sharedContentState = state,
animatedVisibilityScope = morph.visibility,
boundsTransform = morph.boundsTransform,
zIndexInOverlay = CHROME_Z_INDEX,
)
} else {
this@morphChrome.sharedBounds(
sharedContentState = state,
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
// Each side keeps its own size: a title squeezed to the other's
// width would ellipsise and unfold on the way.
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(ContentScale.None, Alignment.CenterStart),
zIndexInOverlay = CHROME_Z_INDEX,
)
}
}
}
/** Above the events and dates, which pass under the top bar on their way. */
private const val CHROME_Z_INDEX = 2f
/** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -40,7 +40,9 @@ fun ViewSwitcherPill(
FilledTonalButton(
onClick = onCycle,
shape = MaterialTheme.shapes.large,
modifier = modifier.padding(end = trailingInset),
modifier = modifier
.padding(end = trailingInset)
.morphChrome(ChromeSlot.Pill),
) {
// The label cross-fades and the pill eases to its width, so cycling
// views turns it over rather than snapping it (#184).
@@ -97,6 +97,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.morphChrome
import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
@@ -237,6 +240,9 @@ fun MonthScreen(
}
val drawerState = rememberDrawerState(DrawerValue.Closed)
// The bar and FAB hold still through a view switch, but not over an open drawer.
val chromeMorph = drawerState.currentValue == DrawerValue.Closed &&
drawerState.targetValue == DrawerValue.Closed
val scope = rememberCoroutineScope()
val scrolling = viewStyle.isScrolling
@@ -420,38 +426,42 @@ fun MonthScreen(
Scaffold(
modifier = modifier,
topBar = {
MonthTopBar(
title = topBarTitle,
shortTitle = topBarShortTitle,
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
ViewMorphChrome(chromeMorph) {
MonthTopBar(
title = topBarTitle,
shortTitle = topBarShortTitle,
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
}
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnCurrentMonth && !todayInToolbar,
todayText = stringResource(R.string.month_today_action),
onToday = jumpToToday,
onCreate = {
// Split has a selected day; the other styles anchor on
// today when its month is shown, else the 1st.
onCreateEvent(
when {
viewStyle == MonthViewStyle.Split -> selectedDate
isOnCurrentMonth -> today
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
},
null,
)
},
)
ViewMorphChrome(chromeMorph) {
CalendarFabColumn(
todayVisible = !isOnCurrentMonth && !todayInToolbar,
todayText = stringResource(R.string.month_today_action),
onToday = jumpToToday,
onCreate = {
// Split has a selected day; the other styles anchor on
// today when its month is shown, else the 1st.
onCreateEvent(
when {
viewStyle == MonthViewStyle.Split -> selectedDate
isOnCurrentMonth -> today
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
},
null,
)
},
)
}
},
) { innerPadding ->
// Hoisted above every style's grid: a dragged chip is drawn in an
@@ -810,7 +820,7 @@ private fun MonthTopBar(
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
@@ -819,7 +829,7 @@ private fun MonthTopBar(
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
@@ -68,6 +68,9 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.morphChrome
import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.flow.first
@@ -351,6 +354,9 @@ private fun PagedTimelineScreen(
}
val drawerState = rememberDrawerState(DrawerValue.Closed)
// The bar and FAB hold still through a view switch, but not over an open drawer.
val chromeMorph = drawerState.currentValue == DrawerValue.Closed &&
drawerState.targetValue == DrawerValue.Closed
val scope = rememberCoroutineScope()
// A new grid numbers the pages differently, so it gets a pager of its own,
@@ -472,31 +478,35 @@ private fun PagedTimelineScreen(
Scaffold(
modifier = modifier,
topBar = {
WeekTopBar(
kind = titleKind,
weekStart = weekStart,
weekEnd = weekEnd,
currentYear = today.year,
selectedView = view,
onCycleView = { onSelectView(view.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
ViewMorphChrome(chromeMorph) {
WeekTopBar(
kind = titleKind,
weekStart = weekStart,
weekEnd = weekEnd,
currentYear = today.year,
selectedView = view,
onCycleView = { onSelectView(view.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
}
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnCurrentWeek && !todayInToolbar,
todayText = stringResource(titleKind.todayText),
onToday = jumpToToday,
onCreate = {
// Anchor on today when it's in view, else the page's first day.
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
},
)
ViewMorphChrome(chromeMorph) {
CalendarFabColumn(
todayVisible = !isOnCurrentWeek && !todayInToolbar,
todayText = stringResource(titleKind.todayText),
onToday = jumpToToday,
onCreate = {
// Anchor on today when it's in view, else the page's first day.
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
},
)
}
},
) { innerPadding ->
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
@@ -955,7 +965,7 @@ private fun WeekTopBar(
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
@@ -964,7 +974,7 @@ private fun WeekTopBar(
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),