Skip to content

add upright content gravity for controls and overlays - #657

Draft
Kimblebee wants to merge 11 commits into
kim/adaptive/viewfinder-rotationfrom
kim/adaptive/content-gravity
Draft

Kimblebee wants to merge 11 commits into
kim/adaptive/viewfinder-rotationfrom
kim/adaptive/content-gravity

Conversation

@Kimblebee

@Kimblebee Kimblebee commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked PR: Based on kim/adaptive/viewfinder-rotation #656

Summary

Completes the Adaptive Capture rotation stack by orienting compact phone controls, labels, canvas icons, and rectangular overlays upright with physical gravity while keeping adaptive side-rail layouts locked to 0°. On kim/adaptive/viewfinder-rotation, CompactPortraitLayout held its structural chrome in portrait geometry and kept the CameraX viewfinder aligned with the window, but individual icons, ZoomBar labels, ToggleSwitch glyphs, CameraErrorDialog, PillSnackbarHost/ElapsedTimeText, and the Quick Settings bottom sheet still rendered sideways in landscape (±90°) and snapped back to 0° when tilted to 180°. This slice adds draw-phase upright rotation for leaf glyphs, constraint-swapping layout rotation for rectangular blocks, sticky 180° gravity retention, 90° quarter-turn animation with immediate 180° flip snapping, and posture-scoped 0° gravity enforcement across :core:model and :ui:components:capture.

Key Changes

  • Sticky 180° Gravity & Quarter-Turn Animation State (DeviceRotation.kt, RotationModifiers.kt, CaptureLayout.kt)
    • Add fallbackOn180Degrees: Int = 0 to DeviceRotation.toGravityRotationDegrees and introduce rememberStickyGravityTargetDegrees so holding the device at 180° retains the previous upright gravity (0°, 90°, or 270°) and resets when windowRotation changes.
    • Keep rememberGravityRotationState animating 90° quarter-turns by default (animateQuarterTurns = true, 250ms tween), while snapping immediately on 180° landscape-to-landscape flips (90° <-> 270°), window-rotation changes, or when animateQuarterTurns = false.
    • Add ambient-reading Modifier.rotateLayoutToGravity() (via derivedStateOf + snapToQuadrantDegrees) with a 0° no-layer fast path, and clamp LocalGravityRotationState to DefaultGravityRotationState (0f) for all non-CompactPortraitLayout modes in CaptureScaffoldLayout.
  • Compact Landscape Top-Edge Banner, Quick Settings Sheet & Error Dialog (CompactPortraitLayout.kt, CameraErrorDialog.kt, QuickSettingsScreen.kt, CaptureScaffold.kt, SideRailCaptureLayouts.kt)
    • Relocate PillSnackbarHost + ElapsedTimeText into LandscapeTopEdgeBanner at AbsoluteAlignment.CenterRight (90° / Rotated270) or AbsoluteAlignment.CenterLeft (270° / Rotated90) using .rotateLayoutToGravity(targetGravityDegrees).
    • Rotate CompactPortraitLayout's Quick Settings sheet via .rotateLayoutToGravity with a 380.dp portrait-height cap (maxWidth = 380.dp in landscape), orthogonal nested-scroll isolation (consumeRemainingScrollConnection), and an open-sheet cross-fade (150ms fade out -> 250ms animateContentSize -> 150ms fade in).
    • Add .verticalScroll(rememberScrollState()) and 24.dp landscape top padding to QuickSettingsScaffoldContent, unify CaptureScaffoldDefaults.QuickSettings onto it across compact and floating-card layouts, and apply .rotateLayoutToGravity() to CameraErrorDialog's Surface.
  • Upright Leaf Controls, ZoomBar Labels & ToggleSwitch Canvas Icons (CaptureScreenComponents.kt, CaptureButtonComponents.kt, ImageWell.kt, QuickSettingsComponents.kt, ZoomBarComponents.kt, ToggleSwitch.kt)
    • Apply Modifier.animateToUpright() to CameraControlIconButton, PauseResumeToggleButton, AmplitudeToggleButton, StabilizationIcon, VideoQualityIcon, FlipCameraButton inner Icon, LockSwitchCaptureButtonNucleus lock Icon, ImageWell thumbnail Image, ToggleQuickSettingsButton, HdrIndicator, and TopBarQuickSettingIcon.
    • Rotate ZoomBarItem labels upright via graphicsLayer with sinusoidal dynamic fit-scaling (lerp(44.dp, 34.dp, |sin(θ)|), min scale 0.6f).
    • Rotate ToggleSwitch left/right painters around each icon's center inside drawClippedSwitchIcons while keeping thumbRect two-tone clipRect bounds in unrotated track coordinates.

