Skip to content

bottom sheet refactor - #569

Open
Kimblebee wants to merge 99 commits into
mainfrom
kim/refactor/quickSettings/bottomSheet-refactor
Open

bottom sheet refactor#569
Kimblebee wants to merge 99 commits into
mainfrom
kim/refactor/quickSettings/bottomSheet-refactor

Conversation

@Kimblebee

@Kimblebee Kimblebee commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Refactors Quick Settings to use Material 3's in-hierarchy BottomSheetScaffold instead of ModalBottomSheet.

Why the Transition from ModalBottomSheet to BottomSheetScaffold?

  • In-Hierarchy Composition: BottomSheetScaffold renders the sheet within the primary Compose layout tree, maintaining a single window lifecycle and preserving seamless viewfinder rendering.

Key Changes

  • In-Hierarchy Scaffolding: Replaced root Scaffold with BottomSheetScaffold in CaptureLayout.kt (sheetPeekHeight = 0.dp), unifying SnackbarHost and removing nested sheet wrappers.
  • Clickable Drag Handle: Added BottomSheetDefaults.DragHandle with onDismissQuickSettings click action and QUICK_SETTINGS_DRAG_HANDLE test tag.
  • State & Back Handling: Managed isQuickSettingsOpen in PreviewScreen.kt with LaunchedEffect sync and BackHandler(enabled = isQuickSettingsOpen).
  • Test Determinism: Added closeQuickSettings() and updated visitQuickSettings in ComposeTestRuleExt.kt to click the drag handle pill, eliminating flaky synthetic gesture timeouts.

Lifecycle Behavior & Trade-offs

  • Persistent Composition (Never Decomposed): Unlike ModalBottomSheet (which dynamically mounts/unmounts from composition upon opening/closing), BottomSheetScaffold is a persistent layout container. When hidden (sheetPeekHeight = 0.dp), the sheet container remains in the composition tree and is translated off-screen rather than decomposed. Automated tests therefore check visibility (.isNotDisplayed()) rather than complete semantics node absence.
  • State Synchronization: Traded the declarative onDismissRequest modal API for bidirectional LaunchedEffect synchronization between the boolean UI state (isQuickSettingsOpen) and SheetState.
  • Drag Handle Affordance: Material 3's default drag handle is purely a drag gesture affordance; a .clickable modifier was explicitly attached to provide a deterministic tap-to-dismiss target for accessibility and test automation.

Kimblebee added 30 commits April 8, 2026 19:12
- remove flip camera, stream config, and concurrent camera from quick settings
- all quick settings menu items adopt the button row ux
- WIP unique selection of settings depending on current capture mode
auxiliary function for settings subtitles
… state

- Re-added the 'More Settings' button to the Quick Settings bottom sheet.

- Introduced a 'showMoreSettingsButton' boolean parameter to control its visibility, defaulting to true.

- Removed unused 'focusedQuickSetting' state from 'QuickSettingsUiState' and 'TrackedCaptureUiState'.

- Cleaned up 'FlashModeUiStateAdapter.kt' by removing a debug 'println' and adding a 'todo(kc)' for 'visibleFlashModes'.
    Remove unused drawables and their corresponding enum classes in
    QuickSettingsEnums that are no longer referenced after the quick
    settings refactoring.
    - Decoupled dynamic range (video HDR) from image format (image HDR) settings across UI, controller, and CameraX configuration layers.
    - Removed dynamic range constraints from the createImageUseCase configuration in CameraSession.kt, enabling independent Ultra HDR
  image capture.
    - Updated QuickSettings bottom sheet click handlers to mutate only the HDR setting relevant to the active capture mode.
    - Enforced specialized Low Light Boost vs Ultra HDR conflicts in CameraXCameraSystem.kt, prioritizing Low Light Boost.
    - Created HdrUiStateAdapterTest.kt covering all HDR availability states and flash conflicts.
    - Refactored CameraXCameraSystemTest.kt to run parameterized HDR decoupling tests on both front and rear lenses.
… state adapter tests

