From 7e55ab67654108c5653ebad5605a3fbd441affa6 Mon Sep 17 00:00:00 2001 From: michele-theleftbit Date: Wed, 25 Feb 2026 10:44:08 +0100 Subject: [PATCH 1/5] Make SwipeableListView Android-compatible --- .../Extensions/View+ViewBuilders.swift | 17 +++++++++ .../SwiftUI/Views/SwipeableListView.swift | 36 ++++++++++++------- 2 files changed, 41 insertions(+), 12 deletions(-) create mode 100644 Sources/BSWInterfaceKit/SwiftUI/Extensions/View+ViewBuilders.swift diff --git a/Sources/BSWInterfaceKit/SwiftUI/Extensions/View+ViewBuilders.swift b/Sources/BSWInterfaceKit/SwiftUI/Extensions/View+ViewBuilders.swift new file mode 100644 index 00000000..d2c1ac32 --- /dev/null +++ b/Sources/BSWInterfaceKit/SwiftUI/Extensions/View+ViewBuilders.swift @@ -0,0 +1,17 @@ +// +// Created by Michele Restuccia on 25/2/26. +// + +import SwiftUI + +public extension View { + + @ViewBuilder + func contentRectangleShape() -> some View { + #if canImport(Darwin) + self.contentShape(Rectangle()) + #else + self + #endif + } +} diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift index b83d70aa..9f23da62 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift @@ -92,6 +92,8 @@ private struct DemoSwipeableListView: View { } } +#endif + // MARK: - SwipeableListView public struct SwipeableListView: View { @@ -177,23 +179,30 @@ struct SwipeableRow: View { Spacer() actionsView } + #if os(Android) + .zIndex(baseOffsetX != 0 ? 1.0 : 0.0) + #endif + content() - .contentShape(Rectangle()) + .contentRectangleShape() .offset(x: effectiveOffsetX) .animation(.swipeable, value: effectiveOffsetX) - .highPriorityGesture(dragGesture, including: isDisabled ? .none : .all) - } - .contentShape(Rectangle()) - .onTapGesture { - guard baseOffsetX != 0 else { return } - closeAndClearOpen(animated: true) + #if canImport(Darwin) + .highPriorityGesture(dragGesture) + #else + .zIndex(baseOffsetX != 0 ? 0.0 : 1.0) + .gesture(dragGesture) + #endif } + .contentRectangleShape() .onChange(of: openRowID) { _, newValue in guard newValue != id, baseOffsetX != 0 else { return } close(animated: true) } + .onChange(of: isDisabled) { _, newValue in + close(animated: true) + } } - // MARK: - ViewBuilders @ViewBuilder @@ -226,12 +235,17 @@ struct SwipeableRow: View { private var dragGesture: some Gesture { DragGesture(minimumDistance: 8, coordinateSpace: .local) .onChanged { value in + guard !isDisabled else { return } let x = value.translation.width let y = value.translation.height guard abs(x) > abs(y) else { return } dragOffsetX = x } .onEnded { value in + guard !isDisabled else { + finishDrag() + return + } defer { finishDrag() } let x = value.translation.width @@ -303,13 +317,13 @@ private enum Constants { // MARK: - Extensions -private extension Animation { +extension Animation { static var swipeable: Animation { .interactiveSpring(response: 0.25, dampingFraction: 0.92) } } -private extension AnyTransition { +extension AnyTransition { static var swipeableRow: AnyTransition { .asymmetric( insertion: .move(edge: .top), @@ -317,5 +331,3 @@ private extension AnyTransition { ) } } - -#endif From c24e6fffd9cd60c254359bf5e400e95c2a4e58f6 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Wed, 25 Feb 2026 11:09:43 +0100 Subject: [PATCH 2/5] refactor: simplify SwipeableListView by removing nested Demo Remove the nested DemoSwipeableListView struct and consolidate the Item definition, improving readability. Use @Previewable directly with a ScrollView, enhancing the layout by utilizing SwiftUI components effectively. Streamline the item view rendering by embedding logic within the SwipeableListView's rowContent parameter, reducing code duplication and providing a cleaner structure. --- .../SwiftUI/Views/SwipeableListView.swift | 88 +++++++++---------- 1 file changed, 40 insertions(+), 48 deletions(-) diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift index 9f23da62..b85f4f72 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift @@ -4,23 +4,19 @@ import SwiftUI -#if canImport(Darwin) +#if canImport(UIKit) // MARK: - Previews -#Preview(traits: .sizeThatFitsLayout) { - DemoSwipeableListView() +private struct Item: Identifiable, Equatable { + let id: String + let title: String + let detail: String + let icon: Image? } -private struct DemoSwipeableListView: View { - - struct Item: Identifiable, Equatable { - let id: String - let title: String - let detail: String - let icon: Image? - } - +#Preview { + @Previewable @State var items: [Item] = [ .init( @@ -43,11 +39,40 @@ private struct DemoSwipeableListView: View { ) ] - var body: some View { + ScrollView { SwipeableListView( items: items, isSwipeDisabled: { $0.id == "milan" }, - rowContent: { itemView($0) }, + rowContent: { item in + /// This is intentionally a Button to prove the swipe still works. + Button {} label: { + HStack(alignment: .top, spacing: 16) { + if let icon = item.icon { + icon + .font(.system(size: 16, weight: .semibold)) + .padding(8) + .background(.secondary.opacity(0.15), in: Circle()) + } + + VStack(alignment: .leading, spacing: 6) { + Text(item.title).bold() + Text(item.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + } + .padding(16) + } + .buttonStyle(.plain) + .background(Color(uiColor: UIColor.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(.separator.opacity(0.25), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + }, onDelete: { id in withAnimation(.swipeable) { items.removeAll { $0.id == id } @@ -55,40 +80,7 @@ private struct DemoSwipeableListView: View { } ) .padding(16) - .background(.primary) - } - - @ViewBuilder - private func itemView(_ item: Item) -> some View { - /// This is intentionally a Button to prove the swipe still works. - Button {} label: { - HStack(alignment: .top, spacing: 16) { - if let icon = item.icon { - icon - .font(.system(size: 16, weight: .semibold)) - .padding(8) - .background(.secondary.opacity(0.15), in: Circle()) - } - - VStack(alignment: .leading, spacing: 6) { - Text(item.title).bold() - Text(item.detail) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 0) - } - .padding(16) - } - .buttonStyle(.plain) - .background(.white) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(.separator.opacity(0.25), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - + .background(Color(uiColor: UIColor.secondarySystemBackground)) } } From 5e825a6eef102eaf8a64f89853a601962156e61c Mon Sep 17 00:00:00 2001 From: michele-theleftbit Date: Wed, 25 Feb 2026 12:06:09 +0100 Subject: [PATCH 3/5] Previews with more power --- .../Extensions/View+ViewBuilders.swift | 2 + .../SwiftUI/Views/SwipeableListView.swift | 120 ++++++++++++------ 2 files changed, 82 insertions(+), 40 deletions(-) diff --git a/Sources/BSWInterfaceKit/SwiftUI/Extensions/View+ViewBuilders.swift b/Sources/BSWInterfaceKit/SwiftUI/Extensions/View+ViewBuilders.swift index d2c1ac32..d6c1c68d 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Extensions/View+ViewBuilders.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Extensions/View+ViewBuilders.swift @@ -6,6 +6,8 @@ import SwiftUI public extension View { + /// Expands the hit-testing area of a `Button` to the full view on iOS. + /// Not required on Android, where the default behavior already covers the whole view. @ViewBuilder func contentRectangleShape() -> some View { #if canImport(Darwin) diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift index b85f4f72..8908c0e4 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift @@ -22,7 +22,7 @@ private struct Item: Identifiable, Equatable { .init( id: "milan", title: "AC Milan ❤️🖤", - detail: "Rossoneri. Sette Champions.", + detail: "Rossoneri. Sette Champions. Incancellabile. Controlled by `isSwipeDisabled`", icon: Image(systemName: "flame.fill") ), .init( @@ -34,52 +34,92 @@ private struct Item: Identifiable, Equatable { .init( id: "inter", title: "Inter", - detail: "Nerazzurri. Pazza Inter", + detail: "Nerazzurri. Pazza Inter.", icon: Image(systemName: "bolt.fill") + ), + .init( + id: "real_madrid", + title: "Real Madrid", + detail: "Blancos. Reyes de Europa.", + icon: Image(systemName: "crown.fill") + ), + .init( + id: "barcelona", + title: "FC Barcelona", + detail: "Blaugrana. Més que un club.", + icon: Image(systemName: "circle.grid.cross.fill") + ), + .init( + id: "atletico", + title: "Atlético de Madrid", + detail: "Colchoneros. Coraje y corazón.", + icon: Image(systemName: "heart.fill") + ), + .init( + id: "bayern", + title: "Bayern München", + detail: "Rekordmeister. Dominio alemán.", + icon: Image(systemName: "star.fill") + ), + .init( + id: "liverpool", + title: "Liverpool", + detail: "Reds. You'll Never Walk Alone.", + icon: Image(systemName: "music.note.list") + ), + .init( + id: "manchester", + title: "Manchester United", + detail: "Red Devils. Theatre of Dreams.", + icon: Image(systemName: "suit.spade.fill") ) ] - ScrollView { - SwipeableListView( - items: items, - isSwipeDisabled: { $0.id == "milan" }, - rowContent: { item in - /// This is intentionally a Button to prove the swipe still works. - Button {} label: { - HStack(alignment: .top, spacing: 16) { - if let icon = item.icon { - icon - .font(.system(size: 16, weight: .semibold)) - .padding(8) - .background(.secondary.opacity(0.15), in: Circle()) - } - - VStack(alignment: .leading, spacing: 6) { - Text(item.title).bold() - Text(item.detail) - .font(.caption) - .foregroundStyle(.secondary) + NavigationStack { + ScrollView { + SwipeableListView( + items: items, + isSwipeDisabled: { $0.id == "milan" }, + rowContent: { item in + /// This is intentionally a Button to prove the swipe still works. + Button {} label: { + HStack(alignment: .top, spacing: 16) { + if let icon = item.icon { + icon + .font(.system(size: 16, weight: .semibold)) + .padding(8) + .background(.secondary.opacity(0.15), in: Circle()) + .frame(width: 44, height: 44) + } + + VStack(alignment: .leading, spacing: 6) { + Text(item.title).bold() + Text(item.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) } - - Spacer(minLength: 0) + .padding(16) + } + .buttonStyle(.plain) + .background(Color(uiColor: UIColor.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(.separator.opacity(0.25), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + }, + onDelete: { id in + withAnimation(.swipeable) { + items.removeAll { $0.id == id } } - .padding(16) - } - .buttonStyle(.plain) - .background(Color(uiColor: UIColor.systemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(.separator.opacity(0.25), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - }, - onDelete: { id in - withAnimation(.swipeable) { - items.removeAll { $0.id == id } } - } - ) - .padding(16) + ) + .padding(16) + } + .navigationTitle("Top Football Teams") .background(Color(uiColor: UIColor.secondarySystemBackground)) } } From a3acf2285f7b9cd6d143c3bf17c97d988e44fcc7 Mon Sep 17 00:00:00 2001 From: michele-theleftbit Date: Wed, 25 Feb 2026 13:45:42 +0100 Subject: [PATCH 4/5] fix: stabilize swipe gesture using dedicated edge hit area --- .../SwiftUI/Views/SwipeableListView.swift | 176 ++++++++++-------- 1 file changed, 96 insertions(+), 80 deletions(-) diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift index 8908c0e4..44bfc12f 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift @@ -81,29 +81,25 @@ private struct Item: Identifiable, Equatable { items: items, isSwipeDisabled: { $0.id == "milan" }, rowContent: { item in - /// This is intentionally a Button to prove the swipe still works. - Button {} label: { - HStack(alignment: .top, spacing: 16) { - if let icon = item.icon { - icon - .font(.system(size: 16, weight: .semibold)) - .padding(8) - .background(.secondary.opacity(0.15), in: Circle()) - .frame(width: 44, height: 44) - } - - VStack(alignment: .leading, spacing: 6) { - Text(item.title).bold() - Text(item.detail) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 0) + HStack(alignment: .top, spacing: 16) { + if let icon = item.icon { + icon + .font(.system(size: 16, weight: .semibold)) + .padding(8) + .background(.secondary.opacity(0.15), in: Circle()) + .frame(width: 44, height: 44) } - .padding(16) + + VStack(alignment: .leading, spacing: 6) { + Text(item.title).bold() + Text(item.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) } - .buttonStyle(.plain) + .padding(16) .background(Color(uiColor: UIColor.systemBackground)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) @@ -137,8 +133,9 @@ public struct SwipeableListView: View { private let isSwipeDisabled: (Item) -> Bool public typealias ID = Item.ID - public typealias DeleteHandler = (ID) -> Void - private let onDelete: DeleteHandler + public typealias Handler = (ID) -> () + private let onTap: Handler? + private let onDelete: Handler @State var openRowID: ID? @@ -148,12 +145,14 @@ public struct SwipeableListView: View { items: [Item], isSwipeDisabled: @escaping (Item) -> Bool = { _ in false }, @ViewBuilder rowContent: @escaping (Item) -> RowContent, - onDelete: @escaping DeleteHandler + onTap: Handler? = nil, + onDelete: @escaping Handler ) { self.spacing = spacing self.items = items self.isSwipeDisabled = isSwipeDisabled self.rowContent = rowContent + self.onTap = onTap self.onDelete = onDelete } @@ -165,6 +164,7 @@ public struct SwipeableListView: View { isDisabled: isSwipeDisabled(item), openRowID: $openRowID, content: { rowContent(item) }, + onTap: onTap, onDelete: onDelete ) .transition(.swipeableRow) @@ -187,56 +187,75 @@ struct SwipeableRow: View { var dragOffsetX: Double = 0 private let id: ID - private let onDelete: (ID) -> () - private let content: () -> Content private let isDisabled: Bool + private let content: () -> Content + private let onTap: ((ID) -> ())? + private let onDelete: (ID) -> () init( id: ID, isDisabled: Bool, openRowID: Binding, @ViewBuilder content: @escaping () -> Content, + onTap: ((ID) -> ())?, onDelete: @escaping (ID) -> Void, ) { self.id = id self.isDisabled = isDisabled self._openRowID = openRowID self.content = content + self.onTap = onTap self.onDelete = onDelete } var body: some View { - ZStack { - HStack { - Spacer() - actionsView - } - #if os(Android) - .zIndex(baseOffsetX != 0 ? 1.0 : 0.0) - #endif - - content() - .contentRectangleShape() - .offset(x: effectiveOffsetX) - .animation(.swipeable, value: effectiveOffsetX) - #if canImport(Darwin) - .highPriorityGesture(dragGesture) - #else - .zIndex(baseOffsetX != 0 ? 0.0 : 1.0) - .gesture(dragGesture) - #endif + ZStack(alignment: .trailing) { + actionsView + contentView + swipeHitArea } - .contentRectangleShape() .onChange(of: openRowID) { _, newValue in - guard newValue != id, baseOffsetX != 0 else { return } + guard newValue != id, (baseOffsetX != 0 || dragOffsetX != 0) else { return } close(animated: true) + dragOffsetX = 0 } .onChange(of: isDisabled) { _, newValue in - close(animated: true) + guard newValue else { return } + close(animated: true, clearOpen: true) } } + // MARK: - ViewBuilders + @ViewBuilder + private var contentView: some View { + Button { + openRowID = nil + onTap?(id) + } label: { + content() + .contentRectangleShape() + #if os(Android) + .zIndex(baseOffsetX != 0 ? 0.0 : 1.0) + #endif + } + .buttonStyle(.plain) + .offset(x: effectiveOffsetX) + .animation(.swipeable, value: effectiveOffsetX) + } + + @ViewBuilder + private var swipeHitArea: some View { + if !isDisabled { + Rectangle() + .fill(Color.clear) + .frame(width: 60) + .contentRectangleShape() + .gesture(dragGesture) + .offset(x: effectiveOffsetX) + } + } + @ViewBuilder private var actionsView: some View { HStack(spacing: 16) { @@ -260,6 +279,10 @@ struct SwipeableRow: View { } .padding(.trailing, Constants.trailingPadding) .frame(width: actionTrayWidth, alignment: .trailing) + .opacity(revealProgress) + #if os(Android) + .zIndex(baseOffsetX != 0 ? 1.0 : 0.0) + #endif } // MARK: - Gesture @@ -270,24 +293,25 @@ struct SwipeableRow: View { guard !isDisabled else { return } let x = value.translation.width let y = value.translation.height - guard abs(x) > abs(y) else { return } + guard abs(x) > abs(y), x <= 0 else { return } + if openRowID != id { openRowID = id } dragOffsetX = x } .onEnded { value in guard !isDisabled else { - finishDrag() + dragOffsetX = 0 return } - defer { finishDrag() } - + defer { dragOffsetX = 0 } + let x = value.translation.width let predicted = x + (value.predictedEndTranslation.width - x) * 0.25 guard x <= 0 || predicted <= 0 else { - closeAndClearOpen(animated: true) + close(animated: true, clearOpen: true) return } if effectiveOffsetX <= Constants.deleteThreshold || predicted <= Constants.deleteThreshold { - closeAndClearOpen(animated: false) + close(animated: false, clearOpen: true) onDelete(id) return } @@ -295,48 +319,34 @@ struct SwipeableRow: View { withAnimation(.swipeable) { baseOffsetX = openSnapX } openRowID = id } else { - closeAndClearOpen(animated: true) + close(animated: true, clearOpen: true) } } } - - private func close(animated: Bool) { + + private func close(animated: Bool, clearOpen: Bool = false) { if animated { withAnimation(.swipeable) { baseOffsetX = 0 } } else { baseOffsetX = 0 } - } - - private func clearOpenIfNeeded() { - if openRowID == id { + if clearOpen && openRowID == id { openRowID = nil } } - - private func closeAndClearOpen(animated: Bool) { - close(animated: animated) - clearOpenIfNeeded() - } - - private func finishDrag() { - dragOffsetX = 0 - } - - private var openSnapX: Double { - -actionTrayWidth - } - - private var effectiveOffsetX: Double { - clamp(baseOffsetX + dragOffsetX, min: openSnapX - 80, max: 0) - } + + private var openSnapX: Double { -actionTrayWidth } private var actionTrayWidth: Double { Constants.actionButtonSize + (Constants.trailingPadding * 2) } + + private var revealProgress: Double { + (-effectiveOffsetX / actionTrayWidth).clamped(to: 0...1) + } - private func clamp(_ value: Double, min: Double, max: Double) -> Double { - Swift.min(Swift.max(value, min), max) + private var effectiveOffsetX: Double { + (baseOffsetX + dragOffsetX).clamped(to: (openSnapX - 80)...0) } } @@ -349,6 +359,12 @@ private enum Constants { // MARK: - Extensions +extension Double { + func clamped(to range: ClosedRange) -> Double { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} + extension Animation { static var swipeable: Animation { .interactiveSpring(response: 0.25, dampingFraction: 0.92) From 74df7fc67864ef114bc7589c36304bce4cb92e2f Mon Sep 17 00:00:00 2001 From: michele-theleftbit Date: Wed, 25 Feb 2026 15:58:52 +0100 Subject: [PATCH 5/5] refactor: improve swipe gesture consistency --- .../SwiftUI/Views/SwipeableListView.swift | 166 +++++++++--------- 1 file changed, 87 insertions(+), 79 deletions(-) diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift index 44bfc12f..9850ab8b 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/SwipeableListView.swift @@ -81,25 +81,33 @@ private struct Item: Identifiable, Equatable { items: items, isSwipeDisabled: { $0.id == "milan" }, rowContent: { item in - HStack(alignment: .top, spacing: 16) { - if let icon = item.icon { - icon - .font(.system(size: 16, weight: .semibold)) - .padding(8) - .background(.secondary.opacity(0.15), in: Circle()) - .frame(width: 44, height: 44) + /// This is intentionally a Button to prove the swipe still works. + Button {} label: { + HStack(alignment: .top, spacing: 16) { + if let icon = item.icon { + icon + .font(.system(size: 16, weight: .semibold)) + .padding(8) + .background(.black.opacity(0.15), in: Circle()) + .foregroundStyle(.black) + .frame(width: 44, height: 44) + } + + VStack(alignment: .leading, spacing: 6) { + Text(item.title) + .bold() + .foregroundStyle(.black) + + Text(item.detail) + .font(.caption) + .foregroundStyle(.black.opacity(0.5)) + } + .multilineTextAlignment(.leading) + + Spacer(minLength: 0) } - - VStack(alignment: .leading, spacing: 6) { - Text(item.title).bold() - Text(item.detail) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 0) + .padding(16) } - .padding(16) .background(Color(uiColor: UIColor.systemBackground)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) @@ -126,33 +134,32 @@ private struct Item: Identifiable, Equatable { public struct SwipeableListView: View { + @State + var items: [Item] + + @State + var openRowID: ID? + private let spacing: Double - private let items: [Item] private let rowContent: (Item) -> RowContent private let isSwipeDisabled: (Item) -> Bool public typealias ID = Item.ID public typealias Handler = (ID) -> () - private let onTap: Handler? private let onDelete: Handler - @State - var openRowID: ID? - public init( spacing: Double = 4, items: [Item], isSwipeDisabled: @escaping (Item) -> Bool = { _ in false }, @ViewBuilder rowContent: @escaping (Item) -> RowContent, - onTap: Handler? = nil, onDelete: @escaping Handler ) { self.spacing = spacing self.items = items self.isSwipeDisabled = isSwipeDisabled self.rowContent = rowContent - self.onTap = onTap self.onDelete = onDelete } @@ -164,8 +171,14 @@ public struct SwipeableListView: View { isDisabled: isSwipeDisabled(item), openRowID: $openRowID, content: { rowContent(item) }, - onTap: onTap, - onDelete: onDelete + handler: { id in + /// Remove locally to keep UI consistency while + /// the parent-owned source of truth updates. + onDelete(id) + withAnimation(.swipeable) { + items.removeAll { $0.id == id } + } + } ) .transition(.swipeableRow) } @@ -189,30 +202,26 @@ struct SwipeableRow: View { private let id: ID private let isDisabled: Bool private let content: () -> Content - private let onTap: ((ID) -> ())? - private let onDelete: (ID) -> () + private let handler: (ID) -> () init( id: ID, isDisabled: Bool, openRowID: Binding, @ViewBuilder content: @escaping () -> Content, - onTap: ((ID) -> ())?, - onDelete: @escaping (ID) -> Void, + handler: @escaping (ID) -> Void ) { self.id = id self.isDisabled = isDisabled self._openRowID = openRowID self.content = content - self.onTap = onTap - self.onDelete = onDelete + self.handler = handler } var body: some View { ZStack(alignment: .trailing) { actionsView contentView - swipeHitArea } .onChange(of: openRowID) { _, newValue in guard newValue != id, (baseOffsetX != 0 || dragOffsetX != 0) else { return } @@ -229,38 +238,27 @@ struct SwipeableRow: View { @ViewBuilder private var contentView: some View { - Button { - openRowID = nil - onTap?(id) - } label: { - content() - .contentRectangleShape() - #if os(Android) - .zIndex(baseOffsetX != 0 ? 0.0 : 1.0) - #endif - } - .buttonStyle(.plain) - .offset(x: effectiveOffsetX) - .animation(.swipeable, value: effectiveOffsetX) - } - - @ViewBuilder - private var swipeHitArea: some View { - if !isDisabled { - Rectangle() - .fill(Color.clear) - .frame(width: 60) - .contentRectangleShape() - .gesture(dragGesture) - .offset(x: effectiveOffsetX) - } + content() + .contentRectangleShape() + .offset(x: effectiveOffsetX) + .animation(.swipeable, value: effectiveOffsetX) + .zIndex(baseOffsetX != 0 ? 0 : 1) + .overlay(alignment: .trailing) { + if !isDisabled { + Color.clear + .frame(width: Constants.swipeActivationWidth) + .contentRectangleShape() + .gesture(dragGesture) + .zIndex(999) + } + } } @ViewBuilder private var actionsView: some View { HStack(spacing: 16) { Button { - onDelete(id) + handler(id) } label: { ZStack { Circle() @@ -275,14 +273,10 @@ struct SwipeableRow: View { .foregroundStyle(.red) } } - .buttonStyle(.plain) } - .padding(.trailing, Constants.trailingPadding) - .frame(width: actionTrayWidth, alignment: .trailing) + .frame(width: Constants.swipeActivationWidth, alignment: .trailing) .opacity(revealProgress) - #if os(Android) - .zIndex(baseOffsetX != 0 ? 1.0 : 0.0) - #endif + .zIndex(baseOffsetX != 0 ? 1 : 0) } // MARK: - Gesture @@ -310,12 +304,12 @@ struct SwipeableRow: View { close(animated: true, clearOpen: true) return } - if effectiveOffsetX <= Constants.deleteThreshold || predicted <= Constants.deleteThreshold { + if effectiveOffsetX <= -Constants.destructiveSwipeThreshold || predicted <= -Constants.destructiveSwipeThreshold { close(animated: false, clearOpen: true) - onDelete(id) + handler(id) return } - if effectiveOffsetX <= Constants.openThreshold || predicted <= Constants.openThreshold { + if effectiveOffsetX <= -Constants.swipeActivationWidth || predicted <= -Constants.swipeActivationWidth { withAnimation(.swipeable) { baseOffsetX = openSnapX } openRowID = id } else { @@ -335,26 +329,40 @@ struct SwipeableRow: View { } } - private var openSnapX: Double { -actionTrayWidth } + private var openSnapX: Double { -Constants.swipeActivationWidth } - private var actionTrayWidth: Double { - Constants.actionButtonSize + (Constants.trailingPadding * 2) - } - private var revealProgress: Double { - (-effectiveOffsetX / actionTrayWidth).clamped(to: 0...1) + (-effectiveOffsetX / Constants.swipeActivationWidth).clamped(to: 0...1) } - private var effectiveOffsetX: Double { - (baseOffsetX + dragOffsetX).clamped(to: (openSnapX - 80)...0) + (baseOffsetX + dragOffsetX).clamped(to: (openSnapX - Constants.swipeOvershoot)...0) } } private enum Constants { - static let trailingPadding: Double = 16 + + /// Width of the invisible swipe hit area on the trailing edge. + /// Defines how far the user must drag horizontally for the swipe + /// gesture to be considered intentional. + static let swipeActivationWidth: Double = 60 + + /// Maximum extra distance the row can be dragged beyond its final + /// resting position. Provides a subtle elastic "overshoot" effect + /// during the swipe interaction. + static let swipeOvershoot: Double = 80 + + /// Horizontal distance required to trigger a destructive action + /// (e.g. delete) when releasing the swipe gesture. + /// Larger than the activation width to avoid accidental deletions. + static let destructiveSwipeThreshold: Double = 260 + + /// Padding applied to the action tray to keep destructive + /// actions visually separated from the row content. + static let padding: Double = 16 + + /// Size of the circular action button (e.g. delete). + /// Aligned with platform touch target recommendations. static let actionButtonSize: Double = 44 - static let openThreshold: Double = -60 - static let deleteThreshold: Double = -240 } // MARK: - Extensions