Blog Infos
Author
Published
Topics
, , , ,
Published

The various form factors we have in the market today [Image generated by Gemini 2.5 Pro]

The Android ecosystem is wonderfully diverse. From compact phones to expansive tablets and innovative foldables, our apps need to shine everywhere. Jetpack Compose has given us incredible power to build beautiful UIs, and its Material 3 adaptive components are game-changers for creating responsive experiences.

Recently, I embarked on a journey to refactor the main interface of my app, Upnext: TV Series Manager, to better utilize screen real estate on larger devices. The goal was simple: transition from a traditional list-navigate-to-detail flow to a sophisticated two-pane layout where appropriate. In this article, I will dive into how I implemented this new adaptive layout: NavigableListDetailPaneScaffold.

If you’ve previously worked with adaptive layouts, you might remember ListDetailPaneScaffold (introduced at Google I/O 2024). While useful, the newer NavigableListDetailPaneScaffold from androidx.compose.material3.adaptive.navigation offers enhanced flexibility and better integration with Jetpack Compose Navigation, especially for more complex scenarios, precisely what Upnext needed.

The Vision: From Single Pane to Dual Pane Delight

On a phone, the user experience is straightforward: tap an item in a list (e.g., a TV show), and the app navigates to a full-screen detail view.

However, on a tablet or foldable, showing just a list or detail screen feels like a missed opportunity. The ideal scenario? Display the list on one side and the selected item’s details on the other, simultaneously. This is the core promise of a list-detail layout.

