diff --git a/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt b/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt index 33f7beb58..da78c3354 100644 --- a/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt +++ b/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt @@ -22,20 +22,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.positionChange -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.layout.MeasureScope -import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.requireLayoutDirection import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.platform.debugInspectorInfo -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.Velocity import kotlin.math.absoluteValue -import kotlin.math.roundToInt import kotlinx.coroutines.launch @ExperimentalFoundationApi @@ -197,104 +189,3 @@ private class AnchoredHorizontalDraggableNode( internal fun AnchoredDraggableState.newOffsetForDelta(delta: Float) = ((if (offset.isNaN()) 0f else offset) + delta) .coerceIn(anchors.minPosition(), anchors.maxPosition()) - -/** - * This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout - * node's size and offsetting it. - * It considers lookahead and reports the appropriate size and measurement for the appropriate - * phase. - * - * @param state The state the anchors should be attached to - * @param orientation The orientation the component should be offset in - * @param anchors Lambda to calculate the anchors based on this layout's size and the incoming - * constraints. These can be useful to avoid subcomposition. - */ -internal fun Modifier.draggableAnchors( - state: AnchoredDraggableState, - orientation: Orientation, - anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, -) = this then DraggableAnchorsElement(state, anchors, orientation) - -private class DraggableAnchorsElement( - private val state: AnchoredDraggableState, - private val anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, - private val orientation: Orientation, -) : ModifierNodeElement>() { - - override fun create() = DraggableAnchorsNode(state, anchors, orientation) - - override fun update(node: DraggableAnchorsNode) { - node.state = state - node.anchors = anchors - node.orientation = orientation - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - - if (other !is DraggableAnchorsElement<*>) return false - - if (state != other.state) return false - if (anchors !== other.anchors) return false - if (orientation != other.orientation) return false - - return true - } - - override fun hashCode(): Int { - var result = state.hashCode() - result = 31 * result + anchors.hashCode() - result = 31 * result + orientation.hashCode() - return result - } - - override fun InspectorInfo.inspectableProperties() { - debugInspectorInfo { - properties["state"] = state - properties["anchors"] = anchors - properties["orientation"] = orientation - } - } -} - -private class DraggableAnchorsNode( - var state: AnchoredDraggableState, - var anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, - var orientation: Orientation, -) : Modifier.Node(), - LayoutModifierNode { - private var didLookahead: Boolean = false - - override fun onDetach() { - didLookahead = false - } - - override fun MeasureScope.measure( - measurable: Measurable, - constraints: Constraints, - ): MeasureResult { - val placeable = measurable.measure(constraints) - // If we are in a lookahead pass, we only want to update the anchors here and not in - // post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead), - // update the anchors in the main pass. - if (!isLookingAhead || !didLookahead) { - val size = IntSize(placeable.width, placeable.height) - val newAnchorResult = anchors(size, constraints) - state.updateAnchors(newAnchorResult.first, newAnchorResult.second) - } - didLookahead = isLookingAhead || didLookahead - return layout(placeable.width, placeable.height) { - // In a lookahead pass, we use the position of the current target as this is where any - // ongoing animations would move. If the component is in a settled state, lookahead - // and post-lookahead will converge. - val offset = if (isLookingAhead) { - state.anchors.positionOf(state.targetValue) - } else { - state.requireOffset() - } - val xOffset = if (orientation == Orientation.Horizontal) offset else 0f - val yOffset = if (orientation == Orientation.Vertical) offset else 0f - placeable.placeRelative(xOffset.roundToInt(), yOffset.roundToInt()) - } - } -} diff --git a/app/src/main/kotlin/androidx/compose/material3/fork/DraggableAnchors.kt b/app/src/main/kotlin/androidx/compose/material3/fork/DraggableAnchors.kt new file mode 100644 index 000000000..80fda71ec --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/fork/DraggableAnchors.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3.fork + +/** + * Material-specific anchor layout logic which considers lookahead. This internal code is expected + * to remain in the library after androidx.compose.material3.internal.AnchoredDraggable.kt is + * removed. + */ +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.requireLayoutDirection +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.roundToInt + +/** + * This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout + * node's size and offsetting it. It considers lookahead and reports the appropriate size and + * measurement for the appropriate phase. + * + * @param state The state the anchors should be attached to + * @param orientation The orientation the component should be offset in + * @param anchors Lambda to calculate the anchors based on this layout's size and the incoming + * constraints. These can be useful to avoid subcomposition. + */ +@Stable +internal fun Modifier.draggableAnchors( + state: AnchoredDraggableState, + orientation: Orientation, + anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, +) = this then DraggableAnchorsElement(state, anchors, orientation) + +private class DraggableAnchorsElement( + private val state: AnchoredDraggableState, + private val anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, + private val orientation: Orientation, +) : ModifierNodeElement>() { + + override fun create() = DraggableAnchorsNode(state, anchors, orientation) + + override fun update(node: DraggableAnchorsNode) { + node.state = state + node.anchors = anchors + node.orientation = orientation + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + + if (other !is DraggableAnchorsElement<*>) return false + + if (state != other.state) return false + if (anchors !== other.anchors) return false + if (orientation != other.orientation) return false + + return true + } + + override fun hashCode(): Int { + var result = state.hashCode() + result = 31 * result + anchors.hashCode() + result = 31 * result + orientation.hashCode() + return result + } + + override fun InspectorInfo.inspectableProperties() { + debugInspectorInfo { + properties["state"] = state + properties["anchors"] = anchors + properties["orientation"] = orientation + } + } +} + +private class DraggableAnchorsNode( + var state: AnchoredDraggableState, + var anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, + var orientation: Orientation, +) : Modifier.Node(), LayoutModifierNode { + private var didLookahead: Boolean = false + + override fun onDetach() { + didLookahead = false + } + + private val isReverseDirection: Boolean + get() = requireLayoutDirection() == LayoutDirection.Rtl && orientation == Orientation.Horizontal + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val placeable = measurable.measure(constraints) + // If we are in a lookahead pass, we only want to update the anchors here and not in + // post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead), + // update the anchors in the main pass. + if (!isLookingAhead || !didLookahead) { + val size = IntSize(placeable.width, placeable.height) + val newAnchorResult = anchors(size, constraints) + state.updateAnchors(newAnchorResult.first, newAnchorResult.second) + } + didLookahead = isLookingAhead || didLookahead + return layout(placeable.width, placeable.height) { + // In a lookahead pass, we use the position of the current target as this is where any + // ongoing animations would move. If the component is in a settled state, lookahead + // and post-lookahead will converge. + val offset = if (isLookingAhead) { + state.anchors.positionOf(state.targetValue) + } else { + state.requireOffset() + } + val rtlModifier = if (isReverseDirection) -1f else 1f + val xOffset = if (orientation == Orientation.Horizontal) offset * rtlModifier else 0f + val yOffset = if (orientation == Orientation.Vertical) offset else 0f + // Tagging as motion frame of reference placement, meaning the placement + // contains scrolling. This allows the consumer of this placement offset to + // differentiate this offset vs. offsets from structural changes. Generally + // speaking, this signals a preference to directly apply changes rather than + // animating, to avoid a chasing effect to scrolling. + withMotionFrameOfReferencePlacement { + placeable.place(xOffset.roundToInt(), yOffset.roundToInt()) + } + } + } +} diff --git a/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt b/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.kt similarity index 59% rename from app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt rename to app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.kt index 1f1d8a575..4e1eaf6a9 100644 --- a/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt +++ b/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.kt @@ -17,14 +17,12 @@ package androidx.compose.material3.fork import androidx.annotation.FloatRange -import androidx.compose.animation.core.SpringSpec import androidx.compose.foundation.gestures.AnchoredDraggableDefaults import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.anchoredHorizontalDraggable import androidx.compose.foundation.gestures.animateTo -import androidx.compose.foundation.gestures.draggableAnchors import androidx.compose.foundation.gestures.snapTo import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -32,32 +30,31 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.material3.SwipeToDismissBoxDefaults import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Density import kotlinx.coroutines.CancellationException -private val SnapAnimationSpec = SpringSpec() - /** * State of the [SwipeToDismissBox] composable. * * @param initialValue The initial value of the state. - * @param density The density that this state can use to convert values to and from dp. - * while a swipe is in progress and when settling after the swipe ends. This is the distance from - * the start of a transition. It will be, depending on the direction of the interaction, added or - * subtracted from/to the origin offset. It should always be a positive value. + * @param positionalThreshold The positional threshold to be used when calculating the target + * state while a swipe is in progress and when settling after the swipe ends. This is the + * distance from the start of a transition. It will be, depending on the direction of the + * interaction, added or subtracted from/to the origin offset. It should always be a positive + * value. */ class SwipeToDismissBoxState( initialValue: SwipeToDismissBoxValue, - internal val density: Density, + internal val positionalThreshold: (totalDistance: Float) -> Float, ) { internal val anchoredDraggableState = AnchoredDraggableState(initialValue) - internal val offset: Float get() = anchoredDraggableState.offset + internal val offset: Float + get() = anchoredDraggableState.offset /** * Require the current offset. @@ -66,29 +63,38 @@ class SwipeToDismissBoxState( */ fun requireOffset(): Float = anchoredDraggableState.requireOffset() - /** - * The current state value of the [SwipeToDismissBoxState]. - */ - val currentValue: SwipeToDismissBoxValue get() = anchoredDraggableState.currentValue + /** The current state value of the [SwipeToDismissBoxState]. */ + val currentValue: SwipeToDismissBoxValue + get() = anchoredDraggableState.currentValue /** * The target state. This is the closest state to the current offset (taking into account * positional thresholds). If no interactions like animations or drags are in progress, this * will be the current state. */ - val targetValue: SwipeToDismissBoxValue get() = anchoredDraggableState.targetValue + val targetValue: SwipeToDismissBoxValue + get() = anchoredDraggableState.targetValue + + /** + * The value the [SwipeToDismissBoxState] is currently settled at. When progressing through + * multiple anchors, e.g. A -> B -> C, settledValue will stay the same until settled at an + * anchor, while currentValue will update to the closest anchor. + */ + val settledValue: SwipeToDismissBoxValue + get() = anchoredDraggableState.settledValue /** * The fraction of the progress going from currentValue to targetValue, within [0f..1f] bounds. */ @get:FloatRange(from = 0.0, to = 1.0) - val progress: Float get() = anchoredDraggableState.progress(anchoredDraggableState.settledValue, anchoredDraggableState.targetValue) + val progress: Float + get() = anchoredDraggableState.progress(anchoredDraggableState.settledValue, anchoredDraggableState.targetValue) /** * The direction (if any) in which the composable has been or is being dismissed. * - * Use this to change the background of the [SwipeToDismissBox] if you want different actions - * on each side. + * Use this to change the background of the [SwipeToDismissBox] if you want different actions on + * each side. */ val dismissDirection: SwipeToDismissBoxValue get() = when { @@ -108,14 +114,12 @@ class SwipeToDismissBoxState( /** * Reset the component to the default position with animation and suspend until it if fully - * reset or animation has been cancelled. This method will throw [CancellationException] if - * the animation is interrupted + * reset or animation has been cancelled. This method will throw [CancellationException] if the + * animation is interrupted * * @return the reason the reset animation ended */ - suspend fun reset() = anchoredDraggableState.animateTo( - targetValue = SwipeToDismissBoxValue.Settled, - ) + suspend fun reset() = anchoredDraggableState.animateTo(targetValue = SwipeToDismissBoxValue.Settled) /** * Dismiss the component in the given [direction], with an animation and suspend. This method @@ -129,12 +133,10 @@ class SwipeToDismissBoxState( companion object { - /** - * The default [Saver] implementation for [SwipeToDismissBoxState]. - */ - fun Saver(density: Density) = Saver( + /** The default [Saver] implementation for [SwipeToDismissBoxState]. */ + fun Saver(positionalThreshold: (totalDistance: Float) -> Float) = Saver( save = { it.currentValue }, - restore = { SwipeToDismissBoxState(it, density) }, + restore = { SwipeToDismissBoxState(it, positionalThreshold) }, ) } } @@ -143,18 +145,19 @@ class SwipeToDismissBoxState( * Create and [remember] a [SwipeToDismissBoxState]. * * @param initialValue The initial value of the state. - * while a swipe is in progress and when settling after the swipe ends. This is the distance from - * the start of a transition. It will be, depending on the direction of the interaction, added or - * subtracted from/to the origin offset. It should always be a positive value. + * @param positionalThreshold The positional threshold to be used when calculating the target state + * while a swipe is in progress and when settling after the swipe ends. This is the distance from + * the start of a transition. It will be, depending on the direction of the interaction, added or + * subtracted from/to the origin offset. It should always be a positive value. */ @Composable fun rememberSwipeToDismissBoxState( initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled, -): SwipeToDismissBoxState { - val density = LocalDensity.current - return rememberSaveable(saver = SwipeToDismissBoxState.Saver(density)) { - SwipeToDismissBoxState(initialValue, density) - } + positionalThreshold: (totalDistance: Float) -> Float = SwipeToDismissBoxDefaults.positionalThreshold, +): SwipeToDismissBoxState = rememberSaveable( + saver = SwipeToDismissBoxState.Saver(positionalThreshold = positionalThreshold), +) { + SwipeToDismissBoxState(initialValue, positionalThreshold) } /** @@ -162,11 +165,14 @@ fun rememberSwipeToDismissBoxState( * * @param state The state of this component. * @param backgroundContent A composable that is stacked behind the [content] and is exposed when - * the content is swiped. You can/should use the [state] to have different backgrounds on each side. + * the content is swiped. You can/should use the [state] to have different backgrounds on each + * side. * @param modifier Optional [Modifier] for this component. * @param enableDismissFromStartToEnd Whether SwipeToDismissBox can be dismissed from start to end. * @param enableDismissFromEndToStart Whether SwipeToDismissBox can be dismissed from end to start. * @param gesturesEnabled Whether swipe-to-dismiss can be interacted by gestures. + * @param onDismiss Optional callback to be called when [content] is dismissed. onDismissed provides + * the current dismissed direction. * @param content The content that can be dismissed. */ @Composable @@ -177,35 +183,28 @@ fun SwipeToDismissBox( enableDismissFromStartToEnd: Boolean = true, enableDismissFromEndToStart: Boolean = true, gesturesEnabled: Boolean = true, + onDismiss: suspend (SwipeToDismissBoxValue) -> Unit = {}, content: @Composable RowScope.() -> Unit, ) { Box( - modifier - .anchoredHorizontalDraggable( + modifier = modifier.anchoredHorizontalDraggable( + state = state.anchoredDraggableState, + enableDragFromStartToEnd = enableDismissFromStartToEnd, + enableDragFromEndToStart = enableDismissFromEndToStart, + enabled = gesturesEnabled && state.settledValue == SwipeToDismissBoxValue.Settled, + flingBehavior = AnchoredDraggableDefaults.flingBehavior( state = state.anchoredDraggableState, - enableDragFromStartToEnd = enableDismissFromStartToEnd, - enableDragFromEndToStart = enableDismissFromEndToStart, - enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled, - flingBehavior = AnchoredDraggableDefaults.flingBehavior( - state = state.anchoredDraggableState, - positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold, - animationSpec = SnapAnimationSpec, - ), + positionalThreshold = state.positionalThreshold, ), + ), propagateMinConstraints = true, ) { - Row( - content = backgroundContent, - modifier = Modifier.matchParentSize(), - ) + Row(content = backgroundContent, modifier = Modifier.matchParentSize()) Row( content = content, - modifier = Modifier.draggableAnchors( - state.anchoredDraggableState, - Orientation.Horizontal, - ) { size, _ -> - val width = size.width.toFloat() - return@draggableAnchors DraggableAnchors { + modifier = Modifier.draggableAnchors(state.anchoredDraggableState, Orientation.Horizontal) { size, _ -> + DraggableAnchors { + val width = size.width.toFloat() SwipeToDismissBoxValue.Settled at 0f if (enableDismissFromStartToEnd) { SwipeToDismissBoxValue.StartToEnd at width @@ -217,4 +216,10 @@ fun SwipeToDismissBox( }, ) } + LaunchedEffect(state.settledValue, onDismiss) { + // Only call when state is settled in a dismissed direction. + if (state.settledValue != SwipeToDismissBoxValue.Settled) { + onDismiss(state.dismissDirection) + } + } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt index 194baa01a..4770e9abf 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBoxDefaults import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -66,7 +67,6 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -161,6 +161,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = fun getTranslation(tag: String) = ehTags?.run { getTranslation(TagNamespace.Artist.prefix, tag) ?: getTranslation(TagNamespace.Cosplayer.prefix, tag) } ?: tag + val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold val allName = stringResource(R.string.download_all) val defaultName = stringResource(R.string.default_download_label_name) val unknownName = stringResource(R.string.unknown_artists) @@ -342,12 +343,21 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = itemsIndexed(groupList, key = { _, (id) -> id }) { itemIndex, (id, label) -> val index by rememberUpdatedState(itemIndex) val item by rememberUpdatedState(label) - // Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it - // SQLite may reuse ROWIDs from previously deleted rows so they'll have the same key - val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) } - LaunchedEffect(dismissState) { - snapshotFlow { dismissState.currentValue }.collect { - if (it == SwipeToDismissBoxValue.EndToStart) { + ReorderableItem( + reorderableLabelState, + id, + enabled = editEnable, + animateItemModifier = Modifier.thenIf(animateItems) { animateItem() }, + ) { isDragging -> + // Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it + // SQLite may reuse ROWIDs from previously deleted rows so they'll have the same key + val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, positionalThreshold) } + SwipeToDismissBox( + state = dismissState, + backgroundContent = {}, + enableDismissFromStartToEnd = false, + gesturesEnabled = editEnable, + onDismiss = { dialogState.runCatching { awaitConfirmationOrCancel(confirmText = R.string.delete) { Text(text = stringResource(R.string.delete_label, item)) @@ -361,20 +371,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = }.onFailure { dismissState.reset() } - } - } - } - ReorderableItem( - reorderableLabelState, - id, - enabled = editEnable, - animateItemModifier = Modifier.thenIf(animateItems) { animateItem() }, - ) { isDragging -> - SwipeToDismissBox( - state = dismissState, - backgroundContent = {}, - enableDismissFromStartToEnd = false, - gesturesEnabled = editEnable, + }, ) { val elevation by animateDpAsState( if (isDragging) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt index 65f83b27c..8acc1b83c 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.SwipeToDismissBoxDefaults import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -49,7 +50,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState 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.draw.clip @@ -180,6 +180,7 @@ fun AnimatedVisibilityScope.GalleryListScreen( } val density = LocalDensity.current + val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold val listState = rememberLazyGridState() val gridState = rememberLazyStaggeredGridState() val isTopList = remember(urlBuilder) { urlBuilder.mode == MODE_TOPLIST } @@ -325,32 +326,28 @@ fun AnimatedVisibilityScope.GalleryListScreen( ) { isDragging -> // Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it // SQLite may reuse ROWIDs from previously deleted rows so they'll have the same key - val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) } - LaunchedEffect(dismissState) { - snapshotFlow { dismissState.currentValue }.collect { value -> - if (value == SwipeToDismissBoxValue.EndToStart) { - dialogState.runCatching { - awaitConfirmationOrCancel(confirmText = R.string.delete) { - Text(text = stringResource(R.string.delete_quick_search, item.name)) - } - }.onSuccess { - EhDB.deleteQuickSearch(item) - with(quickSearchList) { - subList(index + 1, size).forEach { - it.position-- - } - removeAt(index) - } - }.onFailure { - dismissState.reset() - } - } - } - } + val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, positionalThreshold) } SwipeToDismissBox( state = dismissState, backgroundContent = {}, enableDismissFromStartToEnd = false, + onDismiss = { + dialogState.runCatching { + awaitConfirmationOrCancel(confirmText = R.string.delete) { + Text(text = stringResource(R.string.delete_quick_search, item.name)) + } + }.onSuccess { + EhDB.deleteQuickSearch(item) + with(quickSearchList) { + subList(index + 1, size).forEach { + it.position-- + } + removeAt(index) + } + }.onFailure { + dismissState.reset() + } + }, ) { val elevation by animateDpAsState( if (isDragging) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt index 5c127e7f0..144a19f0d 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt @@ -14,19 +14,16 @@ import androidx.compose.material.icons.filled.ClearAll import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.fork.SwipeToDismissBox import androidx.compose.material3.fork.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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 @@ -150,18 +147,12 @@ fun AnimatedVisibilityScope.HistoryScreen(navigator: DestinationsNavigator) = Sc val info = historyData[index] if (info != null) { val dismissState = rememberSwipeToDismissBoxState() - LaunchedEffect(dismissState) { - snapshotFlow { dismissState.currentValue }.collect { - if (it == SwipeToDismissBoxValue.EndToStart) { - EhDB.deleteHistoryInfo(info) - } - } - } SwipeToDismissBox( state = dismissState, backgroundContent = {}, modifier = Modifier.thenIf(animateItems) { animateItem() }, enableDismissFromStartToEnd = false, + onDismiss = { EhDB.deleteHistoryInfo(info) }, ) { GalleryInfoListItem( onClick = { navigate(info.asDst()) },