Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Add custom row swipe actions to a SwiftUI List
// Full recipe at https://swiftuirecipes.com/blog/swiftui-list-custom-row-swipe-actions-all-versions
import SwiftUI
// Button in swipe action, renders text or image and can have background color
struct SwipeActionButton: View, Identifiable {
static let width: CGFloat = 70
let id = UUID()
let text: Text?
let icon: Image?
let action: () -> Void
let tint: Color?
init(text: Text? = nil,
icon: Image? = nil,
action: @escaping () -> Void,
tint: Color? = nil) {
self.text = text
self.icon = icon
self.action = action
self.tint = tint ?? .gray
}
var body: some View {
ZStack {
tint
VStack {
icon?
.foregroundColor(.white)
if icon == nil {
text?
.foregroundColor(.white)
}
}
.frame(width: SwipeActionButton.width)
}
}
}
// Adds custom swipe actions to a given view
@available(iOS 13.0, *)
struct SwipeActionView: ViewModifier {
// How much does the user have to swipe at least to reveal buttons on either side
private static let minSwipeableWidth = SwipeActionButton.width * 0.8
// Buttons at the leading (left-hand) side
let leading: [SwipeActionButton]
// Can you full swipe the leading side
let allowsFullSwipeLeading: Bool
// Buttons at the trailing (right-hand) side
let trailing: [SwipeActionButton]
// Can you full swipe the trailing side
let allowsFullSwipeTrailing: Bool
private let totalLeadingWidth: CGFloat!
private let totalTrailingWidth: CGFloat!
@State private var offset: CGFloat = 0
@State private var prevOffset: CGFloat = 0
init(leading: [SwipeActionButton] = [],
allowsFullSwipeLeading: Bool = false,
trailing: [SwipeActionButton] = [],
allowsFullSwipeTrailing: Bool = false) {
self.leading = leading
self.allowsFullSwipeLeading = allowsFullSwipeLeading && !leading.isEmpty
self.trailing = trailing
self.allowsFullSwipeTrailing = allowsFullSwipeTrailing && !trailing.isEmpty
totalLeadingWidth = SwipeActionButton.width * CGFloat(leading.count)
totalTrailingWidth = SwipeActionButton.width * CGFloat(trailing.count)
}
func body(content: Content) -> some View {
// Use a GeometryReader to get the size of the view on which we're adding
// the custom swipe actions.
GeometryReader { geo in
// Place leading buttons, the wrapped content and trailing buttons
// in an HStack with no spacing.
HStack(spacing: 0) {
// If any swiping on the left-hand side has occurred, reveal
// leading buttons. This also resolves button flickering.
if offset > 0 {
// If the user has swiped enough for it to qualify as a full swipe,
// render just the first button across the entire swipe length.
if fullSwipeEnabled(edge: .leading, width: geo.size.width) {
button(for: leading.first)
.frame(width: offset, height: geo.size.height)
} else {
// If we aren't in a full swipe, render all buttons with widths
// proportional to the swipe length.
ForEach(leading) { actionView in
button(for: actionView)
.frame(width: individualButtonWidth(edge: .leading),
height: geo.size.height)
}
}
}
// This is the list row itself
content
// Add horizontal padding as we removed it to allow the
// swipe buttons to occupy full row height.
.padding(.horizontal, 16)
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
.offset(x: (offset > 0) ? 0 : offset)
// If any swiping on the right-hand side has occurred, reveal
// trailing buttons. This also resolves button flickering.
if offset < 0 {
Group {
// If the user has swiped enough for it to qualify as a full swipe,
// render just the last button across the entire swipe length.
if fullSwipeEnabled(edge: .trailing, width: geo.size.width) {
button(for: trailing.last)
.frame(width: -offset, height: geo.size.height)
} else {
// If we aren't in a full swipe, render all buttons with widths
// proportional to the swipe length.
ForEach(trailing) { actionView in
button(for: actionView)
.frame(width: individualButtonWidth(edge: .trailing),
height: geo.size.height)
}
}
}
// The leading buttons need to move to the left as the swipe progresses.
.offset(x: offset)
}
}
// animate the view as `offset` changes
.animation(.spring(), value: offset)
// allows the DragGesture to work even if there are now interactable
// views in the row
.contentShape(Rectangle())
// The DragGesture distates the swipe. The minimumDistance is there to
// prevent the gesture from interfering with List vertical scrolling.
.gesture(DragGesture(minimumDistance: 10,
coordinateSpace: .local)
.onChanged { gesture in
// Compute the total swipe based on the gesture values.
var total = gesture.translation.width + prevOffset
if !allowsFullSwipeLeading {
total = min(total, totalLeadingWidth)
}
if !allowsFullSwipeTrailing {
total = max(total, -totalTrailingWidth)
}
offset = total
}
.onEnded { _ in
// Adjust the offset based on if the user has swiped enough to reveal
// all the buttons or not. Also handles full swipe logic.
if offset > SwipeActionView.minSwipeableWidth && !leading.isEmpty {
if !checkAndHandleFullSwipe(for: leading, edge: .leading, width: geo.size.width) {
offset = totalLeadingWidth
}
} else if offset < -SwipeActionView.minSwipeableWidth && !trailing.isEmpty {
if !checkAndHandleFullSwipe(for: trailing, edge: .trailing, width: -geo.size.width) {
offset = -totalTrailingWidth
}
} else {
offset = 0
}
prevOffset = offset
})
}
// Remove internal row padding to allow the buttons to occupy full row height
.listRowInsets(EdgeInsets())
}
// Checks if full swipe is supported and currently active for the given edge.
// The current threshold is at half of the row width.
private func fullSwipeEnabled(edge: Edge, width: CGFloat) -> Bool {
let threshold = abs(width) / 2
switch (edge) {
case .leading:
return allowsFullSwipeLeading && offset > threshold
case .trailing:
return allowsFullSwipeTrailing && -offset > threshold
}
}
// Creates the view for each SwipeActionButton. Also assigns it
// a tap gesture to handle the click and reset the offset.
private func button(for button: SwipeActionButton?) -> some View {
button?
.onTapGesture {
button?.action()
offset = 0
prevOffset = 0
}
}
// Calculates width for each button, proportional to the swipe.
private func individualButtonWidth(edge: Edge) -> CGFloat {
switch edge {
case .leading:
return (offset > 0) ? (offset / CGFloat(leading.count)) : 0
case .trailing:
return (offset < 0) ? (abs(offset) / CGFloat(trailing.count)) : 0
}
}
// Checks if the view is in full swipe. If so, trigger the action on the
// correct button (left- or right-most one), make it full the entire row
// and schedule everything to be reset after a while.
private func checkAndHandleFullSwipe(for collection: [SwipeActionButton],
edge: Edge,
width: CGFloat) -> Bool {
if fullSwipeEnabled(edge: edge, width: width) {
offset = width * CGFloat(collection.count) * 1.2
((edge == .leading) ? collection.first : collection.last)?.action()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
offset = 0
prevOffset = 0
}
return true
} else {
return false
}
}
private enum Edge {
case leading, trailing
}
}
extension View {
func swipeActions(leading: [SwipeActionButton] = [],
allowsFullSwipeLeading: Bool = false,
trailing: [SwipeActionButton] = [],
allowsFullSwipeTrailing: Bool = false) -> some View {
modifier(SwipeActionView(leading: leading,
allowsFullSwipeLeading: allowsFullSwipeLeading,
trailing: trailing,
allowsFullSwipeTrailing: allowsFullSwipeTrailing))
}
}
struct CustomSwipeActionTest: View {
var body: some View {
List(1..<20) {
Text("List view item at row \($0)")
.frame(alignment: .leading)
.swipeActions(leading: [
SwipeActionButton(text: Text("Text"), action: {
print("Text")
}),
SwipeActionButton(icon: Image(systemName: "flag"), action: {
print("Flag")
}, tint: .green)
],
allowsFullSwipeLeading: true,
trailing: [
SwipeActionButton(text: Text("Read"),
icon: Image(systemName: "envelope.open"),
action: {
print("Read")
}, tint: .blue),
SwipeActionButton(icon: Image(systemName: "trash"), action: {
print("Trash")
}, tint: .red)
],
allowsFullSwipeTrailing: true)
}
}
}
@Mcrich23
Copy link