- Refactored the Quick Settings bottom sheet UI to a flat layout, replacing nested navigation and scrollable containers with direct option rows.
- Updated Instrumented tests (BackgroundDeviceTest, CaptureModeSettingsTest, ConcurrentCameraTest, NavigationTest, SwitchCameraTest) and helper functions in ComposeTestRuleExt.kt to interact with the flat layout.
- Added LocalDisableAnimations composition local support in PreviewScreen.kt and QuickSettingsModalBottomSheet to disable animations for faster, more reliable testing.
- Refactored FlashModeUiState.Unavailable and HdrUiState.Unavailable from classes to objects and updated tests in FlashModeUiStateAdapterTest.kt and HdrUiStateAdapterTest.kt.
- Dynamically update FlipCameraButton's content description based on current lens facing.
…lBottomSheet

Reverted QuickSettingsModalBottomSheet in QuickSettingsComponents.kt to directly use Material3's ModalBottomSheet, removing the custom non-gestural Box/Column layout previously used with LocalDisableAnimations.

Cleaned up ComposeTestRuleExt.kt visitQuickSettings cleanup to rely on standard swipe-to-dismiss behavior and verification.
- Remove obsolete toast and disabled rationale strings from ui:components:capture.
- Delete unused strings.xml in ui:controller:impl.
- Retain ui:uistateadapter:capture as the single source of truth for toast and rationale strings.
# Conflicts:
#	app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt
- Remove isQuickSettingsOpen and quickSettingsIsOpen from TrackedCaptureUiState, QuickSettingsUiState, and UI state adapters.
- Remove toggleQuickSettings() from QuickSettingsController and its implementations.
- Manage quick settings drawer visibility as local UI state in PreviewScreen via rememberSaveable.
- Clean up unused quickSettingsIsOpen and toggleQuickSettings tests.
…osable

- Make QuickSettingsContent internal and container-independent with modifier and showMoreSettingsButton parameters.
- Flatten internal layout structure by removing intermediate QuickSettingsLayout helper.
- Add additional top padding above the 'More settings' navigation button.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@Kimblebee
Kimblebee changed the base branch from main to kim/refactor/quickSettings/button-rows August 27, 2026 22:12
@Kimblebee
Kimblebee force-pushed the kim/refactor/quickSettings/bottomSheet-refactor branch from 6b1fec0 to a4bf36f Compare August 27, 2026 22:39
@Kimblebee
Kimblebee requested a review from temcguir August 27, 2026 22:41
Integrate Material 3 BottomSheetScaffold into CaptureLayout to render
Quick Settings in-hierarchy and avoid window-spawning modal overlays.
Make the drag handle pill clickable for dismiss, and update test helpers
to close the sheet deterministically.
@Kimblebee
Kimblebee force-pushed the kim/refactor/quickSettings/bottomSheet-refactor branch from a4bf36f to 8544a5b Compare August 27, 2026 22:58
@temcguir

Copy link
Copy Markdown
Collaborator

/gemini review

@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 Quick Settings panel to use BottomSheetScaffold instead of a custom overlay, moving the open/close state from the global UI state to local Compose state in PreviewScreen. Key feedback includes: ensuring the test helper checks if the drag handle is displayed before clicking it to prevent test failures; optimizing gesture synchronization by observing targetValue instead of isVisible; adding zoomStateManager to the captureButtonLambda remember keys to avoid stale references; remembering the onDismissQuickSettings lambda to prevent unnecessary recompositions; and extracting the hardcoded onClickLabel string to strings.xml to comply with localization guidelines.

Comment on lines +661 to +670
val dragHandleNodes = onAllNodesWithTag(QUICK_SETTINGS_DRAG_HANDLE).fetchSemanticsNodes()
if (dragHandleNodes.isNotEmpty()) {
onNodeWithTag(QUICK_SETTINGS_DRAG_HANDLE).performClick()
} else {
val openToggle =
onNodeWithContentDescription(CaptureR.string.quick_settings_toggle_open_description)
if (openToggle.isDisplayed()) {
openToggle.performClick()
}
}

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.

high

Since BottomSheetScaffold keeps the bottom sheet in the composition tree even when hidden, onAllNodesWithTag(QUICK_SETTINGS_DRAG_HANDLE) will return a non-empty list even when the sheet is closed. Attempting to click the hidden drag handle will result in an AssertionError because the node is not displayed. We should check if the drag handle is actually displayed before attempting to click it.

