Demo-settings sheet — DemoScaffold v2¶
Intent: "I want my 3D / AR scene to fill the screen, with controls tucked away in a Material 3 bottom sheet."
DemoScaffold v2 (shipped in PR #1169 under issue #1154) is the shared scaffold every demo in samples/android-demo uses. It renders the 3D scene full-screen under the top bar, with a Tune FAB pinned bottom-right that opens a ModalBottomSheet containing the controls.
This recipe describes the contract so you can use the same pattern in your own app.
API¶
@Composable
fun DemoScaffold(
title: String,
onBack: () -> Unit,
controls: (@Composable ColumnScope.() -> Unit)? = null,
bottomOverlay: (@Composable DemoBottomOverlayScope.() -> Unit)? = null,
scene: @Composable BoxScope.() -> Unit,
)
title— shown in the top app bar.onBack— back navigation. The top app bar surfaces a back arrow.controls— optional slot for the demo's controls. Rendered inside a vertically-scrollingColumnso existing v1 side-panelcontrols = { ... }blocks port unchanged.null⇒ no FAB, scene fills the whole viewport.bottomOverlay— optional slot for a floating bottom banner / status pill / answer card. See Bottom overlays — put them here, never at a bareAlignment.BottomCenterinsidescene.scene— the trailing-lambda slot for the 3D / AR scene. Receives aBoxScope.
Bottom overlays¶
The Tune FAB is scaffold chrome pinned bottom-end, and whether it exists at
all depends on controls. An overlay the demo places itself at
Alignment.BottomCenter therefore has no way to know whether — or by how much —
it must get out of the way, and a long enough status string simply disappears
under the FAB. Pixel 9 device QA found exactly that, on several demos at once
(#2779).
Put the overlay in the bottomOverlay slot instead. Its receiver,
DemoBottomOverlayScope, carries the one number needed:
DemoScaffold(
title = stringResource(R.string.demo_my_title),
onBack = onBack,
controls = { /* … */ }, // ← decides whether a FAB exists at all
bottomOverlay = {
// Full-width card / banner: only its end edge can reach the FAB, so
// only the end edge is inset.
Surface(modifier = Modifier.fillMaxWidth().padding(end = settingsFabReservedSpace)) {
Text(answer)
}
},
) { /* scene */ }
For a centred, content-width pill, do not reach for a symmetric inset. The end-only inset is still the right one — you centre inside what is left of the row, not inside the whole screen:
bottomOverlay = {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(end = settingsFabReservedSpace), // end only — one corner is occupied
contentAlignment = Alignment.Center,
) {
Text(
text = status,
modifier = Modifier
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(24.dp))
.padding(horizontal = 24.dp, vertical = 12.dp),
)
}
}
This recipe prescribed padding(horizontal = settingsFabReservedSpace) until
3229, on the reasoning that a centred element grows outwards from the middle so¶
both sides have to be reserved. The reserve is then spent twice to protect one
corner, which was affordable while it was a flat constant and stopped being
affordable once it started tracking the real cluster: measured on a 411 dp screen
with ar-measure's first-launch peek header, the symmetric form left the pill
73 dp — narrower than the word "measuring" — where the end-only form leaves
242 dp. The pill still reads as centred, because the band it centres in is the
band visibly free of chrome.
In the demo app you rarely write either by hand: DemoStatusBanner is this
idiom, already applied.
settingsFabReservedSpace is the measured width of the Settings cluster,
floored at SETTINGS_FAB_RESERVED_SPACE (104 dp), when the demo passes
controls, and 0.dp when it does not. It is measured rather than assumed
because the widest thing in that corner is the peek chip, and a chip is text —
its width follows the font scale, the locale and the demo's own peekHeader,
none of which a constant can anticipate. It is resolved once, scaffold-side, from the same condition that
composes the FAB — so a demo whose controls are themselves conditional
(controls = if (DemoSettings.qaMode) { … } else null) gets the right inset for
free, with no duplicated condition to drift out of sync.
Use in your own demo¶
@Composable
fun MyDemo(onBack: () -> Unit) {
var iblIntensity by remember { mutableFloatStateOf(5_000f) }
var spinScene by remember { mutableStateOf(true) }
DemoScaffold(
title = stringResource(R.string.demo_my_title),
onBack = onBack,
controls = {
// Same Column scope you'd use in any settings sheet.
Text("IBL intensity: ${iblIntensity.toInt()} lux",
style = MaterialTheme.typography.labelLarge)
Slider(
value = iblIntensity,
onValueChange = { iblIntensity = it },
valueRange = 0f..10_000f,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Spin scene", style = MaterialTheme.typography.bodyMedium)
Switch(checked = spinScene, onCheckedChange = { spinScene = it })
}
},
) {
// BoxScope — full-screen scene.
SceneView(modifier = Modifier.fillMaxSize() /* ... */)
}
}
Gestures¶
- Tap FAB → opens the sheet at its partial detent.
- Tap peek chip ("Settings", floats above the FAB when the sheet is closed) → also opens the sheet. The chip exists because users couldn't discover the FAB on first launch (issue #951).
- Long-press peek chip → toggles
DemoSettings.qaModefor deterministic screenshot captures. Moved here from the top app bar title in v2 so the title carries the demo name verbatim. - Drag handle / outside tap / back gesture → dismiss the sheet.
- AR scenes — opening the sheet does NOT pause the underlying
ARSceneView. The sheet sits on top of the live AR feed; ARCore keeps tracking 6DOF underneath.
Picker pattern (Stage 2 of #1152)¶
A common pattern in the sample app: a horizontal chip row in the controls sheet picks between bundled / streamed assets. The OrbitalARDemo / ModelViewerDemo / AnimationPhysicsDemo / MaterialsDemo / ARPlacementDemo / ARInstantPlacementDemo all use it:
DemoScaffold(
title = stringResource(R.string.demo_my_title),
onBack = onBack,
controls = {
Text("Subject", style = MaterialTheme.typography.labelLarge)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = selectedSlug == null,
onClick = { selectedSlug = null },
label = { Text("Bundled") },
)
SampleAssets.byCategory["ar_placement"].orEmpty().forEach { slug ->
FilterChip(
selected = selectedSlug?.uid == slug.uid,
onClick = { selectedSlug = slug },
label = { Text(slug.displayName) },
)
}
}
},
) {
SceneView(modifier = Modifier.fillMaxSize() /* ... */)
}
See Sketchfab streaming for the asset side.
State preservation¶
The sheet state (SheetValue.Expanded vs Hidden) is rememberSaveable so it survives configuration changes (rotation, dark-mode flip). Your controls are inside a regular Column so any remember / rememberSaveable state inside them is also preserved.
Discoverability — peek chip first launch¶
The peek chip ("Settings" pill above the FAB) is shown only while the sheet is hidden. It exists because pre-v2 users had no idea that the FAB opened controls — first-time use telemetry (issue #951) showed a 25 % drop-off where users back-arrowed out of a demo because they didn't realize there were settings to discover.
See also¶
DemoScaffold.kt— implementation.- Sketchfab streaming — pairs naturally with the picker pattern above.
- Material 3
ModalBottomSheetreference — https://developer.android.com/jetpack/compose/components/bottom-sheets.