UI layer architecture for persistent UI elements

Should persistent UI elements be defined at the root level or per-screen?

TJ Dahunsi

May 14 2025 · 12 mins

In mobile apps, certain UI elements persist across the user interface in multiple contexts. This is especially true for UI elements that facilitate navigation, for example:

  1. Navigation bars and navigation rails.

  2. Top and bottom app bars.

  3. Floating action buttons.

When building the UI for these screens, there's generally 2 ways they may be laid out:

  1. Root level UI elements: The entire app shares a single instance of these elements in a root level scaffolding layout. On Android, this is typically at an Activity, or NavHostFragment level.

  2. Per-screen UI elements: Each navigation destination is responsible for drawing its own UI elements.

The question therefore is, is one approach generally better than the other? Can they coexist? Satisfyingly, I believe this to be one of the rare cases where the classic software engineering "It depends" response does not apply. I'm firmly of the opinion that for Jetpack Compose apps, the per-screen UI element approach should always be preferred. Let's take a brief walk down memory lane.

Root level UI elements

On Android, one can trace the origin of root level UI elements to the original Activity ActionBar API and the subsequent introduction of the Fragment API. A Fragment could call:

  1. getSupportActionBar() to set the title and other ActionBar attributes in its parent Activity.

  2. setHasOptionsMenu() to update the menu items in its parent Activity.

Android ActionBar

The classic ActionBar

This implicitly set up a hierarchical relationship, as the Activity owned the ActionBar. This combined with the move to a single-activity architecture, set the tone for managing top level decor like Floating action buttons, AppBars, and NavigationBars at some root level. This of course came with pros and cons:

Pros

  • True Persistence: The UI element is the same instance, guaranteeing visual consistency without complex transitions between instances. This was especially beneficial for apps that used floating action buttons, as screen transitions did not introduce UI/UX noise by having a different FAB instance per screen.

Cons

  • Tight Coupling: Screens become tightly coupled to the host Activity's implementation details and framework APIs.

  • Complex State Management: The host Activity becomes a bottleneck, needing complex logic to update the title, menu options, FAB visibility/icon/action for each specific screen, especially when the screens are animating. This scales poorly.

  • Limited Flexibility: It becomes difficult to change the toolbar style, remove it entirely for a specific screen, or handle edge cases without adding more conditional complexity to the host.

  • Testing Challenges: It is harder to test screens in isolation as they depend on the host Activity providing the necessary UI components and configuration hooks.

Per-screen UI elements

The cons of root level UI elements were severe enough that setHasOptionsMenu() for fragments were subsequently deprecated in 2022. Although replacements were provided in the MenuHost and MenuProvider APIs, this mostly serves as a way of preserving backwards compatibility.

Crucially, Jetpack Compose had already hit version 1.1.1, and notably it did not provide an ActionBar, or MenuHost like API. In fact, the closest API introduced to provide the functionality of how to manage the general frame of a navigation destination, is the Scaffold composable. Interestingly enough, it is:

  • An opinionated implementation in the material3 compose library.

  • Is a per-screen UI element implementation, providing slots for app bars, floating action buttons and the like.

  • Implicitly encourages that navigation destinations in the app replace the entire screen content, including its specific appbar, floating action button and so on.

Now In Android Scaffold

Scaffold in the Now In Android App

Again, we can list the pros and cons:

Pros

  • Encapsulation & Modularity: Each navigation destination is self-contained and manages its own UI elements and their state. This promotes the Single Responsibility Principle.

  • High Flexibility: Navigation destinations can easily customize persistent UI elements without affecting others. Need a completely custom app bar? No problem. Don't need a floating action button? Just don't include it.

  • Simplified State Management: UI state (title, menu items, navigation bar state) is managed locally within the screen's ViewModel or Composable state, making it easier to reason about.

  • Improved Testability: Navigation destinations can be tested in isolation much more easily.

  • Decoupling: Navigation destinations are decoupled from the host Activity regarding these specific UI elements.

Cons

  • Code Duplication: Without proper structuring, there might be repetition of commonly used UI elements across navigation destinations.

  • Broken immersion: Making it look like the same appbar or floating action button is smoothly persisting/transforming between screens requires explicit transition handling.

