diff --git a/app/src/main/kotlin/androidx/compose/material3/ModalBottomSheet2.kt b/app/src/main/kotlin/androidx/compose/material3/ModalBottomSheet2.kt new file mode 100644 index 000000000..2c25a3a0c --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/ModalBottomSheet2.kt @@ -0,0 +1,989 @@ +@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") + +package androidx.compose.material3 + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.PixelFormat +import android.os.Build +import android.view.Gravity +import android.view.View +import android.view.ViewTreeObserver +import android.view.WindowManager +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.animateTo +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.snapTo +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.imePadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +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.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.ViewRootForInspector +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.popup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.window.SecureFlagPolicy +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 com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState +import com.hippo.ehviewer.util.isAtLeastT +import java.util.UUID +import kotlin.math.absoluteValue +import kotlin.math.max +import kotlin.math.roundToInt +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch + +@Stable +@ExperimentalMaterial3Api +internal object AnchoredDraggableDefaults2 { + /** + * The default animation used by [AnchoredDraggableState]. + */ + @get:ExperimentalMaterial3Api + @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") + @ExperimentalMaterial3Api + val AnimationSpec = SpringSpec() +} + +/** + * State of a sheet composable, such as [ModalBottomSheetFix] + * + * Contains states relating to its swipe position as well as animations between state values. + * + * @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large + * enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move + * to the [Hidden] state if available when hiding the sheet, either programmatically or by user + * interaction. + * @param initialValue The initial value of the state. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. + * @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always + * expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either + * programmatically or by user interaction. + */ +@Stable +@ExperimentalMaterial3Api +class SheetState2 +@Deprecated( + message = "This constructor is deprecated. " + + "Please use the constructor that provides a [Density]", + replaceWith = ReplaceWith( + "SheetState(" + + "skipPartiallyExpanded, LocalDensity.current, initialValue, " + + "confirmValueChange, skipHiddenState)", + ), +) +constructor( + internal val skipPartiallyExpanded: Boolean, + initialValue: SheetValue = SheetValue.Hidden, + val confirmValueChange: (SheetValue) -> Boolean = { true }, + internal val skipHiddenState: Boolean = false, +) { + + /** + * State of a sheet composable, such as [ModalBottomSheetFix] + * + * Contains states relating to its swipe position as well as animations between state values. + * + * @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large + * enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move + * to the [Hidden] state if available when hiding the sheet, either programmatically or by user + * interaction. + * @param initialValue The initial value of the state. + * @param density The density that this state can use to convert values to and from dp. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. + * @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always + * expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either + * programmatically or by user interaction. + */ + @ExperimentalMaterial3Api + @Suppress("Deprecation") + constructor( + skipPartiallyExpanded: Boolean, + density: Density, + initialValue: SheetValue = SheetValue.Hidden, + confirmValueChange: (SheetValue) -> Boolean = { true }, + skipHiddenState: Boolean = false, + ) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) { + this.density = density + } + init { + if (skipPartiallyExpanded) { + require(initialValue != SheetValue.PartiallyExpanded) { + "The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " + + "is set to true." + } + } + if (skipHiddenState) { + require(initialValue != SheetValue.Hidden) { + "The initial value must not be set to Hidden if skipHiddenState is set to true." + } + } + } + + /** + * The current value of the state. + * + * If no swipe or animation is in progress, this corresponds to the state the bottom sheet is + * currently in. If a swipe or an animation is in progress, this corresponds the state the sheet + * was in before the swipe or animation started. + */ + + val currentValue: SheetValue get() = anchoredDraggableState.currentValue + + /** + * The target value of the bottom sheet state. + * + * If a swipe is in progress, this is the value that the sheet would animate to if the + * swipe finishes. If an animation is running, this is the target value of that animation. + * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. + */ + val targetValue: SheetValue get() = anchoredDraggableState.targetValue + + /** + * Whether the modal bottom sheet is visible. + */ + val isVisible: Boolean + get() = anchoredDraggableState.currentValue != SheetValue.Hidden + + /** + * Require the current offset (in pixels) of the bottom sheet. + * + * The offset will be initialized during the first measurement phase of the provided sheet + * content. + * + * These are the phases: + * Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing + * + * During the first composition, an [IllegalStateException] is thrown. In subsequent + * compositions, the offset will be derived from the anchors of the previous pass. Always prefer + * accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next + * frame, after layout. + * + * @throws IllegalStateException If the offset has not been initialized yet + */ + fun requireOffset(): Float = anchoredDraggableState.requireOffset() + + /** + * Whether the sheet has an expanded state defined. + */ + + val hasExpandedState: Boolean + get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded) + + /** + * Whether the modal bottom sheet has a partially expanded state defined. + */ + val hasPartiallyExpandedState: Boolean + get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded) + + /** + * Fully expand the bottom sheet with animation and suspend until it is fully expanded or + * animation has been cancelled. + * * + * @throws [CancellationException] if the animation is interrupted + */ + suspend fun expand() { + anchoredDraggableState.animateTo(SheetValue.Expanded) + } + + /** + * Animate the bottom sheet and suspend until it is partially expanded or animation has been + * cancelled. + * @throws [CancellationException] if the animation is interrupted + * @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true + */ + suspend fun partialExpand() { + check(!skipPartiallyExpanded) { + "Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" + + " skipPartiallyExpanded to false to use this function." + } + animateTo(SheetValue.PartiallyExpanded) + } + + /** + * Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined + * else [Expanded]. + * @throws [CancellationException] if the animation is interrupted + */ + suspend fun show() { + val targetValue = when { + hasPartiallyExpandedState -> SheetValue.PartiallyExpanded + else -> SheetValue.Expanded + } + animateTo(targetValue) + } + + /** + * Hide the bottom sheet with animation and suspend until it is fully hidden or animation has + * been cancelled. + * @throws [CancellationException] if the animation is interrupted + */ + suspend fun hide() { + check(!skipHiddenState) { + "Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" + + " to false to use this function." + } + animateTo(SheetValue.Hidden) + } + + /** + * Animate to a [targetValue]. + * If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the + * [targetValue] without updating the offset. + * + * @throws CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. + * + * @param targetValue The target value of the animation + */ + internal suspend fun animateTo( + targetValue: SheetValue, + velocity: Float = anchoredDraggableState.lastVelocity, + ) { + anchoredDraggableState.animateTo(targetValue, velocity) + } + + /** + * Snap to a [targetValue] without any animation. + * + * @throws CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. + * + * @param targetValue The target value of the animation + */ + internal suspend fun snapTo(targetValue: SheetValue) { + anchoredDraggableState.snapTo(targetValue) + } + + /** + * Find the closest anchor taking into account the velocity and settle at it with an animation. + */ + internal suspend fun settle(velocity: Float) { + anchoredDraggableState.settle(velocity) + } + + internal var anchoredDraggableState = AnchoredDraggableState( + initialValue = initialValue, + animationSpec = AnchoredDraggableDefaults2.AnimationSpec, + confirmValueChange = confirmValueChange, + positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } }, + velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } }, + ) + + internal val offset: Float get() = anchoredDraggableState.offset + + internal var density: Density? = null + private fun requireDensity() = requireNotNull(density) { + "SheetState did not have a density attached. Are you using SheetState with " + + "BottomSheetScaffold or ModalBottomSheet component?" + } + + companion object { + /** + * The default [Saver] implementation for [SheetState2]. + */ + fun Saver( + skipPartiallyExpanded: Boolean, + confirmValueChange: (SheetValue) -> Boolean, + density: Density, + ) = Saver( + save = { it.currentValue }, + restore = { savedValue -> + SheetState2(skipPartiallyExpanded, density, savedValue, confirmValueChange) + }, + ) + + /** + * The default [Saver] implementation for [SheetState2]. + */ + @Deprecated( + message = "This function is deprecated. Please use the overload where Density is" + + " provided.", + replaceWith = ReplaceWith( + "Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)", + ), + ) + @Suppress("Deprecation") + fun Saver( + skipPartiallyExpanded: Boolean, + confirmValueChange: (SheetValue) -> Boolean, + ) = Saver( + save = { it.currentValue }, + restore = { savedValue -> + SheetState2(skipPartiallyExpanded, savedValue, confirmValueChange) + }, + ) + } +} + +/** + * Material Design modal bottom sheet. + * + * Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile, + * especially when offering a long list of action items, or when items require longer descriptions + * and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other + * app functionality when they appear, and remaining on screen until confirmed, dismissed, or a + * required action has been taken. + * + * ![Bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png) + * + * A simple example of a modal bottom sheet looks like this: + * + * @sample androidx.compose.material3.samples.ModalBottomSheetSample + * + * @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet + * animates to [Hidden]. + * @param modifier Optional [Modifier] for the bottom sheet. + * @param sheetState The state of the bottom sheet. + * @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take. + * Pass in [Dp.Unspecified] for a sheet that spans the entire screen width. + * @param shape The shape of the bottom sheet. + * @param containerColor The color used for the background of this bottom sheet + * @param contentColor The preferred color for content inside this bottom sheet. Defaults to either + * the matching content color for [containerColor], or to the current [LocalContentColor] if + * [containerColor] is not a color from the theme. + * @param tonalElevation The tonal elevation of this bottom sheet. + * @param scrimColor Color of the scrim that obscures content when the bottom sheet is open. + * @param dragHandle Optional visual marker to swipe the bottom sheet. + * @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues] + * params. + * @param properties [ModalBottomSheetProperties2] for further customization of this + * modal bottom sheet's behavior. + * @param content The content to be displayed inside the bottom sheet. + */ +@Composable +@ExperimentalMaterial3Api +fun ModalBottomSheetFix( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState2 = rememberModalBottomSheetState(), + sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, + shape: Shape = BottomSheetDefaults.ExpandedShape, + containerColor: Color = BottomSheetDefaults.ContainerColor, + contentColor: Color = contentColorFor(containerColor), + tonalElevation: Dp = BottomSheetDefaults.Elevation, + scrimColor: Color = BottomSheetDefaults.ScrimColor, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + windowInsets: WindowInsets = BottomSheetDefaults.windowInsets, + properties: ModalBottomSheetProperties2 = ModalBottomSheetDefaults2.properties(), + content: @Composable ColumnScope.() -> Unit, +) { + // b/291735717 Remove this once deprecated methods without density are removed + val density = LocalDensity.current + SideEffect { + sheetState.density = density + } + val scope = rememberCoroutineScope() + val animateToDismiss: () -> Unit = { + if (sheetState.confirmValueChange(SheetValue.Hidden)) { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + onDismissRequest() + } + } + } + } + LaunchedEffect(sheetState) { + snapshotFlow { + sheetState.targetValue == SheetValue.Hidden && !sheetState.isVisible + }.drop(1).collectLatest { + if (it) onDismissRequest() + } + } + val settleToDismiss: (velocity: Float) -> Unit = { + scope.launch { sheetState.settle(it) }.invokeOnCompletion { + if (!sheetState.isVisible) onDismissRequest() + } + } + + val popupOnDismissRequest: () -> Unit = { + if (sheetState.currentValue == SheetValue.Expanded && sheetState.hasPartiallyExpandedState) { + scope.launch { sheetState.partialExpand() } + } else { // Is expanded without collapsed state or is collapsed. + scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() } + } + } + + ModalBottomSheetPopup( + properties = properties, + onDismissRequest = popupOnDismissRequest, + windowInsets = windowInsets, + ) { + val configuration = LocalConfiguration.current + val widthDp = configuration.screenWidthDp.dp + val height = configuration.screenHeightDp.dp + val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(true) { + sheetState.hide() + onDismissRequest() + } + val predictiveModifier = Modifier.graphicsLayer { + val scale = lerp(1f, 0.90f, predictiveState.absoluteValue) + scaleX = scale + scaleY = scale + }.offset { + val ofsY = lerp(0f, height.toPx() * 0.0625f, predictiveState.absoluteValue).roundToInt() + val ofsX = lerp(0f, widthDp.toPx() * 0.025f, predictiveState).roundToInt() + IntOffset(ofsX, ofsY) + } + BoxWithConstraints(Modifier.fillMaxSize()) { + val fullHeight = constraints.maxHeight + Scrim( + color = scrimColor, + onDismissRequest = animateToDismiss, + visible = sheetState.targetValue != SheetValue.Hidden, + ) + Surface( + modifier = modifier + .widthIn(max = sheetMaxWidth) + .fillMaxWidth() + .align(Alignment.TopCenter) + .offset { + IntOffset( + 0, + sheetState + .requireOffset() + .toInt(), + ) + } then predictiveModifier + .nestedScroll( + remember(sheetState) { + ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + sheetState = sheetState, + orientation = Orientation.Vertical, + onFling = settleToDismiss, + ) + }, + ) + .anchoredDraggable( + state = sheetState.anchoredDraggableState, + orientation = Orientation.Vertical, + enabled = sheetState.isVisible, + startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning, + ) + .modalBottomSheetAnchors( + sheetState = sheetState, + fullHeight = fullHeight.toFloat(), + ), + shape = shape, + color = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + ) { + Column(Modifier.fillMaxWidth()) { + if (dragHandle != null) { + Box(Modifier.align(Alignment.CenterHorizontally)) { + dragHandle() + } + } + content() + } + } + } + } + if (sheetState.hasExpandedState) { + LaunchedEffect(sheetState) { + sheetState.show() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + sheetState: SheetState2, + orientation: Orientation, + onFling: (velocity: Float) -> Unit, +): NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.Drag) { + sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset() + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + return if (source == NestedScrollSource.Drag) { + sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = available.toFloat() + val currentOffset = sheetState.requireOffset() + val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor() + return if (toFling < 0 && currentOffset > minAnchor) { + onFling(toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + onFling(available.toFloat()) + return available + } + + private fun Float.toOffset(): Offset = Offset( + x = if (orientation == Orientation.Horizontal) this else 0f, + y = if (orientation == Orientation.Vertical) this else 0f, + ) + + @JvmName("velocityToFloat") + private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y + + @JvmName("offsetToFloat") + private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y +} + +/** + * Properties used to customize the behavior of a [ModalBottomSheetFix]. + * + * @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom + * sheet's window. + * @param isFocusable Whether the modal bottom sheet is focusable. When true, + * the modal bottom sheet will receive IME events and key presses, such as when + * the back button is pressed. + * @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing + * the back button. If true, pressing the back button will call onDismissRequest. + * Note that [isFocusable] must be set to true in order to receive key events such as + * the back button - if the modal bottom sheet is not focusable then this property does nothing. + */ +@ExperimentalMaterial3Api +class ModalBottomSheetProperties2( + val securePolicy: SecureFlagPolicy, + val isFocusable: Boolean, + val shouldDismissOnBackPress: Boolean, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ModalBottomSheetProperties2) return false + + if (securePolicy != other.securePolicy) return false + if (isFocusable != other.isFocusable) return false + if (shouldDismissOnBackPress != other.shouldDismissOnBackPress) return false + + return true + } + + override fun hashCode(): Int { + var result = securePolicy.hashCode() + result = 31 * result + isFocusable.hashCode() + result = 31 * result + shouldDismissOnBackPress.hashCode() + return result + } +} + +/** + * Default values for [ModalBottomSheetFix] + */ +@Immutable +@ExperimentalMaterial3Api +object ModalBottomSheetDefaults2 { + /** + * Properties used to customize the behavior of a [ModalBottomSheetFix]. + * + * @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom + * sheet's window. + * @param isFocusable Whether the modal bottom sheet is focusable. When true, + * the modal bottom sheet will receive IME events and key presses, such as when + * the back button is pressed. + * @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing + * the back button. If true, pressing the back button will call onDismissRequest. + * Note that [isFocusable] must be set to true in order to receive key events such as + * the back button - if the modal bottom sheet is not focusable then this property does nothing. + */ + fun properties( + securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, + isFocusable: Boolean = true, + shouldDismissOnBackPress: Boolean = true, + ) = ModalBottomSheetProperties2(securePolicy, isFocusable, shouldDismissOnBackPress) +} + +/** + * Create and [remember] a [SheetState2] for [ModalBottomSheetFix]. + * + * @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough, + * should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the + * [Hidden] state when hiding the sheet, either programmatically or by user interaction. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. + */ +@Composable +@ExperimentalMaterial3Api +fun rememberModalBottomSheetState( + skipPartiallyExpanded: Boolean = false, + confirmValueChange: (SheetValue) -> Boolean = { true }, +) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, SheetValue.Hidden) + +@Composable +@ExperimentalMaterial3Api +internal fun rememberSheetState( + skipPartiallyExpanded: Boolean = false, + confirmValueChange: (SheetValue) -> Boolean = { true }, + initialValue: SheetValue = SheetValue.Hidden, + skipHiddenState: Boolean = false, +): SheetState2 { + val density = LocalDensity.current + return rememberSaveable( + skipPartiallyExpanded, + confirmValueChange, + saver = SheetState2.Saver( + skipPartiallyExpanded = skipPartiallyExpanded, + confirmValueChange = confirmValueChange, + density = density, + ), + ) { + SheetState2( + skipPartiallyExpanded, + density, + initialValue, + confirmValueChange, + skipHiddenState, + ) + } +} + +@Composable +private fun Scrim( + color: Color, + onDismissRequest: () -> Unit, + visible: Boolean, +) { + if (color.isSpecified) { + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = TweenSpec(), + label = "scrim", + ) + val dismissSheet = if (visible) { + Modifier + .pointerInput(onDismissRequest) { + detectTapGestures { + onDismissRequest() + } + } + .clearAndSetSemantics {} + } else { + Modifier + } + Canvas( + Modifier + .fillMaxSize() + .then(dismissSheet), + ) { + drawRect(color = color, alpha = alpha) + } + } +} + +@ExperimentalMaterial3Api +private fun Modifier.modalBottomSheetAnchors( + sheetState: SheetState2, + fullHeight: Float, +) = onSizeChanged { sheetSize -> + + val newAnchors = DraggableAnchors { + SheetValue.Hidden at fullHeight + if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) { + SheetValue.PartiallyExpanded at fullHeight / 2f + } + if (sheetSize.height != 0) { + SheetValue.Expanded at max(0f, fullHeight - sheetSize.height) + } + } + + val newTarget = when (sheetState.anchoredDraggableState.targetValue) { + SheetValue.Hidden -> SheetValue.Hidden + SheetValue.PartiallyExpanded, SheetValue.Expanded -> { + val hasPartiallyExpandedState = newAnchors.hasAnchorFor(SheetValue.PartiallyExpanded) + val newTarget = if (hasPartiallyExpandedState) { + SheetValue.PartiallyExpanded + } else if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.Hidden + newTarget + } + } + + sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget) +} + +/** + * Popup specific for modal bottom sheet. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ModalBottomSheetPopup( + properties: ModalBottomSheetProperties2, + onDismissRequest: () -> Unit, + windowInsets: WindowInsets, + content: @Composable () -> Unit, +) { + val view = LocalView.current + val id = rememberSaveable { UUID.randomUUID() } + val parentComposition = rememberCompositionContext() + val currentContent by rememberUpdatedState(content) + val layoutDirection = LocalLayoutDirection.current + val configuration = LocalConfiguration.current + val modalBottomSheetWindow = remember(configuration) { + ModalBottomSheetWindow2( + properties = properties, + onDismissRequest = onDismissRequest, + composeView = view, + saveId = id, + ).apply { + setCustomContent( + parent = parentComposition, + content = { + Box( + Modifier + .semantics { this.popup() } + .windowInsetsPadding(windowInsets) + .then( + // TODO(b/290893168): Figure out a solution for APIs < 30. + if (Build.VERSION.SDK_INT >= 33) { + Modifier.imePadding() + } else { + Modifier + }, + ), + ) { + currentContent() + } + }, + ) + } + } + + DisposableEffect(modalBottomSheetWindow) { + modalBottomSheetWindow.show() + modalBottomSheetWindow.superSetLayoutDirection(layoutDirection) + onDispose { + modalBottomSheetWindow.disposeComposition() + modalBottomSheetWindow.dismiss() + } + } +} + +/** Custom compose view for [ModalBottomSheetFix] */ +@SuppressLint("ViewConstructor") +@OptIn(ExperimentalMaterial3Api::class) +private class ModalBottomSheetWindow2( + private val properties: ModalBottomSheetProperties2, + private var onDismissRequest: () -> Unit, + private val composeView: View, + saveId: UUID, +) : AbstractComposeView(composeView.context), + ViewTreeObserver.OnGlobalLayoutListener, + ViewRootForInspector, + OnBackPressedDispatcherOwner { + + 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 + } + + override val lifecycle = composeView.findViewTreeLifecycleOwner()!!.lifecycle + override val onBackPressedDispatcher = OnBackPressedDispatcher { + onDismissRequest() + } + + private val windowManager = + composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + + private val displayWidth: Int + get() = context.resources.displayMetrics.widthPixels + + 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 = WindowManager.LayoutParams.MATCH_PARENT + + // 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 + + // Flags specific to modal bottom sheet. + flags = flags and ( + WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or + WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM + ).inv() + + flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + + // Security flag + val secureFlagEnabled = + properties.securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled()) + if (secureFlagEnabled) { + flags = flags or WindowManager.LayoutParams.FLAG_SECURE + } else { + flags = flags and (WindowManager.LayoutParams.FLAG_SECURE.inv()) + } + + // Focusable + if (!properties.isFocusable) { + flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + } else { + flags = flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv()) + } + } + + private var content: @Composable () -> Unit by mutableStateOf({}) + + override var shouldCreateCompositionOnAttachedToWindow: Boolean = false + private set + + @Composable + override fun Content() { + CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides this) { + content() + } + } + + fun setCustomContent( + parent: CompositionContext? = null, + content: @Composable () -> Unit, + ) { + parent?.let { setParentCompositionContext(it) } + this.content = content + shouldCreateCompositionOnAttachedToWindow = true + } + + fun show() { + windowManager.addView(this, params) + if (isAtLeastT) { + onBackPressedDispatcher.setOnBackInvokedDispatcher(findOnBackInvokedDispatcher()!!) + } + } + + fun dismiss() { + setViewTreeLifecycleOwner(null) + setViewTreeSavedStateRegistryOwner(null) + composeView.viewTreeObserver.removeOnGlobalLayoutListener(this) + windowManager.removeViewImmediate(this) + } + + override fun onGlobalLayout() { + // No-op + } + + override fun setLayoutDirection(layoutDirection: Int) { + // Do nothing. ViewRootImpl will call this method attempting to set the layout direction + // from the context's locale, but we have one already from the parent composition. + } + + // Sets the "real" layout direction for our content that we obtain from the parent composition. + fun superSetLayoutDirection(layoutDirection: LayoutDirection) { + val direction = when (layoutDirection) { + LayoutDirection.Ltr -> android.util.LayoutDirection.LTR + LayoutDirection.Rtl -> android.util.LayoutDirection.RTL + } + super.setLayoutDirection(direction) + } +} + +// Taken from AndroidPopup.android.kt +private fun View.isFlagSecureEnabled(): Boolean { + val windowParams = rootView.layoutParams as? WindowManager.LayoutParams + if (windowParams != null) { + return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0 + } + return false +} + +// Taken from AndroidPopup.android.kt +private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean { + return when (this) { + SecureFlagPolicy.SecureOff -> false + SecureFlagPolicy.SecureOn -> true + SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt index 263042221..6122adc9d 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt @@ -49,7 +49,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar -import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetFix import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -948,7 +948,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa var showBottomSheet by remember { mutableStateOf(false) } if (showBottomSheet && galleryDetail != null) { - ModalBottomSheet( + ModalBottomSheetFix( onDismissRequest = { showBottomSheet = false }, windowInsets = WindowInsets(0, 0, 0, 0), ) {