Mcrich23 commented Feb 17, 2022

This keeps clipping my rows, how do I fix it?

@CyrilConraud
Copy link

CyrilConraud commented Feb 20, 2022

I think there is an error at line 143 :

total = max(total, -totalLeadingWidth)
should be :
total = max(total, -totalTrailingWidth)

Overwise the left swipe will break if we disable the trailing full swipe and if we have a different number of leading and trailing buttons
(if allowsFullSwipeTrailing == false && leading.count != trailing.count)

@globulus
Copy link
Author

globulus commented Feb 27, 2022

@CyrilConraud I believe you meant line 148? Because there really was a bug in there, thanks for noticing!

@globulus
Copy link
Author

globulus commented Feb 27, 2022

@Mcrich23 Could you provide a recording of views being clipped?

@Mcrich23
Copy link

Mcrich23 commented Feb 28, 2022

I just ended up making it ios15+ exclusive. Therefore, I don’t have a clip

@dinkar1708
Copy link

dinkar1708 commented Mar 13, 2022

if add more text buttons, h stack etc. instead of just Text at line https://gist.github.com/globulus/140f48754c470dbdfe3fc54c6b3c3399#file-swipeactions-swift-L245

example - view is not as expected
List(1..<20) { a in
VStack {
Text("fff")
Text("List view item at row (a)")
Text("lfjlljjjj")
}
Simulator Screen Shot - iPhone 12 Pro - 2022-03-13 at 18 28 55

is it possible to make swipe action view which can accept any kind of view and view can stretch properly?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment