Add custom row swipe actions to a SwiftUI List
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} | |
} |
@dinkar1708
Maybe We need assign row height to GeometryReader something like frame(height: rowHeight)
.
Or , this could be better. In List, each Row dose not know the height it self. So, we can measure these aproach below.
https://stackoverflow.com/questions/61311007/dynamically-size-a-geometryreader-height-based-on-its-elements/61315678#61315678
@globulus
Thank you very much for showing this. really helpful!
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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")
}
is it possible to make swipe action view which can accept any kind of view and view can stretch properly?