diff --git a/Sources/SkipUI/SkipUI/Commands/SwipeActions.swift b/Sources/SkipUI/SkipUI/Commands/SwipeActions.swift new file mode 100644 index 00000000..03e6668f --- /dev/null +++ b/Sources/SkipUI/SkipUI/Commands/SwipeActions.swift @@ -0,0 +1,61 @@ +// Copyright 2023–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if !SKIP_BRIDGE +import Foundation + +extension View { + public func swipeActions(edge: HorizontalEdge = .trailing, allowsFullSwipe: Bool = true, @ViewBuilder content: () -> any View) -> any View { + #if SKIP + return ModifiedContent(content: self, modifier: SwipeActionsModifier(edge: edge, allowsFullSwipe: allowsFullSwipe, content: ComposeBuilder.from(content))) + #else + return self + #endif + } + + // SKIP @bridge + public func swipeActions(bridgedEdge: Int, allowsFullSwipe: Bool, bridgedActions: any View) -> any View { + let edge = HorizontalEdge(rawValue: bridgedEdge) ?? .trailing + return swipeActions(edge: edge, allowsFullSwipe: allowsFullSwipe, content: { bridgedActions }) + } +} + +#if SKIP +/// Holds a single edge's swipe action configuration. +struct SwipeActionsConfig { + let allowsFullSwipe: Bool + let content: ComposeBuilder +} + +final class SwipeActionsModifier: RenderModifier { + let edge: HorizontalEdge + let allowsFullSwipe: Bool + let content: ComposeBuilder + + init(edge: HorizontalEdge, allowsFullSwipe: Bool, content: ComposeBuilder) { + self.edge = edge + self.allowsFullSwipe = allowsFullSwipe + self.content = content + super.init() + } + + /// Walk the modifier chain and collect leading + trailing swipe configs. + /// Innermost modifier on a given edge wins (matches SwiftUI semantics). + static func combined(for renderable: Renderable) -> (leading: SwipeActionsConfig?, trailing: SwipeActionsConfig?) { + var leading: SwipeActionsConfig? = nil + var trailing: SwipeActionsConfig? = nil + renderable.forEachModifier { + if let mod = $0 as? SwipeActionsModifier { + let config = SwipeActionsConfig(allowsFullSwipe: mod.allowsFullSwipe, content: mod.content) + if mod.edge == .leading { + leading = leading ?? config + } else { + trailing = trailing ?? config + } + } + return nil + } + return (leading: leading, trailing: trailing) + } +} +#endif +#endif diff --git a/Sources/SkipUI/SkipUI/Containers/List.swift b/Sources/SkipUI/SkipUI/Containers/List.swift index 9d37af99..6dff6c10 100644 --- a/Sources/SkipUI/SkipUI/Containers/List.swift +++ b/Sources/SkipUI/SkipUI/Containers/List.swift @@ -4,12 +4,23 @@ import Foundation #if SKIP import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animate import androidx.compose.foundation.background import androidx.compose.foundation.clickable +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.snapTo +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.exponentialDecay import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -17,6 +28,11 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.GenericShape @@ -26,20 +42,25 @@ import androidx.compose.material.pullrefresh.PullRefreshState import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.SwipeToDismissBoxDefaults import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect @@ -49,6 +70,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.zIndex import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -64,6 +86,18 @@ import struct CoreGraphics.CGFloat /// Corner radius for list sections. let listSectionCornerRadius = 8.0 +#if SKIP +/// Discrete rest positions for a row's swipe gesture. Used as the value +/// type for the row's AnchoredDraggableState. +enum SwipeAnchor { + case closed + case leadingOpen + case leadingFull + case trailingOpen + case trailingFull +} +#endif + // SKIP @bridge // SKIP INSERT: @Stable // Otherwise Compose recomposes all internal @Composable funcs because 'this' is unstable public final class List : View, Renderable { @@ -242,6 +276,10 @@ public final class List : View, Renderable { } let itemContext = context.content() + /* Tracks which row currently has its swipe actions revealed. When one + opens, all others observe this state and animate closed. Matches + iOS list behavior of "only one row's swipe actions visible at once". */ + let activeSwipeKey = remember { mutableStateOf(nil) } // Combine contentPadding with contentMargins additively var contentPadding = EnvironmentValues.shared._contentPadding.asPaddingValues() if let contentMargins = EnvironmentValues.shared._contentMargins?.asComposePaddingValues(for: .automatic) { @@ -277,7 +315,7 @@ public final class List : View, Renderable { let index = itemCollector.value.remapIndex(index, from: offset) let itemModifier: Modifier = shouldAnimateItems() ? Modifier.animateItem() : Modifier let renderable = factory(index + range.start, itemContext) - RenderEditableItem(content: renderable, level: level, context: itemContext, modifier: itemModifier, styling: styling, key: keyValue, index: index, onDelete: onDelete, onMove: onMove, reorderableState: reorderableState) + RenderEditableItem(content: renderable, level: level, context: itemContext, modifier: itemModifier, styling: styling, key: keyValue, index: index, onDelete: onDelete, onMove: onMove, reorderableState: reorderableState, activeSwipeKey: activeSwipeKey) } }, objectItems: { objects, identifier, offset, onDelete, onMove, level, factory in @@ -287,7 +325,7 @@ public final class List : View, Renderable { let index = itemCollector.value.remapIndex(index, from: offset) let itemModifier: Modifier = shouldAnimateItems() ? Modifier.animateItem() : Modifier let renderable = factory(objects[index], itemContext) - RenderEditableItem(content: renderable, level: level, context: itemContext, modifier: itemModifier, styling: styling, key: keyValue, index: index, onDelete: onDelete, onMove: onMove, reorderableState: reorderableState) + RenderEditableItem(content: renderable, level: level, context: itemContext, modifier: itemModifier, styling: styling, key: keyValue, index: index, onDelete: onDelete, onMove: onMove, reorderableState: reorderableState, activeSwipeKey: activeSwipeKey) } }, objectBindingItems: { objectsBinding, identifier, offset, editActions, onDelete, onMove, level, factory in @@ -297,7 +335,7 @@ public final class List : View, Renderable { let index = itemCollector.value.remapIndex(index, from: offset) let itemModifier: Modifier = shouldAnimateItems() ? Modifier.animateItem() : Modifier let renderable = factory(objectsBinding, index, itemContext) - RenderEditableItem(content: renderable, level: level, context: itemContext, modifier: itemModifier, styling: styling, objectsBinding: objectsBinding, key: keyValue, index: index, editActions: editActions, onDelete: onDelete, onMove: onMove, reorderableState: reorderableState) + RenderEditableItem(content: renderable, level: level, context: itemContext, modifier: itemModifier, styling: styling, objectsBinding: objectsBinding, key: keyValue, index: index, editActions: editActions, onDelete: onDelete, onMove: onMove, reorderableState: reorderableState, activeSwipeKey: activeSwipeKey) } }, sectionHeader: { content in @@ -359,6 +397,15 @@ public final class List : View, Renderable { private static let horizontalItemInset = 16.0 private static let verticalItemInset = 8.0 private static let levelInset = 24.0 + + /// Seconds of velocity-based projection used when picking the snap anchor. + private static let swipeVelocityProjectionSeconds: Float = Float(0.15) + /// Fraction of the row width past which a full swipe fires the destructive action. + private static let swipeFullSwipeFraction: Float = Float(0.65) + /// Cap on the gray scrim alpha applied to the foreground during a swipe. + private static let swipeScrimMaxAlpha: Float = Float(0.18) + /// Minimum width for a single reveal button; grows to fit longer labels. + private static let swipeButtonMinWidth: Dp = 80.dp static func contentModifier(level: Int) -> Modifier { return Modifier.padding(start: (horizontalItemInset + level * levelInset).dp, end: horizontalItemInset.dp, top: verticalItemInset.dp, bottom: verticalItemInset.dp).fillMaxWidth().requiredHeightIn(min: minimumItemHeight.dp) @@ -466,7 +513,7 @@ public final class List : View, Renderable { } } - @Composable private func RenderEditableItem(content: Renderable, level: Int, context: ComposeContext, modifier: Modifier, styling: ListStyling, objectsBinding: Binding>? = nil, key: String?, index: Int, editActions: EditActions = [], onDelete: ((IndexSet) -> Void)?, onMove: ((IndexSet, Int) -> Void)?, reorderableState: ReorderableLazyListState) { + @Composable private func RenderEditableItem(content: Renderable, level: Int, context: ComposeContext, modifier: Modifier, styling: ListStyling, objectsBinding: Binding>? = nil, key: String?, index: Int, editActions: EditActions = [], onDelete: ((IndexSet) -> Void)?, onMove: ((IndexSet, Int) -> Void)?, reorderableState: ReorderableLazyListState, activeSwipeKey: MutableState) { guard !content.isSwiftUIEmptyView else { return } @@ -475,14 +522,41 @@ public final class List : View, Renderable { return } let editActionsModifier = EditActionsModifier.combined(for: content) + let swipeConfigs = SwipeActionsModifier.combined(for: content) + let leadingSwipe = swipeConfigs.leading + let trailingSwipe = swipeConfigs.trailing + let hasUserSwipe = leadingSwipe != nil || trailingSwipe != nil let isDeleteEnabled = (editActions.contains(.delete) || onDelete != nil) && editActionsModifier.isDeleteDisabled != true let isMoveEnabled = (editActions.contains(.move) || onMove != nil) && editActionsModifier.isMoveDisabled != true - guard isDeleteEnabled || isMoveEnabled else { + guard isDeleteEnabled || isMoveEnabled || hasUserSwipe else { RenderItem(content: content, level: level, context: context, modifier: modifier, styling: styling) return } - if isDeleteEnabled { + /* Build the inner swipe + content composable. User-provided .swipeActions + wins over the implicit onDelete trash. If neither swipe path applies + we just render the row (caller still handles reorder wrapping). */ + let itemContent: @Composable (Modifier) -> Void + if hasUserSwipe { + /* Mirror iOS: a destructive button's full-swipe both fires the + user's action AND removes the row from the underlying data, so + the row visibly disappears via LazyColumn's animateItem. We pass + an `onDestructiveDelete` closure to RenderSwipeableItem; it is + only invoked when (a) the destructive full-swipe fires AND + (b) the List has either an onDelete handler or a deletable + objectsBinding to remove from. */ + let canAutoDelete = isDeleteEnabled + let onDestructiveDelete: (() -> Void)? = canAutoDelete ? { + if let onDelete { + withAnimation { onDelete(IndexSet(integer: index)) } + } else if let objectsBinding, objectsBinding.wrappedValue.count > index { + withAnimation { (objectsBinding.wrappedValue as? RangeReplaceableCollection)?.remove(at: index) } + } + } : nil + itemContent = { rowModifier in + RenderSwipeableItem(content: content, level: level, context: context, modifier: rowModifier, styling: styling, leadingConfig: leadingSwipe, trailingConfig: trailingSwipe, rowKey: key, activeSwipeKey: activeSwipeKey, onDestructiveDelete: onDestructiveDelete) + } + } else if isDeleteEnabled { let rememberedOnDelete = rememberUpdatedState({ if let onDelete { withAnimation { onDelete(IndexSet(integer: index)) } @@ -501,24 +575,458 @@ public final class List : View, Renderable { return false }, positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold) - let itemContent: @Composable (Modifier) -> Void = { + itemContent = { SwipeToDismissBox(state: dismissState, enableDismissFromEndToStart: true, enableDismissFromStartToEnd: false, modifier: $0, backgroundContent: { - let trashVector = Image.composeImageVector(named: "trash")! + /* Red background unconditional (destructive cue); icon only + if the trash vector resolves — force-unwrapping inside a LazyColumn item would crash measurement. */ Box(modifier: Modifier.background(androidx.compose.ui.graphics.Color.Red).fillMaxSize(), contentAlignment: androidx.compose.ui.Alignment.CenterEnd) { - Icon(imageVector: trashVector, contentDescription: "Delete", modifier = Modifier.padding(end: 24.dp), tint: androidx.compose.ui.graphics.Color.White) + if let trashVector = Image.composeImageVector(named: "trash") { + Icon(imageVector: trashVector, contentDescription: "Delete", modifier = Modifier.padding(end: 24.dp), tint: androidx.compose.ui.graphics.Color.White) + } } }, content: { RenderItem(content: content, level: level, context: context, styling: styling) }) } - if isMoveEnabled { - RenderReorderableItem(reorderableState: reorderableState, key: key, modifier: modifier, content: itemContent) - } else { - itemContent(modifier) + } else { + itemContent = { rowModifier in + RenderItem(content: content, level: level, context: context, modifier: rowModifier, styling: styling) } + } + + if isMoveEnabled { + RenderReorderableItem(reorderableState: reorderableState, key: key, modifier: modifier, content: itemContent) } else { - RenderReorderableItem(reorderableState: reorderableState, key: key, modifier: modifier) { - RenderItem(content: content, level: level, context: context, modifier: $0, styling: styling) + itemContent(modifier) + } + } + + /// Render a row wrapped in a horizontal-drag swipe container that reveals + /// user-provided action Buttons on the leading and/or trailing edge. + /// The foreground row determines the cell's height; reveal buttons match it + /// via `Modifier.matchParentSize()` so we never propagate unbounded height + /// constraints up into the surrounding LazyColumn. + @Composable private func RenderSwipeableItem(content: Renderable, level: Int, context: ComposeContext, modifier: Modifier, styling: ListStyling, leadingConfig: SwipeActionsConfig?, trailingConfig: SwipeActionsConfig?, rowKey: String, activeSwipeKey: MutableState, onDestructiveDelete: (() -> Void)? = nil) { + let coroutineScope = rememberCoroutineScope() + + /* Extract Buttons and per-button .tint(_:) values from each edge's + rendered content. .tint() is implemented as an env modifier with + affectsEvaluate=false, so it wraps the Button via ModifiedContent + but doesn't appear in the EnvironmentValues during Evaluate. We + walk each renderable's modifier chain, run any EnvironmentModifier + actions in a scoped env, and capture the resulting _tint. The + innermost matching modifier wins (matches SwiftUI). */ + let trailingRenderables = trailingConfig?.content.Evaluate(context: context, options: 0) ?? listOf() + let leadingRenderables = leadingConfig?.content.Evaluate(context: context, options: 0) ?? listOf() + let trailingTintMap: kotlin.collections.MutableMap = mutableMapOf() + let leadingTintMap: kotlin.collections.MutableMap = mutableMapOf() + let trailingButtonsRawMutable: kotlin.collections.MutableList