UI layer architecture for persistent UI

A comparison in the pros and cons list across both implementations would give the impression that whatever benefits we gain in Compose with per-screen UI in reducing complexity, we pay for in actually conveying in the UX that the UI elements are persistent as seen in the screen recording below.

Noise in the bluesky app from having the fab animate

Multiple FABs in the Bluesky App

However there is an easy solution to this: the Compose shared element transition and Animation Modifier APIs, along with a well designed UI layer architecture.

The UI logic state holder for persistent UI

The entry point to these animation APIs mentioned above are in the scopes they provide: SharedTransitionScope and AnimatedVisibilityScope. For navigation destination transitions, both are almost always used in tandem, and it is really useful to create a UI logic state holder that inherits from both. I personally like to call this a ScaffoldState. This ScaffoldState should be in a common module accessible to all modules in the app that show navigation destinations. I typically call this module scaffold.

The definition for the ScaffoldState can simply be:

class ScaffoldState internal constructor( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope, internal val isMediumScreenWidthOrWider: State<Boolean>, ) : AnimatedVisibilityScope by animatedVisibilityScope, SharedTransitionScope by sharedTransitionScope { internal val canShowBottomNavigation get() = !isMediumScreenWidthOrWider.value internal val canShowNavRail get() = isMediumScreenWidthOrWider.value // implementation omitted && isAtDeviceEdge }

The ScaffoldState has an internal constructor, other modules may not create it. Instead they remember instances of it in the composition using a utility method also defined in the scaffold module:

@Composable fun rememberScaffoldState( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope, ) : ScaffoldState { val isMediumScreenWidthOrWider = isMediumScreenWidthOrWider() return remember { ScaffoldState( animatedVisibilityScope = animatedVisibilityScope, sharedTransitionScope = sharedTransitionScope, isMediumScreenWidthOrWider = isMediumScreenWidthOrWider, ) } } @Composable private fun isMediumScreenWidthOrWider(): State<Boolean> { val isMediumScreenWidthOrWider = currentWindowAdaptiveInfo() .windowSizeClass .isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) return rememberUpdatedState(isMediumScreenWidthOrWider) }

In the above, UI logic defining which navigation UI element is shown is implemented by way of WindowSizeClass in the ScaffoldState. The AnimatedVisibilityScope and SharedTransitionScope parameters are explicitly passed in. The former is typically provided by the navigation library when defining a destination, the latter by a CompositionLocal or prop drilling. An example of using a CompositionLocal is described later.

Persistent UI Scaffolding

With the means to remember a ScaffoldState in composition defined, the actual PersistentScaffold Composable follows:

@Composable fun ScaffoldState.PersistentScaffold( modifier: Modifier = Modifier, topBar: @Composable ScaffoldState.() -> Unit = {}, floatingActionButton: @Composable ScaffoldState.() -> Unit = {}, navigationBar: @Composable ScaffoldState.() -> Unit = {}, navigationRail: @Composable ScaffoldState.() -> Unit, content: @Composable ScaffoldState.(PaddingValues) -> Unit, ) { NavigationRailScaffold( modifier = modifier, navigationRail = navigationRail, content = { Scaffold( modifier = modifier .animateBounds(lookaheadScope = this), topBar = { topBar() }, floatingActionButton = { floatingActionButton() }, bottomBar = { navigationBar() }, content = { paddingValues -> content(paddingValues) }, ) } ) }

An intermediate composable is used in the above: NavigationRailScaffold. This is just a simple Row with 2 items, the navigationRail and content:

@Composable private inline fun NavigationRailScaffold( modifier: Modifier = Modifier, navigationRail: @Composable () -> Unit, content: @Composable () -> Unit, ) { Row( modifier = modifier, content = { Box( modifier = Modifier .widthIn(max = 80.dp) .zIndex(2f), ) { navigationRail() } Box( modifier = Modifier .fillMaxSize() .zIndex(1f), ) { content() } }, ) }

Unfortunately, the NavigationSuiteScaffold set of APIs cannot be used here, as unlike the plain Scaffold composable, they do not allow slot access to the persistent UI elements comprising the scaffold, making it impossible to pass Modifier instances to them. This makes the rest of this article inapplicable to NavigationSuiteScaffold.

