Sync SwipeToDismissBox with upstream

This commit is contained in:
FooIbar committed 2025-07-08 21:58:34 +08:00
1 parent a438d2dd88
commit e428a8be57
6 files changed
+254 -222

No files matched your search

@@ -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<T>(
internal fun <T> AnchoredDraggableState<T>.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 <T> Modifier.draggableAnchors(
state: AnchoredDraggableState<T>,
orientation: Orientation,
anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
) = this then DraggableAnchorsElement(state, anchors, orientation)
private class DraggableAnchorsElement<T>(
private val state: AnchoredDraggableState<T>,
private val anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
private val orientation: Orientation,
) : ModifierNodeElement<DraggableAnchorsNode<T>>() {
override fun create() = DraggableAnchorsNode(state, anchors, orientation)
override fun update(node: DraggableAnchorsNode<T>) {
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<T>(
var state: AnchoredDraggableState<T>,
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, 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())
}
}
}
@@ -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 <T> Modifier.draggableAnchors(
state: AnchoredDraggableState<T>,
orientation: Orientation,
anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
) = this then DraggableAnchorsElement(state, anchors, orientation)
private class DraggableAnchorsElement<T>(
private val state: AnchoredDraggableState<T>,
private val anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
private val orientation: Orientation,
) : ModifierNodeElement<DraggableAnchorsNode<T>>() {
override fun create() = DraggableAnchorsNode(state, anchors, orientation)
override fun update(node: DraggableAnchorsNode<T>) {
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<T>(
var state: AnchoredDraggableState<T>,
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, 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())
}
}
}
}
@@ -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<Float>()
/**
* 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<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
/** The default [Saver] implementation for [SwipeToDismissBoxState]. */
fun Saver(positionalThreshold: (totalDistance: Float) -> Float) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
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)
}
}
}
@@ -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) {
@@ -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) {
@@ -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()) },