On small devices like a compact, only the list is displayed. But when the window size is bigger, both the list and detail can be side-by-side [Source: https://android-developers.googleblog.com/2024/05/scaling-across-screens-with-compose-google-io-24.html]

Laying the Foundation: Key Dependencies

Before we dive into the code, let’s ensure our build.gradle is equipped with the necessary Material 3 adaptive libraries:

// app/build.gradle

dependencies {
    // Core adaptive capabilities
    implementation "androidx.compose.material3:material3-adaptive:<latest version>" // Use the latest version
    implementation "androidx.compose.material3:material3-adaptive-layout:<latest version>"

    // The star: NavigableListDetailPaneScaffold
    implementation "androidx.compose.material3:material3-adaptive-navigation:<latest version>"

    // For the outer navigation structure (Bottom Nav, Rail, Drawer)
    implementation "androidx.compose.material3:material3-adaptive-navigation-suite:<latest version>"

    // WindowSizeClass utilities
    implementation "androidx.compose.material3:material3-window-size-class:<latest version>"

    // Jetpack Compose Navigation (and Compose Destinations in my case)
    implementation "androidx.navigation:navigation-compose:<latest version>"
    // (Optional) Compose Destinations by Rafael Costa
    implementation "io.github.raamcosta.compose-destinations:core:<latest version>"
    ksp "io.github.raamcosta.compose-destinations:ksp:<latest version>" 
}
The Architectural Blueprint: Scaffolds Working in Harmony

Our adaptive UI is built upon two key scaffolds working together:

  1. NavigationSuiteScaffold: This is our outermost container. It intelligently switches between a bottom navigation bar, a navigation rail, or a navigation drawer based on the available screen width. It handles our app’s primary navigation sections (DashboardSearchExplore, etc.).
  2. NavigableListDetailPaneScaffold: Nested within the content area of NavigationSuiteScaffold, this is where the list-detail magic happens. It manages the display of our list content and the detail content, adapting to show one or both panes.

Here’s a simplified conceptual look from our MainScreen.kt:

@Composable
fun MainScreen(/*...*/) {
    // ... calculate windowSizeClass, setup NavControllers ...

    val listDetailNavigator = rememberSupportingPaneScaffoldNavigator<ThreePaneScaffoldRole>()
    val mainNavController = rememberNavController() // For the detail pane's content

    NavigationSuiteScaffold(
        navigationSuiteItems = { /* Define our Dashboard, Search, etc. items */ }
    ) { // Content of NavigationSuiteScaffold
        NavigableListDetailPaneScaffold(
            navigator = listDetailNavigator,
            listPane = {
                // Content for the list area, e.g., DashboardScreen
                // This screen will use a navigator to control mainNavController
                ListContent(destinationsNavigatorForDetailPane = mainNavController.rememberDestinationsNavigator())
            },
            detailPane = {
                // Content for the detail area, hosting its own NavHost
                DetailPaneNavHost(navController = mainNavController)
            }
        )
    }
}
The NavigableListDetailPaneScaffold in Detail

Let’s zoom in on the core components:

  1. rememberSupportingPaneScaffoldNavigator<ThreePaneScaffoldRole>(): This is the controller for our NavigableListDetailPaneScaffold. We call it listDetailNavigator. It determines which pane role (Primary for detail, Secondary for list) is currently active or focused.
  2. listPane: A composable lambda where we place our list content. In Upnext, this is where DashboardScreenSearchScreen, and so on, reside. Crucially, these list screens are given a way to trigger navigation within the detailPane.
  3. detailPane: This is where things get interesting! Instead of just displaying static content, we embed an entirely separate Jetpack Compose NavHost (here using the Compose Destinations’ DestinationsNavHost). This NavHost (managed by mainNavController) has its own navigation graph, including:
  • An EmptyDetailScreen as its starting destination (perfect for when no item is selected on larger screens).
  • ShowDetailScreenShowSeasonsScreen, etc., for displaying the actual content.
Connecting the Dots: List Clicks to Detail Updates

How does clicking an item in the listPane update the detailPane?

  • Our list screens (e.g., DashboardScreen) receive a DestinationsNavigator (a wrapper for mainNavController from the Compose Destinations library, but you could use mainNavController directly).
  • When a list item is clicked, this navigator is used to navigate mainNavController (which controls the detailPane’s NavHost) to the appropriate detail screen (e.g., ShowDetailScreenDestination), passing along the necessary arguments like a show ID.

 

// Simplified example from a list screen
@Composable
fun MyListScreen(navigatorToDetailPane: DestinationsNavigator) {
    // ... display list of items ...
    MyItem(onClick = { item ->
        navigatorToDetailPane.navigate(
            ShowDetailScreenDestination(showId = item.id, /*...*/)
        )
    })
}

 

The Brains of Adaptation: LaunchedEffect for Pane Visibility

To control whether one or both panes are visible, especially on compact screens versus larger ones, a LaunchedEffect is our best friend. It observes changes like whether a detail flow is active (isDetailFlowActive — a state we manage based on the mainNavController’s current route) and the windowWidthSizeClass.

// Inside MainScreen.kt
val isDetailFlowActive = remember(mainNavController.currentBackStackEntryAsState().value) {
    mainNavController.currentDestination?.route != EmptyDetailScreenDestination.route // And other conditions
}
val windowSizeClass = calculateWindowSizeClass(LocalActivity.current)

LaunchedEffect(isDetailFlowActive, listDetailNavigator.currentDestination, windowSizeClass.widthSizeClass) {
    val currentPaneRole = listDetailNavigator.currentDestination?.pane
    val targetPaneRole = if (isDetailFlowActive) {
        ThreePaneScaffoldRole.Primary // Detail
    } else {
        ThreePaneScaffoldRole.Secondary // List
    }

    if (currentPaneRole != targetPaneRole) {
        listDetailNavigator.navigateTo(targetPaneRole)
    }
    // On compact screens, NavigableListDetailPaneScaffold will show only the targetPaneRole.
    // On larger screens, it typically shows both if content is available,
    // with targetPaneRole indicating the "focused" one.
}

This ensures that on compact screens, if isDetailFlowActive is true, we navigate listDetailNavigator to show the Primary pane (our detail content). If not, we show the Secondary pane (our list). On larger screens, this helps establish the focus, while the scaffold often defaults to showing both panes if possible.

Handling Back Navigation Intelligently

Back navigation also needs to be adaptive:

  • On a compact screen, if viewing a detail, “back” should go to the list.
  • On a large screen, “back” from a detail might mean clearing the detail pane (navigating to EmptyDetailScreen) or navigating up within the detail pane’s own stack.

A custom BackHandler in MainScreen.kt manages this:

BackHandler(enabled = listDetailNavigator.canNavigateBack() || isDetailFlowActive) {
    if (isDetailFlowActive) {
        if (mainNavController.previousBackStackEntry == null) { // At the root of detail flow
            // Navigate mainNavController to EmptyDetailScreenDestination
            mainNavController.navigate(EmptyDetailScreenDestination.route) {
                popUpTo(mainNavController.graph.findStartDestination().id) { inclusive = true }
                launchSingleTop = true
            }
        } else { // Deeper in detail flow
            mainNavController.popBackStack()
        }
    } else if (listDetailNavigator.canNavigateBack()) { // For scaffold-level back
        scope.launch { listDetailNavigator.navigateBack() }
    }
    // Else, system back handles it (e.g., closing app from list)
}
The Outcome: A Seamlessly Adaptive Experience

Upnext: TV Series Manager: The list and detail are displayed side-by-side thanks to the NavigableListDetailPaneLayout

With these pieces in place, Upnext now gracefully transitions from a single-pane experience on phones to a rich, informative two-pane layout on tablets and other large-screen devices. The NavigableListDetailPaneScaffold, combined with a dedicated NavHost in the detail pane, provides a robust and flexible foundation.

Upnext’s UI improved thanks to the NavigableListDetailPaneScaffold — the list on the left (secondary) and the detail on the right (primary)

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Réussir une refonte UI modulaire : architecture, design system et screenshots tests

Dans cette conférence, nous partagerons notre retour d’expérience sur la refonte graphique de nos applications Android avec Jetpack Compose.
Watch Video

Réussir une refonte UI modulaire : architecture, design system et screenshots tests

Julien Emery & Damien Baronnet
Android Developer & Tech Lead Android
Coyote

Réussir une refonte UI modulaire : architecture, design system et screenshots tests

Julien Emery & Da ...
Android Developer & ...
Coyote

Réussir une refonte UI modulaire : architecture, design system et screenshots tests

Julien Emery & ...
Android Developer & Tech ...
Coyote

Jobs

Key Takeaways:

  • Embrace NavigableListDetailPaneScaffold: It’s the modern solution for list-detail layouts in Material 3.
  • Decouple Navigation: Use the scaffold’s navigator (rememberSupportingPaneScaffoldNavigator) for pane visibility and a separate NavController for content navigation within your panes, especially for complex detail flows.
  • Think in Roles and States: ThreePaneScaffoldRole and custom states like isDetailFlowActive were crucial for Upnext’s adaptive logic.
  • Master the LaunchedEffect and BackHandler: These are vital for orchestrating pane transitions and intuitive back navigation. Building adaptive UIs can seem daunting, but Jetpack Compose and its Material 3 adaptive components provide powerful, well-thought-out tools. By understanding how these components work together, you can create apps that truly delight users on any device.

Happy coding!

This article was previously published on proandroiddev.com.

Menu