Adetunji Dahunsi

Navigation as state: Implementing animated navigation transitions for large screens with moveableContentOf in Jetpack Compose

Navigation state as a driver for large screen transitions

TJ Dahunsi

Dec 30 2022 · 9 min read

The following is not a reccomendation, endorsement, roadmap, official guidance or production level code. It is an exploration of a conceptual API, specifically navigation as state. Its an exploratory post, feedback is most welcome.

The very essence of Jetpack Compose is that it is declarative. You describe what the UI should look like at any point in time, and the framework renders it.

The description of the UI is typically defined by state; given some state, the UI should look a certain way. More tersely, the UI is a function of its state. This is especially true when trying to build UIs for different screen sizes. Available screen real estate can be defined as the following states:

  • Compact
  • Medium
  • Expanded

Width window size classes

However, screen size is not the only state that should be considered when building for large screens. When navigating in a feed for example, interacting with an item in the feed is often cause to show a detailed view of that item. Large screen devices provide a unique opportunity to improve the user experience in this scenario by providing visual continuity by collapsing the feed into a smaller portion of the screen (a supporting panel), while the detail view takes center stage. This however requires knowing that the main content view is going to be collapsed into the supporting panel. That is, the state that defines the UI is not just the screen size, but navigation itself.

Note: There are semantic distinctions between list-detail, feed and supporting panels in canonical layout definitions in terms of use cases. This article refers to the supporting panel as a generic implementation of having main content in focus and supplementary content shown alongside it regardless of the relationship between the screens. You can learn more about canonical layouts by exploring the Android large screen gallery.

Navigation as state

Navigation as state is a concept that opines that navigation is fundamentally business logic. It should be treated in the same way any state provided from the data layer or domain layer is; a repository provides navigation data with an observable API, and changes to navigation are written back into that repository.

The benefits this brings are numerous:

  • Navigation can be triggered directly from the screen level state holder (often an AAC ViewModel) without going through the UI.
  • Navigation state is not written to volatile storage, it will survive activity restarts and device reboots.
  • Navigation immediately becomes multiplatform as its just data.
  • Navigation state can be observed and used to drive animations that define the UI.

Consider the following declaration for navigation as state that has fields for reading the routes in the main navigation and the supporting panel:

1data class NavState( 2 val mainNav: MultiStackNav, 3 val supportingRoute: AppRoute? 4) 5 6val NavState.mainRoute: AppRoute get() = mainNav.current as? AppRoute ?: Route404

In the above, AppRoute is a type that defines a navigation destination. It declares a Render() function for providing a Composable for the content that should be displayed when it is in focus, as well as strings that define the URI for the AppRoute itself (id), as well as that for the AppRoute that should be rendered alongside it in a supporting panel should screen real estate permit it (supportingRoute).

1interface AppRoute : Route { 2 @Composable 3 fun Render() 4 5 val id: String 6 7 val supportingRoute: String? 8 get() = null 9}

An example declaration for such a route is the detail view of an archive:

1data class ArchiveDetailRoute( 2 override val id: String, 3 val kind: ArchiveKind, 4 val archiveId: ArchiveId 5) : AppRoute { 6 @Composable 7 override fun Render() { 8 ArchiveDetailScreen( 9 stateHolder = LocalScreenStateHolderCache.current.screenStateHolderFor(this), 10 ) 11 } 12 13 override val supportingRoute get() = "archives/${kind.type}" 14}

The supportingRoute URI refers to the feed navigation destination ArchiveListRoute declared in a different module:

1data class ArchiveListRoute( 2 override val id: String, 3 val kind: ArchiveKind 4) : AppRoute { 5 @Composable 6 override fun Render() { 7 ArchiveScreen( 8 stateHolder = LocalScreenStateHolderCache.current.screenStateHolderFor(this), 9 ) 10 } 11} 12

Animating stateful navigation

Animating a navigation transition requires being able to fluidly move UI elements between their initial and final places. In the classic View system with shared element transitions, this typically involved placing the views in the destination screen in the position of their counterparts in the preceding screen, and then animating them back into their final positions. This largely worked fine provided that the initial and final screens didn't change in size during the transition.

Things would however break if there was any appreciable size change in the layout container during the transition as the captured initial and final positions of the shared elements would not match the new configuration.

Jetpack Compose has a novel solution to this, the moveableContentOf API. Rather than try to match the end UI with the start UI, and then animate it to the final place, what if you actually just kept the same UI element between the start and end UIs? This way, since the content itself is being moved around, it is resilient to noise and jitter caused by the changes in the configuration of the device.

The goal therefore, is to combine navigation as state with the moveableContentOf API to define animated transitions for navigation changes that involve content moving in between the main content container and the supporting panel even if the routes involved are in different modules.

