Skip to content

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.
  • controlsoptional slot for the demo's controls. Rendered inside a vertically-scrolling Column so existing v1 side-panel controls = { ... } blocks port unchanged. null ⇒ no FAB, scene fills the whole viewport.
  • bottomOverlayoptional slot for a floating bottom banner / status pill / answer card. See Bottom overlays — put them here, never at a bare Alignment.BottomCenter inside scene.
  • scene — the trailing-lambda slot for the 3D / AR scene. Receives a BoxScope.

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.qaMode for 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