From 07a495c4995388ee33e0687297fdec96454f8349 Mon Sep 17 00:00:00 2001 From: Vladimir Dostoyevskiy <99822064+VDostoyevskiy@users.noreply.github.com> Date: Sun, 19 Nov 2023 15:53:54 +0800 Subject: [PATCH] Reimplement ModalNavigationDrawer to allow HistoryScreen gesture (#293) * Reimplement ModalNavigationDrawer to allow HistoryScreen gesture * Fix RTL, fix gesture disabled --------- Co-authored-by: Lokesh Gidra --- .../compose/material3/NavigationDrawer2.kt | 348 ++++++++++++++++++ .../ehviewer/ui/scene/HistoryFragment.kt | 3 +- 2 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt diff --git a/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt b/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt new file mode 100644 index 000000000..4d1231191 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt @@ -0,0 +1,348 @@ +@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") + +package androidx.compose.material3 + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.animate +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.horizontalDrag +import androidx.compose.foundation.gestures.snapTo +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +private val AnimationSpec = TweenSpec(durationMillis = 256) +private const val DrawerPositionalThreshold = 0.5f +private val DrawerVelocityThreshold = 400.dp +private val ContainerWidth = 360.0.dp + +@Suppress("NotCloseable") +@Stable +class DrawerState2( + initialValue: DrawerValue, + val confirmStateChange: (DrawerValue) -> Boolean = { true }, +) { + + val anchoredDraggableState = AnchoredDraggableState( + initialValue = initialValue, + animationSpec = AnimationSpec, + confirmValueChange = confirmStateChange, + positionalThreshold = { distance -> distance * DrawerPositionalThreshold }, + velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } }, + ) + + /** + * Whether the drawer is open. + */ + val isOpen: Boolean + get() = currentValue == DrawerValue.Open + + /** + * Whether the drawer is closed. + */ + val isClosed: Boolean + get() = currentValue == DrawerValue.Closed + + /** + * The current value of the state. + * + * If no swipe or animation is in progress, this corresponds to the start the drawer + * currently in. If a swipe or an animation is in progress, this corresponds the state drawer + * was in before the swipe or animation started. + */ + val currentValue: DrawerValue + get() { + return anchoredDraggableState.currentValue + } + + /** + * Whether the state is currently animating. + */ + val isAnimationRunning: Boolean + get() { + return anchoredDraggableState.isAnimationRunning + } + + /** + * Open the drawer with animation and suspend until it if fully opened or animation has been + * cancelled. This method will throw [CancellationException] if the animation is + * interrupted + * + * @return the reason the open animation ended + */ + suspend fun open() = animateTo(DrawerValue.Open) + + /** + * Close the drawer with animation and suspend until it if fully closed or animation has been + * cancelled. This method will throw [CancellationException] if the animation is + * interrupted + * + * @return the reason the close animation ended + */ + suspend fun close() = animateTo(DrawerValue.Closed) + + /** + * Set the state of the drawer with specific animation + * + * @param targetValue The new value to animate to. + * @param anim The animation that will be used to animate to the new value. + */ + @Deprecated( + message = "This method has been replaced by the open and close methods. The animation " + + "spec is now an implementation detail of ModalDrawer.", + ) + suspend fun animateTo(targetValue: DrawerValue, anim: AnimationSpec) { + animateTo(targetValue = targetValue, animationSpec = anim) + } + + /** + * Set the state without any animation and suspend until it's set + * + * @param targetValue The new target value + */ + suspend fun snapTo(targetValue: DrawerValue) { + anchoredDraggableState.snapTo(targetValue) + } + + /** + * The target value of the drawer state. + * + * If a swipe is in progress, this is the value that the Drawer 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: DrawerValue + get() = anchoredDraggableState.targetValue + + /** + * The current position (in pixels) of the drawer sheet, or Float.NaN before the offset is + * initialized. + * + * @see [AnchoredDraggableState.offset] for more information. + */ + @Deprecated( + message = "Please access the offset through currentOffset, which returns the value " + + "directly instead of wrapping it in a state object.", + replaceWith = ReplaceWith("currentOffset"), + ) + val offset: State = object : State { + override val value: Float get() = anchoredDraggableState.offset + } + + /** + * The current position (in pixels) of the drawer sheet, or Float.NaN before the offset is + * initialized. + * + * @see [AnchoredDraggableState.offset] for more information. + */ + val currentOffset: Float get() = anchoredDraggableState.offset + + internal var density: Density? by mutableStateOf(null) + + private fun requireDensity() = requireNotNull(density) { + "The density on BottomDrawerState ($this) was not set. Did you use BottomDrawer" + + " with the BottomDrawer composable?" + } + + internal fun requireOffset(): Float = anchoredDraggableState.requireOffset() + + private suspend fun animateTo( + targetValue: DrawerValue, + animationSpec: AnimationSpec = AnimationSpec, + velocity: Float = anchoredDraggableState.lastVelocity, + ) { + anchoredDraggableState.anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> + val targetOffset = anchors.positionOf(latestTarget) + if (!targetOffset.isNaN()) { + var prev = if (currentOffset.isNaN()) 0f else currentOffset + animate(prev, targetOffset, velocity, animationSpec) { value, velocity -> + // Our onDrag coerces the value within the bounds, but an animation may + // overshoot, for example a spring animation or an overshooting interpolator + // We respect the user's intention and allow the overshoot, but still use + // DraggableState's drag for its mutex. + dragTo(value, velocity) + prev = value + } + } + } + } + + companion object { + /** + * The default [Saver] implementation for [DrawerState2]. + */ + fun Saver(confirmStateChange: (DrawerValue) -> Boolean) = + Saver( + save = { it.currentValue }, + restore = { DrawerState2(it, confirmStateChange) }, + ) + } +} + +/** + * Create and [remember] a [DrawerState2]. + * + * @param initialValue The initial value of the state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Composable +fun rememberDrawerState( + initialValue: DrawerValue, + confirmStateChange: (DrawerValue) -> Boolean = { true }, +): DrawerState2 { + return rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) { + DrawerState2(initialValue, confirmStateChange) + } +} + +/** + * Material Design navigation drawer. + * + * Navigation drawers provide ergonomic access to destinations in an app. + * + * Modal navigation drawers block interaction with the rest of an app’s content with a scrim. + * They are elevated above most of the app’s UI and don’t affect the screen’s layout grid. + * + * ![Navigation drawer image](https://developer.android.com/images/reference/androidx/compose/material3/navigation-drawer.png) + * + * @sample androidx.compose.material3.samples.ModalNavigationDrawerSample + * + * @param drawerContent content inside this drawer + * @param modifier the [Modifier] to be applied to this drawer + * @param drawerState state of the drawer + * @param gesturesEnabled whether or not the drawer can be interacted by gestures + * @param scrimColor color of the scrim that obscures content when the drawer is open + * @param content content of the rest of the UI + */ +@Composable +fun ModalNavigationDrawer( + drawerContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed), + gesturesEnabled: Boolean = true, + scrimColor: Color = DrawerDefaults.scrimColor, + content: @Composable () -> Unit, +) { + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val minValue = -with(density) { ContainerWidth.toPx() } + val maxValue = 0f + + SideEffect { + drawerState.density = density + drawerState.anchoredDraggableState.updateAnchors( + DraggableAnchors { + DrawerValue.Closed at minValue + DrawerValue.Open at maxValue + }, + ) + } + + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val velocityTracker = remember { VelocityTracker() } + val dragModifier = if (gesturesEnabled) { + Modifier.pointerInput(isRtl) { + val multiplier = if (isRtl) -1 else 1 + awaitEachGesture { + fun canConsume(slop: Float) = (slop > 0 && drawerState.isClosed) || (slop < 0 && drawerState.isOpen) + val down = awaitFirstDown(requireUnconsumed = false) + var overSlop: Float + val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over -> + overSlop = over * multiplier + if (canConsume(overSlop)) { + velocityTracker.addPointerInputChange(change) + change.consume() + drawerState.anchoredDraggableState.dispatchRawDelta(overSlop) + } + } + if (drag != null) { + horizontalDrag(drag.id) { + velocityTracker.addPointerInputChange(it) + drag.consume() + drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier) + } + val velocity = velocityTracker.calculateVelocity() + velocityTracker.resetTracking() + scope.launch { + drawerState.anchoredDraggableState.settle(velocity.x * multiplier) + } + } + } + } + } else { + Modifier + } + Box(modifier.fillMaxSize().then(dragModifier)) { + Box { + content() + } + Scrim( + open = drawerState.isOpen, + onClose = { + if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) { + scope.launch { drawerState.close() } + } + }, + fraction = { + calculateFraction(minValue, maxValue, drawerState.requireOffset()) + }, + color = scrimColor, + ) + Box( + modifier = Modifier.offset { + IntOffset(drawerState.requireOffset().roundToInt(), 0) + }, + ) { + drawerContent() + } + } +} + +@Suppress("SameParameterValue") +private fun calculateFraction(a: Float, b: Float, pos: Float) = ((pos - a) / (b - a)).coerceIn(0f, 1f) + +@Composable +private fun Scrim(open: Boolean, onClose: () -> Unit, fraction: () -> Float, color: Color) { + val dismissDrawer = if (open) { + Modifier.pointerInput(onClose) { detectTapGestures { onClose() } } + } else { + Modifier + } + + Canvas(Modifier.fillMaxSize().then(dismissDrawer)) { + drawRect(color, alpha = fraction()) + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/HistoryFragment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/HistoryFragment.kt index 9059903fa..7bc630374 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/HistoryFragment.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/HistoryFragment.kt @@ -197,8 +197,7 @@ fun HistoryScreen(navigator: NavController) { } class HistoryFragment : BaseScene() { - // Disabled for breaking swipe-to-dismiss - override val enableDrawerGestures = false + override val enableDrawerGestures = true override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { return ComposeWithMD3 {