
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:
- 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 (Dashboard, Search, Explore, etc.).
- 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:
- 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.
- listPane: A composable lambda where we place our list content. In Upnext, this is where DashboardScreen, SearchScreen, and so on, reside. Crucially, these list screens are given a way to trigger navigation within the detailPane.
- 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).
- ShowDetailScreen, ShowSeasonsScreen, 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
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.