diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-05-31 21:58:19 +0000 |
---|---|---|
committer | Gerrit Code Review <noreply-gerritcodereview@google.com> | 2023-05-31 21:58:19 +0000 |
commit | 085ee5253d8371b267a2c42c2b91d92ad5f57fff (patch) | |
tree | 7eb69c03a0132f678589e86b804d7a9e4b478677 | |
parent | 27c69a0a8f6f433a0e969ff934e622adcfd5a947 (diff) | |
parent | ed62a1363cd750498d1126498ad847c7024c523c (diff) | |
download | support-snap-temp-L01400000961003868.tar.gz |
Merge "[M3][ModalBottomSheet] Update internal popup logic to use custom abstract view" into snap-temp-L01400000961003868snap-temp-L01400000961003868
6 files changed, 235 insertions, 57 deletions
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt index 72643ece42b..cbbc054736f 100644 --- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt +++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt @@ -213,8 +213,11 @@ class ModalBottomSheetTest { @Test fun modalBottomSheet_defaultStateForSmallContentIsFullExpanded() { lateinit var sheetState: SheetState + var height by mutableStateOf(0.dp) rule.setContent { + val config = LocalContext.current.resources.configuration + height = config.screenHeightDp.dp sheetState = rememberModalBottomSheetState() ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState, dragHandle = null) { Box( @@ -226,7 +229,6 @@ class ModalBottomSheetTest { } } - val height = rule.onNode(isPopup()).getUnclippedBoundsInRoot().height assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) rule.onNodeWithTag(sheetTag).assertTopPositionInRootIsEqualTo(height - sheetHeight) } @@ -356,15 +358,15 @@ class ModalBottomSheetTest { ) } } - assertThat(state.requireOffset()).isWithin(0.5f).of(expectedExpandedAnchor) + assertThat(state.requireOffset()).isWithin(1f).of(expectedExpandedAnchor) size = 100.dp rule.waitForIdle() - assertThat(state.requireOffset()).isWithin(0.5f).of(expectedExpandedAnchor) + assertThat(state.requireOffset()).isWithin(1f).of(expectedExpandedAnchor) size = 30.dp rule.waitForIdle() - assertThat(state.requireOffset()).isWithin(0.5f).of(expectedExpandedAnchor) + assertThat(state.requireOffset()).isWithin(1f).of(expectedExpandedAnchor) } @Test diff --git a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/ModalBottomSheet.android.kt b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/ModalBottomSheet.android.kt index 33e9f0cdec1..9ba0d942241 100644 --- a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/ModalBottomSheet.android.kt +++ b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/ModalBottomSheet.android.kt @@ -16,6 +16,13 @@ package androidx.compose.material3 +import android.content.Context +import android.graphics.PixelFormat +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.ViewTreeObserver +import android.view.WindowManager import androidx.compose.animation.core.TweenSpec import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas @@ -23,21 +30,27 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.SheetValue.Expanded import androidx.compose.material3.SheetValue.Hidden +import androidx.compose.material3.SheetValue.PartiallyExpanded import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -45,17 +58,30 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.ViewRootForInspector import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.collapse import androidx.compose.ui.semantics.dismiss import androidx.compose.ui.semantics.expand +import androidx.compose.ui.semantics.paneTitle +import androidx.compose.ui.semantics.popup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.unit.dp +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.findViewTreeViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import java.util.UUID import kotlin.math.max +import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -102,6 +128,9 @@ fun ModalBottomSheet( dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, content: @Composable ColumnScope.() -> Unit, ) { + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val fullHeight = with(density) { configuration.screenHeightDp.dp.toPx() } val scope = rememberCoroutineScope() val animateToDismiss: () -> Unit = { if (sheetState.swipeableState.confirmValueChange(Hidden)) { @@ -128,7 +157,6 @@ fun ModalBottomSheet( snapTo = { target -> scope.launch { sheetState.snapTo(target) } } ) } - val systemBarHeight = WindowInsets.systemBarsForVisualComponents.getBottom(LocalDensity.current) ModalBottomSheetPopup( onDismissRequest = { @@ -139,18 +167,19 @@ fun ModalBottomSheet( } } ) { - BoxWithConstraints(Modifier.fillMaxSize()) { - val fullHeight = constraints.maxHeight + Box { Scrim( color = scrimColor, onDismissRequest = animateToDismiss, visible = sheetState.targetValue != Hidden ) + val bottomSheetPaneTitle = getString(string = Strings.BottomSheetPaneTitle) Surface( modifier = modifier .widthIn(max = BottomSheetMaxWidth) .fillMaxWidth() .align(Alignment.TopCenter) + .semantics { paneTitle = bottomSheetPaneTitle } .offset { IntOffset( 0, @@ -171,8 +200,7 @@ fun ModalBottomSheet( .modalBottomSheetSwipeable( sheetState = sheetState, anchorChangeHandler = anchorChangeHandler, - screenHeight = fullHeight.toFloat(), - bottomPadding = systemBarHeight.toFloat(), + screenHeight = fullHeight, onDragStopped = { settleToDismiss(it) }, @@ -188,35 +216,38 @@ fun ModalBottomSheet( getString(Strings.BottomSheetPartialExpandDescription) val dismissActionLabel = getString(Strings.BottomSheetDismissDescription) val expandActionLabel = getString(Strings.BottomSheetExpandDescription) - Box(Modifier - .align(Alignment.CenterHorizontally) - .semantics(mergeDescendants = true) { - // Provides semantics to interact with the bottomsheet based on its - // current value. - with(sheetState) { - dismiss(dismissActionLabel) { - animateToDismiss() - true - } - if (currentValue == SheetValue.PartiallyExpanded) { - expand(expandActionLabel) { - if (swipeableState.confirmValueChange(Expanded)) { - scope.launch { sheetState.expand() } - } + Box( + Modifier + .align(Alignment.CenterHorizontally) + .semantics(mergeDescendants = true) { + // Provides semantics to interact with the bottomsheet based on its + // current value. + with(sheetState) { + dismiss(dismissActionLabel) { + animateToDismiss() true } - } else if (hasPartiallyExpandedState) { - collapse(collapseActionLabel) { - val confirmPartial = swipeableState - .confirmValueChange(SheetValue.PartiallyExpanded) - if (confirmPartial) { - scope.launch { partialExpand() } + if (currentValue == PartiallyExpanded) { + expand(expandActionLabel) { + if (swipeableState.confirmValueChange(Expanded)) { + scope.launch { sheetState.expand() } + } + true + } + } else if (hasPartiallyExpandedState) { + collapse(collapseActionLabel) { + if ( + swipeableState.confirmValueChange( + PartiallyExpanded + ) + ) { + scope.launch { partialExpand() } + } + true } - true } } } - } ) { dragHandle() } @@ -265,7 +296,8 @@ private fun Scrim( detectTapGestures { onDismissRequest() } - }.clearAndSetSemantics {} + } + .clearAndSetSemantics {} } else { Modifier } @@ -284,23 +316,22 @@ private fun Modifier.modalBottomSheetSwipeable( sheetState: SheetState, anchorChangeHandler: AnchorChangeHandler<SheetValue>, screenHeight: Float, - bottomPadding: Float, onDragStopped: CoroutineScope.(velocity: Float) -> Unit, ) = draggable( - state = sheetState.swipeableState.swipeDraggableState, - orientation = Orientation.Vertical, - enabled = sheetState.isVisible, - startDragImmediately = sheetState.swipeableState.isAnimationRunning, - onDragStopped = onDragStopped -) + state = sheetState.swipeableState.swipeDraggableState, + orientation = Orientation.Vertical, + enabled = sheetState.isVisible, + startDragImmediately = sheetState.swipeableState.isAnimationRunning, + onDragStopped = onDragStopped + ) .swipeAnchors( state = sheetState.swipeableState, anchorChangeHandler = anchorChangeHandler, - possibleValues = setOf(Hidden, SheetValue.PartiallyExpanded, Expanded), + possibleValues = setOf(Hidden, PartiallyExpanded, Expanded), ) { value, sheetSize -> when (value) { - Hidden -> screenHeight + bottomPadding - SheetValue.PartiallyExpanded -> when { + Hidden -> screenHeight + PartiallyExpanded -> when { sheetSize.height < screenHeight / 2 -> null sheetState.skipPartiallyExpanded -> null else -> screenHeight / 2f @@ -320,9 +351,9 @@ private fun ModalBottomSheetAnchorChangeHandler( val previousTargetOffset = previousAnchors[previousTarget] val newTarget = when (previousTarget) { Hidden -> Hidden - SheetValue.PartiallyExpanded, Expanded -> { - val hasPartiallyExpandedState = newAnchors.containsKey(SheetValue.PartiallyExpanded) - val newTarget = if (hasPartiallyExpandedState) SheetValue.PartiallyExpanded + PartiallyExpanded, Expanded -> { + val hasPartiallyExpandedState = newAnchors.containsKey(PartiallyExpanded) + val newTarget = if (hasPartiallyExpandedState) PartiallyExpanded else if (newAnchors.containsKey(Expanded)) Expanded else Hidden newTarget } @@ -343,12 +374,150 @@ private fun ModalBottomSheetAnchorChangeHandler( * Popup specific for modal bottom sheet. */ @Composable -@ExperimentalMaterial3Api internal fun ModalBottomSheetPopup( onDismissRequest: () -> Unit, content: @Composable () -> Unit -) = Popup( - onDismissRequest = onDismissRequest, - properties = PopupProperties(focusable = true), - content = content -)
\ No newline at end of file +) { + val view = LocalView.current + val id = rememberSaveable { UUID.randomUUID() } + val parentComposition = rememberCompositionContext() + val currentContent by rememberUpdatedState(content) + val modalBottomSheetWindow = remember { + ModalBottomSheetWindow( + onDismissRequest = onDismissRequest, + composeView = view, + saveId = id + ).apply { + setCustomContent( + parent = parentComposition, + content = { + Box(Modifier.semantics { this.popup() }) { + currentContent() + } + } + ) + } + } + + DisposableEffect(modalBottomSheetWindow) { + modalBottomSheetWindow.show() + onDispose { + modalBottomSheetWindow.disposeComposition() + modalBottomSheetWindow.dismiss() + } + } +} + +/** Custom compose view for [ModalBottomSheet] */ +private class ModalBottomSheetWindow( + private var onDismissRequest: () -> Unit, + private val composeView: View, + saveId: UUID, +) : + AbstractComposeView(composeView.context), + ViewTreeObserver.OnGlobalLayoutListener, + ViewRootForInspector { + init { + id = android.R.id.content + // Set up view owners + setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner()) + setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner()) + setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner()) + setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId") + // Enable children to draw their shadow by not clipping them + clipChildren = false + } + + private val windowManager = + composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + + private val displayWidth: Int + get() { + val density = context.resources.displayMetrics.density + return (context.resources.configuration.screenWidthDp * density).roundToInt() + } + + private val displayHeight: Int + get() { + val density = context.resources.displayMetrics.density + return (context.resources.configuration.screenHeightDp * density).roundToInt() + } + + private val params: WindowManager.LayoutParams = + WindowManager.LayoutParams().apply { + // Position bottom sheet from the bottom of the screen + gravity = Gravity.BOTTOM or Gravity.START + // Application panel window + type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL + // Fill up the entire app view + width = displayWidth + height = displayHeight + + // Format of screen pixels + format = PixelFormat.TRANSLUCENT + // Title used as fallback for a11y services + // TODO: Provide bottom sheet window resource + title = composeView.context.resources.getString( + androidx.compose.ui.R.string.default_popup_window_title + ) + // Get the Window token from the parent view + token = composeView.applicationWindowToken + } + + private var content: @Composable () -> Unit by mutableStateOf({}) + + override var shouldCreateCompositionOnAttachedToWindow: Boolean = false + private set + + @Composable + override fun Content() { + content() + } + + fun setCustomContent( + parent: CompositionContext? = null, + content: @Composable () -> Unit + ) { + parent?.let { setParentCompositionContext(it) } + this.content = content + shouldCreateCompositionOnAttachedToWindow = true + } + + fun show() { + windowManager.addView(this, params) + } + + fun dismiss() { + setViewTreeLifecycleOwner(null) + setViewTreeSavedStateRegistryOwner(null) + composeView.viewTreeObserver.removeOnGlobalLayoutListener(this) + windowManager.removeViewImmediate(this) + } + + /** + * Taken from PopupWindow. Calls [onDismissRequest] when back button is pressed. + */ + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + if (keyDispatcherState == null) { + return super.dispatchKeyEvent(event) + } + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + val state = keyDispatcherState + state?.startTracking(event, this) + return true + } else if (event.action == KeyEvent.ACTION_UP) { + val state = keyDispatcherState + if (state != null && state.isTracking(event) && !event.isCanceled) { + onDismissRequest() + return true + } + } + } + return super.dispatchKeyEvent(event) + } + + override fun onGlobalLayout() { + // No-op + } +} diff --git a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt index 07394fccbb0..27b2cc7e2d9 100644 --- a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt +++ b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt @@ -153,6 +153,9 @@ internal actual fun getString(string: Strings): String { Strings.DateRangeInputInvalidRangeInput -> resources.getString( androidx.compose.material3.R.string.date_range_input_invalid_range_input ) + Strings.BottomSheetPaneTitle -> resources.getString( + androidx.compose.material3.R.string.bottom_sheet_pane_title + ) Strings.BottomSheetDragHandleDescription -> resources.getString( androidx.compose.material3.R.string.bottom_sheet_drag_handle_description ) diff --git a/compose/material3/material3/src/androidMain/res/values/strings.xml b/compose/material3/material3/src/androidMain/res/values/strings.xml index dbd7ceb95af..2250a9cc5df 100644 --- a/compose/material3/material3/src/androidMain/res/values/strings.xml +++ b/compose/material3/material3/src/androidMain/res/values/strings.xml @@ -71,7 +71,9 @@ <!-- Describes an invalid date range input when a user enters a start or end date [CHAR_LIMIT=NONE] --> - <string name="date_range_input_invalid_range_input">Invalid date range input</string> + <string name="m3c_date_range_input_invalid_range_input">Invalid date range input</string> + <!-- Spoken description of a bottom sheet --> + <string name="m3c_bottom_sheet_pane_title">Bottom Sheet</string> <!-- Names the drag handle visual for bottom sheet. --> <string name="bottom_sheet_drag_handle_description">Drag handle</string> <!-- Describes the collapse action for bottom sheet. --> diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt index dd8f671de58..285f8b4ad59 100644 --- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt +++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt @@ -73,6 +73,7 @@ internal value class Strings private constructor( val DateRangePickerDayInRange = Strings() val DateRangeInputTitle = Strings() val DateRangeInputInvalidRangeInput = Strings() + val BottomSheetPaneTitle = Strings() val BottomSheetDragHandleDescription = Strings() val BottomSheetPartialExpandDescription = Strings() val BottomSheetDismissDescription = Strings() diff --git a/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/Strings.desktop.kt b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/Strings.desktop.kt index 2de7b3174c7..71be0df073e 100644 --- a/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/Strings.desktop.kt +++ b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/Strings.desktop.kt @@ -69,6 +69,7 @@ internal actual fun getString(string: Strings): String { Strings.DateRangePickerDayInRange -> "In range" Strings.DateRangeInputTitle -> "Enter dates" Strings.DateRangeInputInvalidRangeInput -> "Invalid date range input" + Strings.BottomSheetPaneTitle -> "Bottom Sheet" Strings.BottomSheetDragHandleDescription -> "Drag Handle" Strings.BottomSheetPartialExpandDescription -> "Collapse bottom sheet" Strings.BottomSheetDismissDescription -> "Dismiss bottom sheet" |