Suggested change
val dragHandleNodes = onAllNodesWithTag(QUICK_SETTINGS_DRAG_HANDLE).fetchSemanticsNodes()
if (dragHandleNodes.isNotEmpty()) {
onNodeWithTag(QUICK_SETTINGS_DRAG_HANDLE).performClick()
} else {
val openToggle =
onNodeWithContentDescription(CaptureR.string.quick_settings_toggle_open_description)
if (openToggle.isDisplayed()) {
openToggle.performClick()
}
}
val dragHandleNodes = onAllNodesWithTag(QUICK_SETTINGS_DRAG_HANDLE).fetchSemanticsNodes()
if (dragHandleNodes.isNotEmpty() && onNodeWithTag(QUICK_SETTINGS_DRAG_HANDLE).isDisplayed()) {
onNodeWithTag(QUICK_SETTINGS_DRAG_HANDLE).performClick()
} else {
val openToggle =
onNodeWithContentDescription(CaptureR.string.quick_settings_toggle_open_description)
if (openToggle.isDisplayed()) {
openToggle.performClick()
}
}

Comment on lines +328 to +332
LaunchedEffect(scaffoldState.bottomSheetState.isVisible) {
if (!scaffoldState.bottomSheetState.isVisible && isQuickSettingsOpen) {
isQuickSettingsOpen = false
}
}

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

Observing isVisible for gesture synchronization means isQuickSettingsOpen will only update to false after the collapse animation fully completes. This can make the UI (such as the quick settings toggle button) feel laggy or out of sync. Observing targetValue == SheetValue.Hidden allows us to immediately update the state as soon as the swipe-down gesture starts, making the UI much more responsive.

Suggested change
LaunchedEffect(scaffoldState.bottomSheetState.isVisible) {
if (!scaffoldState.bottomSheetState.isVisible && isQuickSettingsOpen) {
isQuickSettingsOpen = false
}
}
LaunchedEffect(scaffoldState.bottomSheetState.targetValue) {
if (scaffoldState.bottomSheetState.targetValue == SheetValue.Hidden && isQuickSettingsOpen) {
isQuickSettingsOpen = false
}
}

Comment on lines 483 to 486
val captureButtonLambda = remember(
captureButtonState,
quickSettingsState,
quickSettingsController,
captureController
) {

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 captureButtonLambda captures zoomStateManager but does not include it in its remember keys. If zoomStateManager is recreated (e.g., when the lens changes or during configuration changes), the lambda will hold a stale reference to the old manager. Adding zoomStateManager to the keys ensures correctness.

    val captureButtonLambda = remember(
        captureButtonState,
        captureController,
        zoomStateManager
    ) {

Comment on lines 754 to +757
LayoutWrapper(
modifier = modifier,
scaffoldState = scaffoldState,
onDismissQuickSettings = { isQuickSettingsOpen = false },

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 onDismissQuickSettings lambda is currently recreated on every recomposition of ContentScreen, which can cause LayoutWrapper and PreviewLayout to unnecessarily recompose. Wrapping it in a remember block ensures a stable reference and improves performance.

Suggested change
LayoutWrapper(
modifier = modifier,
scaffoldState = scaffoldState,
onDismissQuickSettings = { isQuickSettingsOpen = false },
val onDismissQuickSettings = remember { { isQuickSettingsOpen = false } }
LayoutWrapper(
modifier = modifier,
scaffoldState = scaffoldState,
onDismissQuickSettings = onDismissQuickSettings,

Comment on lines +115 to +118
.clickable(
onClickLabel = "Close quick settings",
onClick = onDismissQuickSettings
)

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 accessibility onClickLabel is a user-facing string and should not be hardcoded to "Close quick settings". According to the repository style guide, all user-facing strings must be extracted to strings.xml to support localization.

Suggested change
.clickable(
onClickLabel = "Close quick settings",
onClick = onDismissQuickSettings
)
.clickable(
onClickLabel = stringResource(R.string.quick_settings_close_description),
onClick = onDismissQuickSettings
)
References
  1. Forbid hardcoded user-facing strings in composables. All text should be extracted into strings.xml to support localization and make updates easier. (link)

Base automatically changed from kim/refactor/quickSettings/button-rows to main August 31, 2026 17:19
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