Technical Notes

  • Draw-phase vs. layout-phase rotation: 1:1 glyphs and stationary-container icons use graphicsLayer (animateToUpright) so layout slot sizes never change (0 sibling shift). Rectangular blocks (CameraErrorDialog, LandscapeTopEdgeBanner, landscape Quick Settings) use Modifier.rotateLayoutToGravity() to swap (minWidth, maxWidth) <-> (minHeight, maxHeight) before measurement and center via placeWithLayer, keeping text wrapping unclipped and hit targets aligned with rotated visuals while avoiding extra layer allocation at 0°.
  • Quarter-turn animation by default with 180° flip snap: rememberGravityRotationState animates 90° quarter-turns over 250ms by default (animateQuarterTurns = true), while snapping immediately on 180° half-turn flips (rawDelta == ±180f) or when animateQuarterTurns = false.
  • Quick Settings nested-scroll isolation: Because Compose's NestedScrollDispatcher does not rotate Offset/Velocity vectors across graphicsLayer.rotationZ = ±90f, consumeRemainingScrollConnection absorbs post-scroll/post-fling remainders so vertical list overscroll inside the rotated landscape sheet cannot drag or dismiss BottomSheetScaffold along its orthogonal portrait-Y axis.
  • Split-screen counter-rotation cancellation: In 800x400dp horizontal compact split-screen (windowRotation = Natural, shouldCounterRotate = true), .counterRotateToPortrait rotates -90° CCW while interior gravity resolves +90° CW (containerFrameRotation = Rotated90), bringing controls and dialogs back to 0° upright on screen.

Pre-Existing Bugs Fixed

  • Upside-down 180° spin on +90° -> 270° transitions (RotationModifiers.kt): rememberGravityRotationState now snaps 180° half-turn flips immediately while animating 90° quarter-turns (animateQuarterTurns = true).
  • Unscrollable QuickSettingsScaffoldContent & duplicate scroll wrappers: Moving .verticalScroll(rememberScrollState()) into QuickSettingsScaffoldContent, unifying CaptureScaffoldDefaults.QuickSettings, and removing the outer verticalScroll Box in QuickSettingsOverlayCardPlaceholder fixes vertical scrolling across both compact sheets and short/side-rail floating cards without nested-scroll crashes.

Not Included Yet

  • Comes later in this series:
    • Side-rail leaf replacements: Vertical CaptureModeToggleButton/ToggleSwitch (#635), QuickSettingsOverlayCard (#648), and VerticalIndicatorPill (#649) remain tracked in their dedicated leaf PRs; side-rail layouts enforce LocalGravityRotationState = 0f so these components compose cleanly without conflict.
    • Separate polish: Tooltip work (#622) and zoom bar right-to-left polish (#644) are tracked in their own PRs.

Testing

  • ./gradlew :core:model:testDebugUnitTest --tests "*DeviceRotationTest*" — Default 0° and stateful fallbackOn180Degrees (90, 270) behavior at 180° deltas.
  • ./gradlew :ui:components:capture:testDebugUnitTest --tests "*RotationModifiersTest*" --tests "*ContentGravityTest*" — Sticky 180° state transitions, default animateQuarterTurns = true (90° tween / 180° snap) and animateQuarterTurns = false immediate snap, 1:1 slot-bounds invariance, single-rotation in topStartContent, unclipped CameraErrorDialog & LandscapeTopEdgeBanner (LTR + RTL), 380.dp landscape Quick Settings sheet swap, 0° side-rail enforcement across all 4 rail/short layouts, 800x400dp split-screen counter-rotation + interior gravity cancellation, ZoomBar "1.5×" label rotation, and @GraphicsMode(NATIVE) pixel verification of ToggleSwitch center-rotated icons with track-space thumb clipping.

@Kimblebee
Kimblebee changed the base branch from main to kim/adaptive/viewfinder-rotation November 13, 2026 19:11

@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 implements dynamic screen orientation and window rotation support, allowing the camera preview and UI to adapt to different window postures and multi-window modes. Key changes include adding resolveRequestedOrientation and applyWindowRotation logic, updating MainActivity and CameraXCameraSystem to handle configuration changes, and adding comprehensive unit tests. The review feedback highlights the need to guard isInMultiWindowMode with an SDK version check to prevent crashes on older Android versions and requests the addition of a @RunWith annotation to DeviceRotationTest to adhere to the repository's style guide.

I am having trouble creating individual review comments. Click here to see my feedback.

app/src/main/java/com/google/jetpackcamera/MainActivity.kt (230)

high

The property Activity.isInMultiWindowMode was introduced in Android Nougat (API 24). If the application is run on devices with API levels below 24 (such as API 21-23), accessing this property directly as a default parameter value will result in a NoSuchMethodError or NoClassDefFoundError at runtime.\n\nTo prevent potential crashes on older Android versions, please guard the access to isInMultiWindowMode with an SDK version check.

        multiWindowMode: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode

core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt (20-22)

medium

According to the repository style guide (line 61), all test classes must be annotated with @RunWith(...) to explicitly declare which test runner should be used. Since DeviceRotationTest does not have any Android dependencies other than the inlined Surface constants, it should be annotated with @RunWith(JUnit4::class) to comply with the style guide and ensure explicit test runner declaration.

import org.junit.Test\nimport org.junit.runner.RunWith\nimport org.junit.runners.JUnit4\n\n@RunWith(JUnit4::class)\nclass DeviceRotationTest {
References
  1. All test classes must be annotated with @RunWith(...) to explicitly declare which test runner should be used (e.g., @RunWith(AndroidJUnit4::class), @RunWith(RobolectricTestRunner::class), or @RunWith(JUnit4::class) for host tests with no Android dependencies). (link)

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