The rest of this article details a real world implementation of the aforementioned concepts of to match the design mockup shown, with the added benefit of animating content between the supporting panel and main navigation area.

window size class transitions

Defining state for navigation transitions

With navigation state already defined, the next step is being able to identify when content is moving between content containers. For every navigation change, content may be moving:

  • From the main content container to the supporting panel
  • From the supporting panel to the main content container
  • Just popping in and out of view

This can be defined with an enum:

1internal enum class MoveKind { 2 MainToSupporting, SupportingToMain, None 3}

Next is the transition implementation. The heart of this transition animation is a smoke and mirrors trick. There will be 2 layout containers on screen at all times. When content is transitioning between the main content and the supporting panel, an instantaneous swap will take place using the moveableContentOf API and the containers will be adjusted to take the position of their new content.

The following is a data structure that allows for keeping track of which content is in what layout:

1internal data class MoveableNav( 2 val mainRouteId: String = Route404.id, 3 val supportingRouteId: String? = null, 4 val moveKind: MoveKind = MoveKind.None, 5 val containerOneAndRoute: ContainerAndRoute = ContainerAndRoute(container = ContentContainer.One), 6 val containerTwoAndRoute: ContainerAndRoute = ContainerAndRoute(container = ContentContainer.Two, route = Route403) 7) 8 9internal val MoveableNav.mainContainer: ContentContainer 10 get() = when (mainRouteId) { 11 containerOneAndRoute.route.id -> containerOneAndRoute.container 12 containerTwoAndRoute.route.id -> containerTwoAndRoute.container 13 else -> throw IllegalArgumentException() 14 } 15 16internal val MoveableNav.supportingContainer: ContentContainer? 17 get() = when (supportingRouteId) { 18 containerOneAndRoute.route.id -> containerOneAndRoute.container 19 containerTwoAndRoute.route.id -> containerTwoAndRoute.container 20 else -> null 21 } 22 23internal data class ContainerAndRoute( 24 val route: AppRoute = Route404, 25 val container: ContentContainer, 26) 27 28internal enum class ContentContainer { 29 One, Two 30}

To create instances of it, the navigation state can be observed to to detect when content moves from one container to the next:

1internal fun StateFlow<NavState>.moveableNav(): Flow<MoveableNav> = 2 state 3 .map { it.mainRoute to it.supportingRoute } 4 .distinctUntilChanged() 5 .scan( 6 MoveableNav( 7 mainRouteId = value.mainRoute.id, 8 containerOneAndRoute = ContainerAndRoute( 9 route = value.mainRoute, 10 container = ContentContainer.One 11 ), 12 ) 13 ) { previousMoveableNav, (mainRoute, supportingRoute) -> 14 val moveableNavContext = MoveableNavContext( 15 slotOneAndRoute = previousMoveableNav.containerOneAndRoute, 16 slotTwoAndRoute = previousMoveableNav.containerTwoAndRoute 17 ) 18 moveableNavContext.moveToFreeContainer( 19 incoming = supportingRoute, 20 outgoing = mainRoute, 21 ) 22 moveableNavContext.moveToFreeContainer( 23 incoming = mainRoute, 24 outgoing = supportingRoute, 25 ) 26 MoveableNav( 27 mainRouteId = mainRoute.id, 28 supportingRouteId = supportingRoute?.id, 29 moveKind = when { 30 previousMoveableNav.mainRouteId == supportingRoute?.id -> MoveKind.MainToSupporting 31 mainRoute.id == previousMoveableNav.supportingRouteId -> MoveKind.SupportingToMain 32 else -> MoveKind.None 33 }, 34 containerOneAndRoute = moveableNavContext.slotOneAndRoute, 35 containerTwoAndRoute = moveableNavContext.slotTwoAndRoute, 36 ) 37 } 38 39 40private fun MoveableNavContext.moveToFreeContainer(incoming: AppRoute?, outgoing: AppRoute?) = 41 when (incoming) { 42 null, 43 slotOneAndRoute.route, 44 slotTwoAndRoute.route -> Unit 45 else -> when (outgoing) { 46 slotOneAndRoute.route -> slotTwoAndRoute = slotTwoAndRoute.copy(route = incoming) 47 slotTwoAndRoute.route -> slotOneAndRoute = slotOneAndRoute.copy(route = incoming) 48 else -> slotOneAndRoute = slotOneAndRoute.copy(route = incoming) 49 } 50 } 51

The above shuffles content from one container to the next to allow for both the main content and supporting panel content to remain on screen and in the composition to allow for seamless animation.

Moving Composables using moveableContentOf

The data structure and algorithm for preserving UI for navigation that moves between two screens have been defined. Next is the implementation in Compose. The moveableContentOf API is the bedrock of this. It allows a Composable to be moved from its current node in the layout tree to another node.

First we define a composable for a navigation route that can be moved:

