Skip to content

Wire PreviewLayout and PreviewDisplay to CameraLayoutSolver - #583

Open
temcguir wants to merge 26 commits into
temcguir/adaptive-layout-solverfrom
temcguir/adaptive-capture-layout
Open

temcguir wants to merge 26 commits into
temcguir/adaptive-layout-solverfrom
temcguir/adaptive-capture-layout

Conversation

@temcguir

@temcguir temcguir commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Stacked PR (3/3): Based on temcguir/adaptive-layout-solver (#582).

  • Adaptive Portrait Capture Layout (CaptureLayout.kt, CaptureScreenComponents.kt):
    • Wires PreviewLayout and PreviewDisplay to CameraLayoutSolver on handheld portrait windows (AdaptivePortraitCaptureLayout), solving once per window/spec change and placing the viewfinder and control slots (TopBar, ZoomBar, CaptureRow, ModeSwitcher, BottomToolbar) at their solved coordinates.
    • Keeps topStartContent at a fixed position directly below the top bar across aspect ratios.
    • Adds viewfinderTopEndContent, anchored to the top-end corner of the viewfinder. While topStartContent is inside the viewfinder, both top slots share the same vertical origin below the top bar; when the viewfinder shifts below topStartContent (for example 1:1), it follows the viewfinder's top edge. While the two slots share rows, its width is limited to the space beside topStartContent so they never overlap.
    • PreviewDisplay reports the top edge of its visible frame through the ViewfinderTopLine alignment line, so viewfinder-relative content can track a viewfinder that fills its slot and offsets its own frame.
    • Centers mode switcher content on its solved band. Content taller than the band, such as a carousel with a larger touch target, extends into the adjacent gaps (up to the smaller gap on each side) without moving or overlapping the neighboring rows.
    • Preserves VerticalMaterialControls as a fallback for squarer windows (landscape, foldable inner displays, or short split-screen panes), compressing its spacing on short windows and reserving a captureModeCarousel slot.
    • Updates PreviewDisplay to read the solved viewfinder band (top, height) from LocalCameraLayoutSolution.
  • System Bars (CameraSystemBarsEffect.kt, SystemBarsPolicy.kt, JcaApp.kt):
    • Moves CameraSystemBarsEffect into :ui:components:capture with Activity- and Dialog-window support, re-asserting the bars on lifecycle resume and on window focus changes (LocalWindowInfo.isWindowFocused).
    • PreviewLayout reports solution.requiresImmersive through onRequiresImmersiveChange instead of changing the system bars itself. The app-level SystemBarsPolicy applies it on the preview route (HideStatusAndNavigationBars), so a single owner controls navigation bar visibility across resume and focus changes.
  • Multi-Preview & Microbenchmark (PreviewPortraitDevices.kt, :microbenchmark):
    • Adds @PreviewPortraitDevices multi-preview annotation for portrait screen sizes and CameraLayoutSolverBenchmark measuring solver execution time across standard and compact device configurations.

Testing

  • CaptureLayoutPositionTest: Robolectric Compose tests verifying slot placement, viewfinder alignment, fixed topStartContent, viewfinderTopEndContent placement (including with a self-positioning viewfinder) and overlap limits, mode switcher content sizing, and the onRequiresImmersiveChange contract across device profiles and aspect ratios.
  • CameraSystemBarsEffectTest & SystemBarsPolicyTest: system bar behavior, multi-window handling, focus re-assertion with a hidden navigation bar, and the route-to-policy mapping.

Replaces the linear-interpolation portrait control placement in CaptureLayout with AdaptivePortraitCaptureLayout, a custom Compose Layout driven by CameraLayoutSolver:
- Measures the live control row composables at runtime and reads WindowInsets and LocalMinimumInteractiveComponentSize to construct CameraWindow and CameraLayoutSpec.
- Places the Top Bar, Zoom Bar, Elapsed Time, Capture Row, Mode Switcher, Bottom Toolbar, and Viewfinder at the exact vertical bands computed by CameraLayoutSolver.
- Provides LocalCameraLayoutSolution so PreviewDisplay positions 3:4, 1:1, and 9:16 viewfinders at their solved bands while keeping the control stack stationary.
- Drives ImmersiveNavigationBarEffect when requiresImmersive is true on 16:9 and 17:9 portrait screens.
CameraLayoutSolver.solve was being called from inside the Layout measure lambda whenever a measured row height differed from the design default, which is the normal case on a real device, so the solve ran on every measure pass rather than once per window geometry change. The resulting solution was also written back into snapshot state during the layout phase, which invalidates composition from layout and can oscillate.

The solve now runs in a remember keyed on the spec and the window, and the measure pass places each control row into the band the solver already reserved for it. This also restores the ControlRow.height contract: a row reports its reserved height, not its measured content height, so showing or hiding a row's content can no longer move the rows around it.
The search cost was documented but not measured, so there was no way to
tell a real regression from a comment that had drifted. Every solve now
reports how many candidate layouts it evaluated, and the property tests
assert that number instead of only wall clock.

Wall clock alone is the wrong assertion. It moves with the machine and
the JIT, so a bound loose enough to be reliable is too loose to catch
anything. Evaluation count is deterministic and exactly proportional to
the work done, so it can be bounded tightly. The timing test is kept,
narrowed to what it is actually good for: catching a single evaluation
becoming expensive.

The measured distribution across the supported sweep of 1,568 windows:

  evaluations | windows
  ------------|--------
  1           | 897
  2 to 10     | 120
  11 to 100   | 545
  over 1000   | 6

So 57% of supported windows need no search at all, and only 6 ever pay
for a full gap sweep. The worst is 1,055 evaluations, about 1.6 ms, once
per window geometry rather than per frame.

This was written to justify a branch-and-bound optimisation of the
padding sweep. Measurement refuted it: the bound available before
evaluating a padding assumes zero frame displacement, but a layout that
needed repair has almost always displaced a frame, so the bound never
beat the incumbent and never pruned. It changed the in-scope cost by
zero evaluations. That work is dropped and only the measurement is kept.
The cost of a solve had only ever been measured on a desktop JVM. That
machine is both much faster per core and a different runtime from the
one this ships on, so the numbers could not support any claim about
device behaviour. This makes the measurement possible on real hardware.

Four window geometries are benchmarked rather than one, because how much
work a solve does is a property of the window, not of the solver. The
designed layout fits outright on 10 of the 22 device configurations in
the golden fixture and costs a single candidate evaluation; the worst
real device costs 96; the worst supported window anywhere costs 1,055.
Benchmarking one geometry would report an arbitrary point on that range.
Comparing the first two separates the cost of searching from the fixed
cost of a solve, which is the number worth knowing before optimising
either.

The androidx.benchmark Gradle plugin is deliberately not applied. The
version in the catalog reaches for an extension this AGP no longer
registers, so applying it fails. It is a convenience wrapper, and the
parts that matter here are done explicitly in the build file instead.

Note that a microbenchmark reports warm steady-state cost, which is not
what the user pays. The solve runs once, cold, during the first
composition, and on the desktop JVM that first call was four orders of
magnitude more expensive than the warm one. Measuring that needs a trace
section and the macrobenchmark module; this does not replace it.

@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 new microbenchmark module to measure the performance of the CameraLayoutSolver on real hardware, and implements an adaptive portrait layout (AdaptivePortraitCaptureLayout) that dynamically solves and positions the camera control rows and viewfinder. It also refactors system bar handling to support immersive navigation bar behavior on shorter screens. The review feedback highlights compilation issues in CaptureLayout.kt due to direct division of Dp operands, identifies several magic numbers that should be extracted to constants, and suggests restricting the visibility of LocalCameraLayoutSolution to internal.

@temcguir
temcguir marked this pull request as draft September 24, 2026 22:25
- Report requiresImmersive from PreviewLayout through a new
  onRequiresImmersiveChange callback and apply it in the app-level
  SystemBarsPolicy (HideStatusAndNavigationBars) so that a single owner
  controls navigation bar visibility. Remove ImmersiveNavigationBarEffect,
  which focus changes could override.
- Re-assert system bars on window focus changes through
  LocalWindowInfo.isWindowFocused instead of a ViewTreeObserver listener.
- Align CameraSystemBarsEffect with the Dialog-aware implementation so it
  can be shared with Dialog-hosted capture surfaces.
- Let mode switcher content taller than its band, such as a 50dp
  carousel, extend into the adjacent gaps without moving any row or
  overlapping the neighboring rows.
- Remove TopStartPlacement from PreviewLayout; topStartContent always
  stays directly below the top bar.
- Remove the unreachable portrait branch from VerticalMaterialControls.
Mirror the OverlapAwareStyleProvider wrapping that main added to
VerticalMaterialControls so the image well, flip camera button and capture
mode toggle use the reskinned backgrounds on portrait handhelds too.
@temcguir
temcguir requested a review from Kimblebee October 20, 2026 23:35
Add viewfinderTopEndContent, anchored to the top-end corner of the
viewfinder. While topStartContent is inside the viewfinder, it shares the
same vertical origin below the top bar, so both top slots stay aligned.
When the viewfinder shifts below topStartContent (for example 1:1), it
follows the top edge of the viewfinder while topStartContent stays fixed.
While the two slots share rows, its width is limited to the space beside
topStartContent so they never overlap.

PreviewDisplay fills its slot and offsets its own frame, so it now reports
the frame's top edge through the ViewfinderTopLine alignment line, which
PreviewLayout reads to place viewfinder-relative content.
… into temcguir/adaptive-capture-layout

# Conflicts:
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutTypes.kt
- Remove an unnecessary non-null assertion in PreviewLayout's top slot measurement.
- Use the v2 createComposeRule in CaptureLayoutPositionTest.
… into temcguir/adaptive-capture-layout

# Conflicts:
#	feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt
… into temcguir/adaptive-capture-layout

# Conflicts:
#	app/src/main/java/com/google/jetpackcamera/ui/SystemBarsPolicy.kt
#	app/src/test/java/com/google/jetpackcamera/ui/SystemBarsPolicyTest.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraSystemBarsEffect.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt
#	ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CameraSystemBarsEffectTest.kt
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