-
-
Save globulus/140f48754c470dbdfe3fc54c6b3c3399 to your computer and use it in GitHub Desktop.
| // 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) | |
| } | |
| } | |
| } |
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?
@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!
I just ended up making it ios15+ exclusive. Therefore, I don’t have a clip