Skip to content

Add an adaptive camera layout solver - #582

Open
temcguir wants to merge 35 commits into
mainfrom
temcguir/adaptive-layout-solver
Open

temcguir wants to merge 35 commits into
mainfrom
temcguir/adaptive-layout-solver

Conversation

@temcguir

@temcguir temcguir commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Stacked PR (2/3): Based on temcguir/edge-to-edge (#580).

image
  • Introduces CameraLayoutSolver (CameraLayoutSolver.kt, CameraLayoutDefaults.kt, CameraLayoutTypes.kt) in :ui:components:capture, a pure, stateless layout solver that computes vertical coordinates for the camera viewfinder (3:4, 1:1, 9:16) and the five portrait control rows (TopBar, ZoomBar, CaptureRow, ModeSwitcher, BottomToolbar):
    • Keeps the control stack fixed across aspect-ratio switches on a given window (CameraWindow).
    • Avoids viewfinder top/bottom edges bisecting interactive control rows (>= 4.0.dp clearance).
    • Scales inter-row gaps and bottom padding smoothly across tall, standard, and compact portrait displays, requesting full immersive mode (requiresImmersive) only on compact ~16:9 screens where a 9:16 viewfinder cannot clear a persistent navigation bar.
    • Prunes partial row stacks that leave a movable frame (3:4 or 1:1) with no collision-free position so supported windows do not exhaust the evaluation budget, and stops the zero-drop repair sweep once the primary frame is flush with the top bar.
    • Exposes LocalCameraLayoutSolution for child components to read the solved geometry.

Testing

  • CameraLayoutSolverGoldenTest & CameraLayoutSolverPropertyTest: Golden and property-based unit test suites covering standard, compact, 3-button navigation, and foldable display profiles, verifying zero bisections, aspect-ratio invariance of control coordinates, and bounded candidate evaluations.

Preview and PostCapture now hide the status bar; the navigation bar is
deliberately kept visible everywhere. Settings, Permissions, any unknown
route, and every destination while in multi-window show all bars.

Compose's inset APIs are visibility dependent: WindowInsets.statusBars,
navigationBars, systemBars and safeDrawing all collapse to zero while the
corresponding bar is hidden, so simply hiding a bar shifts the layout. The
capture surfaces therefore switch to the *IgnoringVisibility variants, which
keep reporting a bar's size regardless of whether it is currently shown,
hidden, or transiently revealed. With those in place the layout no longer
moves when bar visibility changes, which is what makes the hide/show
sequencing across navigation transitions a non-issue.

Details:

* New SystemBarsPolicy.kt owns bar visibility for the whole app. It maps a
  navigation route to a policy, applies it idempotently (diffing against the
  real window state before writing), re-asserts on every resume because the
  system can reset bars underneath us, and clamps to "show everything" in
  multi-window. It lives above the NavHost on purpose: during a navigation
  transition both destinations are composed, so a per-screen effect would let
  the outgoing screen's cleanup run last and clobber the incoming screen's
  request. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE is installed only while the
  status bar is hidden and reset to BEHAVIOR_DEFAULT otherwise, so it cannot
  steal edge swipes from scrollable screens.

* Status bar icon contrast adapts dynamically: when the status bar is visible
  on non-capture surfaces (such as Settings), isAppearanceLightStatusBars matches
  the theme contrast (dark icons on light theme, light icons on dark theme).
  On capture surfaces, transiently revealed icons remain light over the black
  viewfinder.

* Top real estate utilization: the top indicator controls are housed in a top
  bar Box sized to accommodate the cutout or status bar height (at least 48dp),
  occupying the top cutout/status bar band with horizontal cutout insets. This
  reclaims vertical viewfinder space rather than pushing the controls down into
  a void below the status bar.

* Intentional visual change: enableEdgeToEdge() now uses explicit dark (light
  content) transparent bar styles. The default "auto" styles pick their
  light/dark variant from the system-wide configuration rather than the app's
  own theme, which produced dark, effectively invisible status bar icons over
  the black viewfinder on a light system. This also drops the translucent
  scrim "auto" applies below API 29. Navigation bar contrast enforcement is
  disabled for the same reason.

* Bug fix: PostCapture previously applied both the Scaffold's content padding
  and safeContentPadding(). Scaffold does not consume the insets it reports,
  so the controls were inset by roughly twice the system bar heights. The
  Scaffold padding is dropped and the remaining inset excludes gesture insets,
  which had been pulling the left and right buttons inward.

* The Permissions screen had no inset handling at all and drew under both
  bars; it now applies safeDrawing padding while keeping its background
  edge to edge.

Tests: a plain JUnit table test pins the route-to-policy mapping, including
the route patterns with unresolved query-argument placeholders that
navigation actually reports. A Robolectric test dispatches synthetic insets
and asserts the capture controls keep identical bounds when bars are hidden;
it includes a guard case that fails if the environment stops delivering
insets into Compose, and both position assertions were confirmed to fail when
the modifiers are reverted to the visibility-dependent APIs.
…space

- Size the capture top bar from max(displayCutout.top, minInteractiveTouchTarget) instead of statusBarsIgnoringVisibility so the top bar sits in the hidden status bar region rather than reserving an oversized top band.
- Add CutoutAwareRow with LookaheadScope and RowScope support so top bar indicators shift horizontally past any intersecting corner or center display cutout while maintaining stable positions during AnimatedVisibility expand/shrink transitions.
- Re-assert system bar visibility on window focus gain and call WindowInsetsControllerCompat.hide/show directly to avoid stale root inset visibility checks on activity launch.
Restructures the vertical capture controls so the stack matches the
design spec:

 - Adds a defaulted captureModeCarousel slot to PreviewLayout,
   rendered in a Box between the shutter row and the lower controls
   row. The slot height of 32dp is reserved unconditionally, so a
   future occupant can appear there without shifting the shutter row
   or any other control.
 - Gives the shutter row a hard 86dp height (replacing the two 120dp
   child boxes and the intrinsic-max sizing) and the lower controls
   row a hard 64dp height, so neither can grow with its content.
 - Anchors the zoom bar to the 3:4 viewfinder bottom baseline in
   standard portrait orientation (matching reference camera app behavior).
   This places the zoom bar cleanly inside the 3:4 preview, keeps it
   locked at the same physical screen position across aspect ratios
   (3:4 and 9:16), and adapts dynamically across device screen heights.
 - Replaces static bottom padding with a navigation-bar-aware clearance
   (56dp target clearance, 8dp minimum safety margin) that preserves a
   consistent thumb resting position for the lower controls across both
   gesture navigation and 3-button navigation.
 - Extracts every dimension into named constants.
 - Wraps the stack in BoxWithConstraints so that on short screens the
   gaps and bottom padding compress while the fixed element heights
   stay intact.

As a result of the new spacing the shutter ring centre sits ~43dp
higher than before, matching the design spec.

Adds CaptureLayoutPositionTest, a JVM Compose test that renders the
layout with an empty and a populated middle slot and asserts the
capture button lands at an identical vertical position in both cases,
guarding the slot reservation against regressions. The tests pin the
window size with Robolectric qualifiers so that both the regular and
the compressed spacing are exercised, and assert the concrete
expected positions; the short-screen test also asserts the top of the
stack is not clipped.
- Introduce CameraSystemBarsEffect as a reusable, drop-in composable in
  :ui:components:capture to encapsulate edge-to-edge system bar management
  for camera capture experiences.
- Refactor SystemBarsPolicy in :app to delegate to CameraSystemBarsEffect.
- Add vertical clearance between the zoom bar and the bottom edge of the
  3:4 viewfinder so the zoom pill has comfortable breathing room above the
  rounded preview boundary.
- Introduce PreviewPortraitDevices multi-preview annotation in
  :ui:components:capture covering standard phone, compact (<600dp) phone,
  foldable unfolded portrait, and tablet portrait profiles.
- Apply PreviewPortraitDevices to CaptureLayoutPreview and ContentScreenPreview.
- Update CaptureLayoutPreview viewfinder placeholder to scale with 3:4 aspect
  ratio instead of a fixed 600dp height.
Dynamically calculate the available space below the 3:4 viewfinder
and scale the control stack gaps and bottom clearance linearly
between their minimum and maximum targets. This ensures the shutter row
never overlaps the live preview on shorter portrait devices while
smoothly expanding to the full design spec spacing on taller screens.
Introduces a pure, framework-independent solver that places a stack of
camera control rows and a set of viewfinder frames against a device
window, guaranteeing that no viewfinder edge overlaps a control and that
the control stack stays put when the user switches aspect ratio.

The solver has no Compose or Android dependency and takes no UI state.
Control rows are opaque obstacles identified only by id, so it has no
knowledge of what any row contains, and content visibility is
deliberately not an input: routing that through the solver is how a
shutter button ends up shifting under the user's thumb on a mode change.
Being framework-free also means the whole evaluation set runs on the JVM
in milliseconds and belongs in ordinary presubmit.

This commit is the solver and its tests only. Nothing calls it yet.

Two deliberate corrections to the algorithm this was ported from:

* The compact-toolbar band pinned the stack lift to zero on the premise
  that compacting the bottom toolbar would absorb the shortfall, but the
  compacted height is floored and nothing checked whether the band
  actually offered that much room. When it did not, an overlap was
  arithmetically guaranteed and the search's only escape route had been
  disabled. Sweeping 3,780 window geometries found 42 supported shapes
  that always overlapped, at ordinary phone aspect ratios. Entering the
  band only when the compacted toolbar fits resolves all 42, and the
  band is never load-bearing for collision avoidance elsewhere.

* The repair search is a cartesian sweep whose last-resort rung swept
  bottom padding without bounding it by the height of the stack being
  placed, costing up to 677k candidate evaluations for a single window.
  The sweep now stops once the stack can no longer be placed, which is
  exact rather than approximate because that failure mode is monotone in
  padding, and a hard evaluation budget bounds pathological geometry.
  Worst-case cost on supported windows is now 1,055 evaluations.

Testing:

* A golden fixture pins all 66 device, navigation mode and aspect ratio
  combinations against the reference implementation. Both corrections
  above were verified to leave every one of those cases byte-identical,
  so parity and the fixes hold simultaneously.

* Property tests sweep 3,780 synthetic window geometries, split by what
  is actually promised rather than holding everything to one bar. The
  1,568 shapes between 16:9 and 21:9 must be collision free; the 2,212
  squarer or taller shapes are tablets, foldable inner displays and
  split-screen panes, which get a different template and need only
  degrade gracefully. Invariants that hold by construction are still
  asserted over all 3,780.
…get to 5,000

- Reduce COLLISION_TEST_SLACK from 0.12 dp to float representation tolerance (1e-4f) so the solver enforces the full 4.0 dp minimum clearance rather than stopping one 0.1 dp quantum early on 3-button navigation configurations.
- Lower EVALUATION_BUDGET from 20,000 (~25 ms) to 5,000 (~6 ms) to bound worst-case out-of-scope geometry evaluation within a single frame while retaining nearly 5x headroom above the worst in-scope handheld window (1,055 evaluations).
- Update golden fixture and property test clearance floor accordingly.
…nputs

Replaces hardcoded compact-band and sweep constants in CameraLayoutSolver with geometric formulas computed from CameraLayoutSpec and CameraWindow inputs:
- minToolbarHeight derived from minInteractiveTouchTarget - minControlClearance
- compactBandMinPadding derived from navigation bar vs. gesture inset comparison
- compactBandMin and compactBandMax derived from toolbar heights, clearance, and bottom padding
- repair sweep minimum gap read from ControlRow.gapAbove.min

@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 introduces a custom camera layout solver (CameraLayoutSolver) along with its default specifications, supporting types, and comprehensive golden and property-based tests to verify layout invariants across various device geometries. The review feedback focuses heavily on performance optimizations in the solver's hot paths—specifically recommending the elimination of intermediate list, iterator, and lambda allocations during candidate evaluation and repair sweeps. Additionally, the feedback notes style guide violations regarding missing @RunWith(JUnit4::class) annotations on the test classes.

@temcguir
temcguir marked this pull request as draft September 24, 2026 22:25
- Use rememberUpdatedState(isDarkTheme) in SystemBarsPolicyEffect's cleanup DisposableEffect so toggling dark mode does not transiently restore the status bar.
- Annotate SystemBarsPolicyTest with @RunWith(JUnit4::class) and switch assertions to Google Truth.
- Simplify cutout inset state reads and guard platform DisplayCutout access with Build.VERSION.SDK_INT >= P in CutoutAwareRow.
- Add class-level KDoc to CutoutAwareRowTest and @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") to PostCaptureLayout.
…lver and defer UI changes to adaptive-capture-layout
@temcguir
temcguir changed the base branch from temcguir/bottom-controls-layout to temcguir/edge-to-edge September 28, 2026 20:50
The reference layout for a 360 x 740 window was outside the solver's
feasible set. These changes make it the solution in both gesture and
3-button navigation.

- Anchor the bottom row. The preferred padding keeps the toolbar's lower
  edge at most 54 dp above the window bottom, so a 48 dp navigation bar
  reduces the padding instead of raising the controls
  (bottomRowAnchor). Above a navigation bar taller than the gesture
  zone, the padding floor is minControlClearance.
- Let a 9:16 frame that cannot clear the stack from above enclose it,
  passing behind the fixed top-start control with at least 1 dp to the
  button's visible top (topStartVisibleInset, topStartMinEdgeGap),
  before falling back to the top of the window.
- Measure the top-weighting cap to the bottom of the window, since the
  navigation bar reads as black.
- Compact the toolbar only when the full-height toolbar does not lay
  out cleanly.
- Bound how far a collision raises the stack. The repair takes the
  top-ranked candidate no more than INCIDENTAL_LIFT_BUDGET above the
  lowest collision-free padding the search found. The candidates
  evaluated are unchanged.
- Regenerate the golden fixture (s9, pixel3, and the 3-button cases of
  pixel9, pixel9_xl, pixel8, pixel7 and pixel_fold) and record the
  departures from the prototype in its header.
- Add window360x740MatchesTheDesignReference, and measure the property
  test's lower band to the bottom of the window.
The bottom inset of a single device varies: 48 dp for 3-button
navigation, 24 dp for gesture navigation from Android 13 (16 dp on
Android 10 to 12L), and zero when no bar is reported. The bottom gesture
zone varies with it: 32 dp for gesture navigation and the full bar
height for 3-button navigation. The solver chose a different layout
when either value changed by a few dp, and the tests passed a 24 dp
gesture zone that devices do not report.

- Make the bottom row anchor two-way. Outside an immersive window the
  preferred padding is bottomRowAnchor minus the navigation bar height,
  so a shorter bar, or none, adds the padding that a taller bar removes.
  An immersive window keeps the designed padding, capped at the anchor.
- Treat a navigation bar at least as tall as the gesture zone as
  covering it, so 3-button navigation (48 dp bar, 48 dp zone) gets the
  minControlClearance floor.
- Default CameraWindow.gestureInset to 32 dp, the gesture navigation
  value.
- Regenerate the gestureInset column of the golden fixture: 32 dp for
  gesture navigation, 48 dp for 3-button navigation, and 24 dp for the
  immersive pixel2 and compact_16_9 windows. No solution changed.
- Check the 360 x 740 design reference with every reported pair of
  bottom insets, and add layoutDoesNotMoveWithTheBottomInset. It solves
  each golden window with every pair and pins the three comparisons it
  skips because the 9:16 frame changes placement.
- Sweep the property test over reported (bar, zone) pairs instead of
  independent bar heights with a fixed 24 dp zone, and update the
  measured evaluation counts (worst supported window 938 -> 587).
Base automatically changed from temcguir/edge-to-edge to main October 5, 2026 23:30
@temcguir
temcguir marked this pull request as ready for review October 12, 2026 20:30
- Fix sweepZeroDrop early-exit condition when the 9:16 frame fits below the top bar so tall windows stop once the 3:4 frame is flush.
- Add movableFramesFit pruning in RepairSearch so partial row placements that leave no valid position for the 3:4 or 1:1 frame are skipped before evaluating gap combinations.
- Reuse a pre-sized FloatArray in minimax, return primitive IntArray from gapGrid, and split evaluate() into placeAll() and materialize() so rejected or non-improving candidates do not allocate layout maps.
@temcguir
temcguir requested a review from Kimblebee October 17, 2026 01:41
The top-start control is always kept at a fixed position below the top
bar, so the viewfinder-tracking placement mode is no longer needed.
Remove the enum, the spec field and the branches that only applied to
that mode. Layouts for the remaining mode are unchanged: the golden
fixture and the evaluation-count bounds pass without regeneration.
- Declare explicit Dp types on the public CameraLayoutDefaults dimensions.
- Declare the explicit type of LocalCameraLayoutSolution.
- Remove an unused import from CameraLayoutSolver.
- Use for loops instead of forEach in the solver tests.
- Replace a not-null assertion with a null-safe check.
- Use a List for the gap grids in the generic sweep.
- Document and suppress the array lint on the solver's internal
  primitive-array buffers, which avoid boxing and per-candidate
  allocations in the search loop.
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.

2 participants