From e6b5fb9b4e016ab5e6f4fa01aa0ec811fa7babc8 Mon Sep 17 00:00:00 2001 From: Adedeji Toki Date: Wed, 13 May 2026 18:23:09 -0700 Subject: [PATCH 01/19] Implement multi-button swipeActions for Android Replace the View.swipeActions(...) unavailable stub with a working implementation backed by AnchoredDraggable-style horizontal drag in Compose. Buttons declared inside swipeActions(content:) are extracted via ComposeBuilder.Evaluate, rendered as Material reveal cells pinned to the swiped edge, and tinted by ButtonRole (.destructive uses MaterialTheme.colorScheme.error). Adds SwipeActionsModifier (RenderModifier) under Commands/, mirrors the EditActionsModifier collector pattern, and wires combined(for:) into RenderEditableItem so user-provided .swipeActions takes precedence over the implicit onDelete trash-swipe. Falling back to the existing SwipeToDismissBox when only onDelete is set keeps the prior behavior. Supports leading + trailing edges, allowsFullSwipe (full-swipe fires the first declared action), and per-button tap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SkipUI/SkipUI/Commands/SwipeActions.swift | 55 ++++++ Sources/SkipUI/SkipUI/Containers/List.swift | 179 ++++++++++++++++-- 2 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 Sources/SkipUI/SkipUI/Commands/SwipeActions.swift diff --git a/Sources/SkipUI/SkipUI/Commands/SwipeActions.swift b/Sources/SkipUI/SkipUI/Commands/SwipeActions.swift new file mode 100644 index 00000000..1b5e3929 --- /dev/null +++ b/Sources/SkipUI/SkipUI/Commands/SwipeActions.swift @@ -0,0 +1,55 @@ +// 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) -> some View { + #if SKIP + return ModifiedContent(content: self, modifier: SwipeActionsModifier(edge: edge, allowsFullSwipe: allowsFullSwipe, content: ComposeBuilder.from(content))) + #else + return self + #endif + } +} + +#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 c7714f87..3612180c 100644 --- a/Sources/SkipUI/SkipUI/Containers/List.swift +++ b/Sources/SkipUI/SkipUI/Containers/List.swift @@ -4,12 +4,18 @@ import Foundation #if SKIP import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.Animatable import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints 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 +23,7 @@ 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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.GenericShape @@ -26,6 +33,7 @@ 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 @@ -49,6 +57,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 @@ -475,14 +484,35 @@ 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 { + // Pass-through onDelete so an explicit destructive button can still + // remove the row by calling the same action the trash swipe would. + let rememberedOnDelete = rememberUpdatedState({ + if let onDelete { + withAnimation { onDelete(IndexSet(integer: index)) } + } else if let objectsBinding, objectsBinding.wrappedValue.count > index { + withAnimation { (objectsBinding.wrappedValue as? RangeReplaceableCollection)?.remove(at: index) } + } + }) + itemContent = { rowModifier in + RenderSwipeableItem(content: content, level: level, context: context, modifier: rowModifier, styling: styling, leadingConfig: leadingSwipe, trailingConfig: trailingSwipe) + } + } else if isDeleteEnabled { let rememberedOnDelete = rememberUpdatedState({ if let onDelete { withAnimation { onDelete(IndexSet(integer: index)) } @@ -501,7 +531,7 @@ 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")! Box(modifier: Modifier.background(androidx.compose.ui.graphics.Color.Red).fillMaxSize(), contentAlignment: androidx.compose.ui.Alignment.CenterEnd) { @@ -511,18 +541,145 @@ public final class List : View, Renderable { 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. + @Composable private func RenderSwipeableItem(content: Renderable, level: Int, context: ComposeContext, modifier: Modifier, styling: ListStyling, leadingConfig: SwipeActionsConfig?, trailingConfig: SwipeActionsConfig?) { + let coroutineScope = rememberCoroutineScope() + + let trailingButtons: kotlin.collections.List