Persistent UI elements as extensions of its UI logic state holder

To provide the illusion of these UI elements persisting from navigation destination to navigation destination, composables for these UI elements can be written as extensions on the ScaffoldState, providing access to the SharedTransitionScope to provide a shared element Modifier for the element. These definitions should also reside in the scaffold module. For example, a PersistentNavigationAppBar could be:

@Composable fun ScaffoldState.PersistentNavigationBar( modifier: Modifier = Modifier, enterTransition: EnterTransition = slideInVertically(initialOffsetY = { it }), exitTransition: ExitTransition = slideOutVertically(targetOffsetY = { it }), content: @Composable RowScope.() -> Unit ) { AnimatedVisibility( modifier = modifier .sharedElement( sharedContentState = rememberSharedContentState( BottomNavSharedElementKey ), animatedVisibilityScope = this, zIndexInOverlay = BottomNavSharedElementZIndex, ), visible = canShowBottomNavigation, enter = enterTransition, exit = exitTransition, content = { NavigationBar { ... }, } ) } private data object BottomNavSharedElementKey

Extensions of the same kind can be written for other persistent UI elements like navigation rails or floating action buttons. Feature modules can then depend on the scaffold module, and use the scaffolding as follows:

@Composable fun FeedRoute( viewModel: ListingFeedViewModel, // Gotten from navigation library. // In Navigation Compose, it is provided in // the composable<Destination> { } lambda. animatedVisibilityScope: AnimatedVisibilityScope, // Passed from the navigation destination declaration. sharedTransitionScope: SharedTransitionScope, ) { route -> rememberScaffoldState( animatedVisibilityScope = animatedVisibilityScope, sharedTransitionScope = sharedTransitionScope, ).PersistentScaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.listing_app)) }, ) }, content = { paddingValues -> ListingFeedScreen( modifier = Modifier .padding(paddingValues), scaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, actions = viewModel.accept ) }, navigationBar = { PersistentNavigationAppBar( modifier = Modifier .animateEnterExit( enter = slideInVertically(initialOffsetY = { it }), exit = slideOutVertically(targetOffsetY = { it }), ) ) }, navigationRail = { PersistentNavigationNavRail() } ) }

Each API used, and the UI/UX they enable are visualized below:

scaffold local changes

scaffold navigation changes

scaffold shared elements

From left to right: Persistent UI shared elements across navigation destinations, persistent UI enter and exits across navigation destinations, and persistent UI enter and exits within the same destination.

As seen in the table above, using the ScaffoldState as the UI state holder, allows for tailoring specific Jetpack Compose animation APIs, to their strengths, all while combining them into a cohesive whole. In more detail:

  1. ScaffoldState and Modifier.sharedElement(): Persistent UI elements that are invoked on different navigation destinations will have the shared element APIs preserve visual continuity. In the example shown, each navigation destination is responsible for its own floating action button, yet the illusion of persistence is maintained. In this particular example, the gallery destination scaffold is identical to the detail destination except that it invokes the PersistentFab with different arguments.

  2. ScaffoldState and AnimatedContent: Invocations of persistent UI elements can use Modifier.animateEnterExit() to define an EnterTransition and/or an ExitTransition for navigation changes. In this example, the persistent navigation bar animates in and out by sliding vertically. In this particular example, the detail destination scaffold is identical to the feed destination except that it:

    • Does not invoke the PersistentNavigationAppBar.

    • Invokes the PersistentFab.

  3. ScaffoldState, AnimatedVisibility and Modifier.animateBounds(): Invocations of PersistentNavigationAppBar and/or PersistentNavigationNavRail automatically hide or show based on the current WindowSizeClass, i.e local screen changes. When either is shown or hidden, the content composable animates its size and position to accommodate the changes. Users may also pass an EnterTransition or ExitTransition directly to the composable to customize the animations.

Persistent UI elements and the application UI state holder

Persistent UI like navigation bars or navigation rails sometimes have to display state that is far removed from their local contexts. Sometimes, this information can be in a different module. Examples of these include:

  • Notification badges for unseen notifications.

  • Unread counts for messages.

  • User profile alerts or reminders.

In cases like this, your app should have a definition for an AppState. In the Now In Android sample, this is the NiaAppState. When working with persistent UI across navigation destinations, this AppState is crucial as it has access to the app's navigation semantics, and all the resources it may need to populate the current items in the NavigationBar. At its simplest, an AppState may be the following:

@Stable class AppState( private val navigationStateHolder: NavigationStateHolder, ) { private val navState by navigationStateHolder.state lateinit var sharedTransitionScope: SharedTransitionScope val navItems: List<NavItem> get() = navItemsFrom(navState) }

The AppState is the entry point to things provided at an app level. Your app's navigation state, how many panes your app is capable of showing, and so on reside here. It may require access to business logic and often needs dependency injected data sources. In the Now In Android sample, NiaAppState uses data sources to determine which tabs have notification badges.

To provide state for persistent UI elements from your AppState, ScaffoldState should depend on AppState as an internal implementation detail and as an example of state holder compounding:

class ScaffoldState internal constructor( ... internal val appState: AppState ) : AnimatedVisibilityScope by animatedVisibilityScope, // The SharedTransitionScope is retrieved from the AppState SharedTransitionScope by appState.sharedTransitionScope { ... internal val canShowNavRail get() = appState.isInEdgePane && isMediumScreenWidthOrWider.value } internal val LocalAppState = staticCompositionLocalOf<AppState> { throw IllegalArgumentException("AppState must be provided in the app scaffolding.") }

To retrieve the AppState for use, there should be an internal LocalAppState definition in the scaffold module, along with an App Composable. This AppState is then provided to the composition tree at the entry point of your app into compose.

@Composable fun App( modifier: Modifier, appState: AppState, ) { AppTheme { Surface { // Root LookaheadScope used to anchor all shared element transitions SharedTransitionLayout( modifier = modifier.fillMaxSize() ) { // You may opt to hold a reference to the SharedElementTransitionScope appState.sharedElementTransitionScope = this@SharedTransitionLayout CompositionLocalProvider( LocalAppState provides appState, ) { // The rest of your app's UI goes here, things like the `NavHost` // and so on. ... } } } } } internal val LocalAppState = staticCompositionLocalOf<AppState> { throw IllegalStateException("CompositionLocal LocalAppState not present") }

In Android apps, use of the above will be in the Activity, resembling the following:

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) // Create your app state here. If it needs dependency injected data sources // or access to a nav controller or navigation state, provide it here as well // or in Composition. val appState: AppState = ... setContent { App( modifier = Modifier, appState = appState, ) } } }

Next, the call-site for remembering the ScaffoldState would then be updated to:

@Composable fun rememberScaffoldState( // This is provided by the navigation library animatedVisibilityScope: AnimatedVisibilityScope, ) : ScaffoldState { val isMediumScreenWidthOrWider = isMediumScreenWidthOrWider val appState = LocalAppState.current return remember { ScaffoldState( animatedVisibilityScope = animatedVisibilityScope, isMediumScreenWidthOrWider = isMediumScreenWidthOrWider, // SharedTransitionScope is now provided by the AppState appState = appState, ) } }

Leaving the declaration of the PersistentNavigationAppBar only needing to specify the Modifier or transitions to run:

fun ScaffoldState.PersistentNavigationAppBar( modifier: Modifier = Modifier, ..., ) { AnimatedVisibility( modifier = modifier .sharedElement(...), ..., content = { NavigationBar { val appState = LocalAppState.current appState.navItems.forEach { item -> NavigationBarItem(...) } } }, ) }

Round up

The above describes an architectural pattern for persistent UI for apps. The great part about this architecture is that it also scales to tablet and desktop form factors; this will be covered in a subsequent blog post.

To recap, the architecture defined allows for:

  • Navigation destination control of persistent UI animations for local changes within a navigation destination like window size changes.

  • Navigation destination control of persistent UI animations for navigation changes across navigation destinations.

  • Full customization per navigation destination on which UI elements are persistent, and which ones are not.

  • A pattern of describing persistent UI elements as a function of the ScaffoldState of a navigation destination.

  • The practice of having a scaffold module or similar for disseminating fine grained control of AppState to navigation destinations.

An example of the above in an app with a scaffold module, AppState and per navigation destination customization can be seen below.

,