Skip to content

add adaptive posture layouts and in-hierarchy Quick Settings surfaces - #654

Draft
Kimblebee wants to merge 5 commits into
kim/adaptive/side-railsfrom
kim/adaptive/layouts
Draft

Kimblebee wants to merge 5 commits into
kim/adaptive/side-railsfrom
kim/adaptive/layouts

Conversation

@Kimblebee

@Kimblebee Kimblebee commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked PR: Based on kim/adaptive/side-rails #652

Summary

Extracts the monolithic capture layout in CaptureLayout.kt into CompactPortraitLayout and introduces four side-rail posture layouts—FoldableLayout, TabletPortraitLayout, TabletLandscapeLayout, and CompactShortLayout—sharing mode-agnostic CaptureScaffoldState, in-hierarchy Quick Settings surfaces, and scoped CaptureRtlPolicy handling. Factoring out these five layout primitives and their geometry/inset/accessibility invariants ahead of kim/adaptive/layout-orchestrator lets reviewers verify each posture's layout math and tests independently while keeping CaptureScaffoldLayout behaviorally unchanged via delegation to CompactPortraitLayout.

Key Changes

  • CaptureLayout.kt & TestTags.kt:
    • Reduces CaptureScaffoldLayout to a ~110-line compatibility adapter packing its slot lambdas into CaptureSlots and delegating to CompactPortraitLayout(..., includeStatusBarsInTopBar = false).
    • Adds COMPACT_CONTROLS_TAG = "container_capture_controls_compact".
  • CompactPortraitLayout.kt (new):
    • Houses CompactPortraitLayout, AdaptivePortraitCaptureLayout, VerticalMaterialControls, CompactTopBarBox, CompactCaptureRow, and CompactBottomToolbar, plus LocalActiveCaptureLayout and Modifier.suppressBackgroundForModal.
    • Upgrades BottomSheetScaffold state to non-deprecated rememberBottomSheetState, adds includeStatusBarsInTopBar multi-window top-bar clearance, and scopes CaptureRtlPolicy.mirrorCompactBottomBar strictly to the horizontal capture and bottom-toolbar rows.
  • SideRailCaptureLayouts.kt (new):
    • Adds FoldableLayout (0.50f rail center, 380.dp max Quick Settings card width), TabletLandscapeLayout (0.50f rail center, 20.dp rail horizontal padding, 412.dp max card width), and TabletPortraitLayout (0.70f lower thumb-zone rail center, 48.dp bottom inset, 412.dp max card width) backed by SideRailCaptureScaffold.
    • Adds QuickSettingsOverlayCardPlaceholder (TODO(#648)): an in-hierarchy floating M3 card (surfaceContainerLow, 28.dp corners, verticalScroll, BackHandler, 0.32f scrim) dynamically clearing both measured side rails by 12.dp.
  • CompactShortLayout.kt (new):
    • Adds CompactShortLayout for < 480dp x < 480dp multi-window / cover-display panes (360x360dp, 320x340dp), wrapping CaptureSlots with proportional Modifier.sizeIn and hard Layout Constraints caps (withCondensedSizeCaps / ConstrainedSlot) over SideRailCaptureScaffold (isCompactHeight = true, 8.dp condensed chrome padding, <= 320.dp card width).
  • SideRailCaptureLayoutsTest.kt & CompactShortLayoutTest.kt (new):
    • Adds 15 Robolectric Compose UI tests covering all five layouts across viewport qualifiers, RTL modes, visible/hidden statusBars insets, Quick Settings bounds, and accessibility modal pruning.

Technical Notes

  • Dynamic Rail Clearance: SideRailCaptureScaffold seeds rail widths from MinSideRailWidth + railHorizontalPadding * 2 and updates them via onSizeChanged, offsetting topStartContent / viewfinderTopEndContent by max(startRailWidth, endRailWidth) + 12.dp and anchoring the floating Quick Settings card at BottomStart under railContainerDirection (start = safeStart + startRailWidth + 12.dp, end = safeEnd + endRailWidth + 12.dp) so it always opens beside StartSideRail without overlap in both unmirrored and mirrored RTL.
  • Proportional Condensed Budget (CompactShortLayout): After subtracting live WindowInsets.systemBars.union(WindowInsets.displayCutout) and 16.dp rail vertical padding (innerRailHeight), start-rail slots cap at 0.20 + 0.45 + 0.16 = 0.81 * innerRailHeight and end-rail slots cap at 0.15 + 0.22 + 0.15 + 0.15 = 0.67 * innerRailHeight, guaranteeing collision-free, unclipped placement even at 320x340dp or 360x360dp with a 32.dp visible status bar.
  • In-Hierarchy Modal Pruning: Neither BottomSheetScaffold nor QuickSettingsOverlayCardPlaceholder spawns a Popup or Dialog window (screenFlashOverlay remains drawn last edge-to-edge above all chrome). Whenever Quick Settings or an errorDialog (tracked via both isErrorDialogShowing and onSizeChanged non-zero bounds) is active, Modifier.suppressBackgroundForModal applies focusProperties { canFocus = false } and semantics { hideFromAccessibility() } to background chrome.
  • Scoped RTL Sandwich: Container mirroring (mirrorSideRails, mirrorCompactBottomBar) is provided only to the immediate horizontal positioning container, while LocalLayoutDirection is restored to localeDirection inside every child slot wrapper.

Pre-Existing Bugs Fixed

  • TalkBack & keyboard focus leaked into occluded controls behind in-hierarchy modals: CaptureLayout.kt previously left background camera controls focusable and exposed to accessibility traversal while BottomSheetScaffold (Quick Settings) or in-hierarchy CameraErrorDialog was open. Fixed via Modifier.suppressBackgroundForModal and automatic errorDialog placement detection.
  • QUICK_SETTINGS_SCRIM & drag handle semantics/precedence: Guarded CompactPortraitLayout's scrim with !isErrorModalActive so it no longer overlays an active CameraErrorDialog, added Role.Button, contentDescription, and traversalIndex = 1f to the scrim, added .minimumInteractiveComponentSize() to QUICK_SETTINGS_DRAG_HANDLE, and replaced deprecated rememberStandardBottomSheetState with rememberBottomSheetState.

Not Included Yet

  • Comes later in this series:
    • Choosing a layout at runtime (kim/adaptive/layout-orchestrator): Adds the 7-mode CameraLayoutMode hysteresis classifier, preserves the viewfinder via movableContentOf, and wires live CaptureScaffold controls across all five layouts.
    • The real Quick Settings card (#648): The floating card is a private placeholder (QuickSettingsOverlayCardPlaceholder) until #648 lands.
    • Vertical control variants (#635, #649, #622, #644): Vertical ToggleSwitch, VerticalIndicatorPill, Tooltip, and ZoomBar RTL polish land in their dedicated Wave 1 leaf PRs.
    • Phone landscape counter-rotation & upright gravity (kim/adaptive/layout-rotation, kim/adaptive/viewfinder-rotation, kim/adaptive/content-gravity): Handled in the post-join branches.
  • Open follow-ups:
    • Layouts without a UX spec: Foldable and tablet layouts follow official design specs. Four window shapes do not, and what ships for them is an unspec'd placeholder pending design input:
      • Compact Slim (narrow one-third split or slim cover screen): reuses the compact bar, with tight horizontal width.
      • Compact Tall (compact width at tablet height, e.g. a narrow split on a tablet): reuses the compact bar, with extra vertical letterbox space.
      • Compact Short (50/50 phone split or flip cover screen): developer placeholder side rails.
      • Compact Short Slim (smallest quarter split or mini panes): developer placeholder side rails.
    • Quick Settings card placement: On side-rail layouts the floating card is anchored to the bottom of the window today as a placeholder, not the intended placement. It will be aligned to the Figma spec; so far the only spec'd placement is the foldable, where the card is vertically centered against the left edge of the window.
    • Right-to-left behavior is not finalized: The current default is interim: side rails stay on their physical sides in right-to-left locales. Two questions remain open: how items are ordered inside vertical components (such as zoom stops and status indicators), and whether the whole layout should mirror so the rails swap sides. Placement polish is follow-up work.

Testing

  • SideRailCaptureLayoutsTest: Verifies full-bleed viewfinder centering (LocalCameraLayoutSolution == null), FoldableLayout (852x883dp) 0.50f rails, TabletPortraitLayout (800x1280dp) 0.70f lower thumb zone, TabletLandscapeLayout (1280x800dp) & CompactPortraitLayout (412x915dp, 360x500dp) unmirrored/mirrored CaptureRtlPolicy behavior, rail-cleared QuickSettingsOverlayCardPlaceholder non-overlap and !isPopup()/!isDialog() hierarchy, synthetic visible/hidden statusBars insets, error modal precedence, and HideFromAccessibility background pruning.
  • CompactShortLayoutTest: Verifies zero clipping and strict vertical rail ordering at 360x360dp, 320x340dp (COMPACT_SHORT_SLIM), 470x400dp (both RTL policies), 360x360dp with a 32dp visible multi-window status bar, and condensed <= 320dp Quick Settings card bounds/dismissal.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the camera capture screen layout by decomposing the monolithic CaptureLayout.kt into modular, posture-aware layouts: CompactPortraitLayout, CompactShortLayout, and SideRailCaptureLayouts (covering foldables and tablets), along with comprehensive unit tests. The feedback focuses on restricting the visibility of LocalActiveCaptureLayout to internal, adding missing KDoc documentation for several new non-private components, and optimizing the layout tree by removing redundant wrapping Box nodes. Additionally, there are recommendations to address potential layout lag from dynamic rail measurements and to improve accessibility by ensuring consistent screen reader traversal indices and correctly consuming accessibility clicks on the Quick Settings card.

Comment on lines +104 to +105
val LocalActiveCaptureLayout: ProvidableCompositionLocal<String> =
compositionLocalOf { "CompactPortraitLayout" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The LocalActiveCaptureLayout CompositionLocal is currently public. To adhere to the repository style guide, it should use the most restrictive visibility modifier possible (internal in this case) since it is only used within the capture UI module.

Suggested change
val LocalActiveCaptureLayout: ProvidableCompositionLocal<String> =
compositionLocalOf { "CompactPortraitLayout" }
internal val LocalActiveCaptureLayout: ProvidableCompositionLocal<String> =
compositionLocalOf { "CompactPortraitLayout" }
References
  1. Ensure all new functions, properties, and classes use the most restrictive visibility modifier possible (e.g., private, internal) while still allowing necessary access. Avoid public visibility unless explicitly required for external API exposure. (link)

val LocalActiveCaptureLayout: ProvidableCompositionLocal<String> =
compositionLocalOf { "CompactPortraitLayout" }

internal object CompactPortraitTokens {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Please add KDoc documentation for the CompactPortraitTokens object to adhere to the repository style guide, which requires all non-private members to be documented.

References
  1. All non-private classes, functions, and composables must have KDoc documentation. (link)

internal const val QUICK_SETTINGS_OVERLAY_CARD_PLACEHOLDER_TAG = "card_quick_settings_overlay"

@Immutable
internal data class SideRailPostureConfig(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Please add KDoc documentation for the SideRailPostureConfig data class to adhere to the repository style guide, which requires all non-private members to be documented.

References
  1. All non-private classes, functions, and composables must have KDoc documentation. (link)

val clearRailsForQuickSettings: Boolean = true
)

internal object SideRailTokens {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Please add KDoc documentation for the SideRailTokens object to adhere to the repository style guide, which requires all non-private members to be documented.

References
  1. All non-private classes, functions, and composables must have KDoc documentation. (link)

Comment on lines +275 to +276
var startRailWidthPx by remember(seededRailWidthPx) { mutableIntStateOf(seededRailWidthPx) }
var endRailWidthPx by remember(seededRailWidthPx) { mutableIntStateOf(seededRailWidthPx) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using onSizeChanged to dynamically measure the rail widths and feed them back into state (startRailWidthPx and endRailWidthPx) causes a 1-frame layout lag and potential visual jitter/flicker when the screen is first displayed or rotated. Since the rail widths are predictable (MinSideRailWidth + config.railHorizontalPadding * 2), consider calculating the padding directly in Dp during composition, or implement a custom single-pass Layout for the scaffold to measure and position the rails and Quick Settings card in one go.

Comment on lines +323 to +345
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.fillMaxHeight()
.onSizeChanged { startRailWidthPx = it.width }
.suppressBackgroundForModal(isModalOpen)
) {
CompositionLocalProvider(
LocalLayoutDirection provides localeDirection
) {
StartSideRail(
slots = slots,
modifier = Modifier.fillMaxHeight(),
isCompactHeight = config.isCompactHeight,
topPadding = config.railTopPadding,
bottomPadding = config.railBottomPadding,
horizontalPadding = config.railHorizontalPadding,
minSectionGap = config.railMinSectionGap,
centerVerticalFraction = config.centerVerticalFraction
)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The wrapping Box layout node is redundant here. We can remove it and apply the alignment, size, and modal suppression modifiers directly to StartSideRail to optimize the layout tree and reduce nesting overhead.

                            CompositionLocalProvider(
                                LocalLayoutDirection provides localeDirection
                            ) {
                                StartSideRail(
                                    slots = slots,
                                    modifier = Modifier
                                        .align(Alignment.CenterStart)
                                        .fillMaxHeight()
                                        .onSizeChanged { startRailWidthPx = it.width }
                                        .suppressBackgroundForModal(isModalOpen),
                                    isCompactHeight = config.isCompactHeight,
                                    topPadding = config.railTopPadding,
                                    bottomPadding = config.railBottomPadding,
                                    horizontalPadding = config.railHorizontalPadding,
                                    minSectionGap = config.railMinSectionGap,
                                    centerVerticalFraction = config.centerVerticalFraction
                                )
                            }

Comment on lines +347 to +369
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.onSizeChanged { endRailWidthPx = it.width }
.suppressBackgroundForModal(isModalOpen)
) {
CompositionLocalProvider(
LocalLayoutDirection provides localeDirection
) {
EndSideRail(
slots = slots,
modifier = Modifier.fillMaxHeight(),
isCompactHeight = config.isCompactHeight,
showElapsedTimeInRail = false,
topPadding = config.railTopPadding,
bottomPadding = config.railBottomPadding,
horizontalPadding = config.railHorizontalPadding,
minSectionGap = config.railMinSectionGap,
centerVerticalFraction = config.centerVerticalFraction
)
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The wrapping Box layout node is redundant here. We can remove it and apply the alignment, size, and modal suppression modifiers directly to EndSideRail to optimize the layout tree and reduce nesting overhead.

                            CompositionLocalProvider(
                                LocalLayoutDirection provides localeDirection
                            ) {
                                EndSideRail(
                                    slots = slots,
                                    modifier = Modifier
                                        .align(Alignment.CenterEnd)
                                        .fillMaxHeight()
                                        .onSizeChanged { endRailWidthPx = it.width }
                                        .suppressBackgroundForModal(isModalOpen),
                                    isCompactHeight = config.isCompactHeight,
                                    showElapsedTimeInRail = false,
                                    topPadding = config.railTopPadding,
                                    bottomPadding = config.railBottomPadding,
                                    horizontalPadding = config.railHorizontalPadding,
                                    minSectionGap = config.railMinSectionGap,
                                    centerVerticalFraction = config.centerVerticalFraction
                                )
                            }

modifier = Modifier
.fillMaxSize()
.background(SideRailTokens.QuickSettingsScrimColor)
.testTag(QUICK_SETTINGS_SCRIM)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure consistent screen reader traversal precedence across all posture layouts, the Quick Settings scrim in SideRailCaptureScaffold should have traversalIndex = 1f set in its semantics block, matching the implementation in CompactPortraitLayout.

                .semantics {
                    contentDescription = dismissLabel
                    traversalIndex = 1f
                }

Comment on lines +564 to +574
Surface(
modifier = Modifier
.testTag(QUICK_SETTINGS_OVERLAY_CARD_PLACEHOLDER_TAG)
.widthIn(max = maxCardWidth)
.then(heightModifier)
.pointerInput(Unit) {
detectTapGestures { /* Consume taps on card surface */ }
},
shape = RoundedCornerShape(SideRailTokens.QuickSettingsCardCornerRadius),
color = MaterialTheme.colorScheme.surfaceContainerLow,
tonalElevation = SideRailTokens.QuickSettingsCardTonalElevation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using pointerInput with detectTapGestures consumes touch events but does not stop accessibility click propagation. If a screen reader user double-taps the card background, the click action will propagate up to the clickable scrim and dismiss the Quick Settings card. Replacing this with .clickable with an empty lambda correctly consumes both touch and accessibility click actions.

Suggested change
Surface(
modifier = Modifier
.testTag(QUICK_SETTINGS_OVERLAY_CARD_PLACEHOLDER_TAG)
.widthIn(max = maxCardWidth)
.then(heightModifier)
.pointerInput(Unit) {
detectTapGestures { /* Consume taps on card surface */ }
},
shape = RoundedCornerShape(SideRailTokens.QuickSettingsCardCornerRadius),
color = MaterialTheme.colorScheme.surfaceContainerLow,
tonalElevation = SideRailTokens.QuickSettingsCardTonalElevation
Surface(
modifier = Modifier
.testTag(QUICK_SETTINGS_OVERLAY_CARD_PLACEHOLDER_TAG)
.widthIn(max = maxCardWidth)
.then(heightModifier)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {} // Consumes taps and accessibility clicks to prevent dismissal
),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant