ListDetailPaneScaffold: the back button is the whole reason it exists
ListDetailPaneScaffold adapts a list and detail across window sizes, but its real value is the navigator — which makes back pop the detail on compact and exit the screen on expanded, from one implementation.

Day 31 of 100. Yesterday named list-detail as the most common canonical layout. Today: the implementation, and why the interesting part isn't the layout.
The symptom
The hand-rolled version, which looks finished:
var selectedId by rememberSaveable { mutableStateOf<String?>(null) }
if (widthClass == COMPACT) {
if (selectedId == null) ItemList(onSelect = { selectedId = it })
else ItemDetail(selectedId!!)
} else {
Row {
ItemList(Modifier.weight(1f), onSelect = { selectedId = it })
ItemDetail(selectedId, Modifier.weight(2f))
}
}
It renders correctly at both sizes. Then you press back on a phone with an item open and the app exits, because as far as the navigation system is concerned nothing was pushed.
Add a BackHandler and the next problem arrives: on a tablet, back now clears the
selection instead of leaving the screen, so the user is stuck on a screen with an empty
pane and has to press back twice. Then someone rotates a phone with a detail open, and
the layout switches to two panes mid-gesture with a selection that was meant to be a
destination.
Why the obvious fix fails
The obvious fix is conditional back handling:
BackHandler(enabled = widthClass == COMPACT && selectedId != null) {
selectedId = null
}
This handles the two cases you thought of. It doesn't handle the transition between
them — unfold a device with a detail open and widthClass changes while selectedId
stays set, which is fine. Fold it back and the detail is showing with no back entry,
because the BackHandler was disabled when the push conceptually happened.
Every additional case is another condition, and the conditions interact. This is the week that the scaffold exists to save.

The actual mechanism
The scaffold splits into two objects with clean jobs: a navigator holding where you are, and a scaffold rendering it.
@Composable
fun MailScreen(mail: List<Mail>) {
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
BackHandler(enabled = navigator.canNavigateBack()) {
scope.launch { navigator.navigateBack() }
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
MailList(mail, onSelect = { id ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, id) }
})
}
},
detailPane = {
AnimatedPane {
navigator.currentDestination?.contentKey
?.let { id -> MailDetail(mail.first { it.id == id }) }
?: EmptyDetailPlaceholder()
}
},
)
}
canNavigateBack() is the piece doing the work. It knows how many panes are currently
visible, so:
- Compact, detail open → true. Back pops to the list.
- Expanded, both visible → false. Back falls through to the nav host and leaves the screen.
- Fold mid-session → recomputed, because the navigator reads the same adaptive info that drove the layout.
One implementation, correct at every size, and correct across transitions between sizes.
Three roles, not two
Despite the name, the scaffold has three pane roles: List, Detail, and Extra. The
third is for an inspector alongside an open detail — a message, its thread, and contact
info — and it appears only when the window is wide enough for three.
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = { AnimatedPane { … } },
detailPane = { AnimatedPane { … } },
extraPane = { AnimatedPane { ContactCard(…) } },
)
You don't decide which are visible; scaffoldValue does, from the directive. Each role's
value is Expanded, Hidden or Levitated, and the scaffold hides the panes that don't
fit — pushing the list off on a narrow window when detail is open, for instance.
Customising the directive
scaffoldDirective carries the layout rules — how many panes, the gutter sizes, where
the hinge is. The default derives from currentWindowAdaptiveInfo(), and you can adjust
it without reimplementing anything:
val navigator = rememberListDetailPaneScaffoldNavigator<String>(
scaffoldDirective = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo())
.copy(horizontalPartitionSpacerSize = 24.dp),
)
This is also where hinge-awareness comes from for free: the default directive reads the
posture and places the pane split at the fold rather than near it, which is Day 33's
subject and something a hand-rolled Row will never do.
The state that must survive
Two details that only show up on real devices.
The selection must be saveable. The navigator's content key goes through the same
bundle machinery as Day 14, so use an id — a String or Int — not the domain object.
Passing a whole Mail works until process death, and then it doesn't.
Detail state is per-selection. A scroll position or a half-typed reply belongs to that item. Key it:
detailPane = {
AnimatedPane {
val id = navigator.currentDestination?.contentKey ?: return@AnimatedPane
key(id) { MailDetail(id) } // fresh state per message
}
}
Without the key, switching messages on a tablet keeps the previous message's scroll
position — Day 8's positional identity, arriving in an adaptive layout.
How to prove it
The back behaviour is the thing to test, and it's the thing hand-rolled versions get wrong:
@Test fun backPopsDetailOnCompact() = runComposeUiTest {
setContent { CompactWindow { MailScreen(sampleMail) } }
onNodeWithText("Subject 1").performClick()
onNodeWithTag("detail").assertExists()
Espresso.pressBack()
onNodeWithTag("list").assertExists()
}
@Test fun backExitsOnExpanded() { … }
Same composable, two window sizes, opposite expectations — both passing is the signal the navigator is doing its job.
On device, the check that finds the remaining bugs is folding with a detail open. A hand-rolled version usually survives rotation and fails a fold, because rotation preserves the back stack and folding doesn't change it.
Integrating with your nav host
The scaffold is not a replacement for navigation — it's what one destination renders. The usual shape is a single route that owns both panes:
NavHost(navController, startDestination = "mail") {
composable("mail") { MailScreen(mail) } // scaffold lives INSIDE
composable("settings") { SettingsScreen() }
}
The mistake is giving list and detail separate routes and trying to show two destinations at once. Nav hosts render one destination at a time by design, so that fight ends with a custom back stack and no benefit — the scaffold already handles the two-panes-one-place model, which is exactly what a list-detail screen is.
Deep links still work, and they're worth wiring: a link to a specific message should open
the screen with that item pre-selected, which is one navigateTo in a LaunchedEffect
keyed on the deep-link argument. On a tablet the user lands on both panes; on a phone
they land on the detail with a working back — again from the same code.
What this generalizes to
The lesson is that adaptive layout is a navigation problem wearing a layout costume. Making two panes appear side by side is trivial. Deciding what "go back" means when the same content is sometimes a destination and sometimes a region is not, and it's where the week goes.
That's why the library ships a navigator rather than just a container. Any time an adaptive design changes whether something is a place or a part of a place, expect the hard part to be the transitions, not the rendering.
Tomorrow, Day 32: SupportingPaneScaffold — the same machinery for content that supports
rather than details.
Day 31 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: List-detail layout.