diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 2386ce08..f42499c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -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), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt index bc686171..44ee7c37 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -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), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt index bdd9e9a8..73a4ae95 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt @@ -56,6 +56,7 @@ fun CalendarTitleButton( Row( modifier = modifier + .morphChrome(ChromeSlot.Title) .heightIn(min = 48.dp) .clip(MaterialTheme.shapes.large) .clickable( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt index 583ee4a0..903447fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt index ebad7c35..10c7aabf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt index 3ca60cd9..9d8d0d04 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt @@ -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). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index d12529c2..b8ac85cd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -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), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 8b00def9..c5ea80a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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),