1@Composable 2private fun rememberMoveableContainerContent( 3 saveableStateHolder: SaveableStateHolder, 4 route: AppRoute 5): @Composable () -> Unit { 6 return remember(route) { 7 movableContentOf { 8 saveableStateHolder.SaveableStateProvider(route.id) { 9 route.Render() 10 } 11 } 12 } 13}

Next, on the ContentContainer state defined in the previous section, an extension that allows it to render content that has been moved within it:

1@Composable 2private fun ContentContainer?.content( 3 containerOneContent: @Composable () -> Unit, 4 containerTwoContent: @Composable () -> Unit 5) { 6 when (this) { 7 ContentContainer.One -> containerOneContent() 8 ContentContainer.Two -> containerTwoContent() 9 null -> Unit 10 } 11}

Finally, we set up the app scaffold and read the navigation states that describe the UI and its animations:

1@Composable 2fun Scaffold( 3 modifier: Modifier, 4 navStateHolder: NavStateHolder, 5 globalUiStateHolder: GlobalUiStateHolder, 6) { 7 val moveableNavFlow = remember { 8 navStateHolder.state.moveableNav() 9 } 10 val moveableNav by moveableNavFlow.collectAsState(MoveableNav()) 11 12 val moveKind by remember { 13 derivedStateOf { moveableNav.moveKind } 14 } 15 val containerOneRoute by remember { 16 derivedStateOf { moveableNav.containerOneAndRoute.route } 17 } 18 val containerTwoRoute: AppRoute by remember { 19 derivedStateOf { moveableNav.containerTwoAndRoute.route } 20 } 21 val mainContainer: ContentContainer? by remember { 22 derivedStateOf { moveableNav.mainContainer } 23 } 24 val supportingContainer: ContentContainer? by remember { 25 derivedStateOf { moveableNav.supportingContainer } 26 } 27 28 val saveableStateHolder: SaveableStateHolder = rememberSaveableStateHolder() 29 30 val containerOneContent = rememberMoveableContainerContent( 31 saveableStateHolder = saveableStateHolder, 32 route = containerOneRoute 33 ) 34 val containerTwoContent = rememberMoveableContainerContent( 35 saveableStateHolder = saveableStateHolder, 36 route = containerTwoRoute 37 ) 38 39 Box( 40 modifier = modifier.fillMaxSize() 41 ) { 4243 AppRouteContainer( 44 globalUiStateHolder = globalUiStateHolder, 45 navStateHolder = navStateHolder, 46 moveKind = moveKind, 47 mainContent = { 48 mainContainer.content( 49 containerOneContent = containerOneContent, 50 containerTwoContent = containerTwoContent 51 ) 52 }, 53 supportingContent = { 54 supportingContainer.content( 55 containerOneContent = containerOneContent, 56 containerTwoContent = containerTwoContent 57 ) 58 } 59 ) 6061 } 62}

In the above, when MoveKind.MainToSupporting or MoveKind.SupportingToMain occur, both routes will remain in the composition and the transition can be animated seamlessly.

Result

In the screen recording below, the user is:

  • Initially presented with a feed with a COMPACT WindowSizeClass. Navigation is performed with a BottomAppBar.
  • The window is then resized to the MEDIUM WindowSizeClass, prompting the NavigationRail to replace the BottomAppBar as the means of navigation.
  • The window is then expanded to the EXPANDED WindowSizeClass, and the feed rearranges itself once again to maximize screen real estate. At this point the user decides to view an item in the feed in more detail.
  • The feed content is moved out of the main layout container and into the supporting panel using moveableContentOf in response to the change in navigation state. The supporting panel uses the animateDpAsState API with a Spring AnimationSpec to animate the collapsing of the feed content into the supporting panel.
  • The window is then resized back down to the MEDIUM WindowSizeClass. Since there's limited screen real estate, the supporting panel is collapsed with the same animation used to bring it into focus. The NavigationRail remains the UI element for navigation.
  • Finally the window is collapsed into the COMPACT WindowSizeClass, replacing the NavigationRail with the BottomAppBar.

Navigation as state driving screen transitions

Wrap-up

The best part of the implementation described is that the transition applies to any permutation of navigation transitions in the app. There is no need to hand roll state coordination between the main content and supporting panel; they are fully independent routes. If custom logic needs to be defined, it can be done in the navigation state declaration and used to drive the UI.

This can easily be seen when navigating back to the ArchiveList route. Any state change that occured when the ArchiveList route was in the supporting panel, is present when it returns to the main container. Navigation as state allows for fully modularized applications, where navigation routes are truly insulated from one another across device configurations and screen sizes.

The source for the app shown above is on github here:

The next article exploring navigation as state will focus on being able to drive navigation state from the business logic state holder directly without needing the UI to act as an intermediary. See you around!

13