Last active
September 28, 2025 10:17
-
-
Save barbaramartina/ea984e91d3fdb38ab870723ec40050dc to your computer and use it in GitHub Desktop.
Custom Swipe Actions in Lists. An alternative solution.
This file contains hidden or 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
| // | |
| // CustomSwipeActionsSwiftUI | |
| // | |
| // Custom Swipe Actions in Lists are provided by default, however even if you add a title and an icon to the SwipeAction, | |
| // SwiftUI will decide internally which one to you (or both). So you do not have full control on how your swipe action | |
| // button appears... I was implementing a custom swipe action and discovered many pitfalls in the List + SwipeActions | |
| // combination in SwiftUI. Checkout how I solved it with a dragGesture and views on a ScrollView. | |
| // It's a lot of craft to make these custom actions work, so I do not recommend to use such a fancy alternative unless | |
| // you have extremely good reasons since you will loose the capabilities offered by the system to do swipe, drags, | |
| // gestures interactions calculations on its own, and it will cost you on maintenance in the future. | |
| // | |
| // Created by Barbara Rodeker on 27/09/25. | |
| // | |
| import SwiftUI | |
| /// component displaying a single tag as a list row | |
| struct ListItem<ViewModel: ListItemViewModelProtocol>: View { | |
| /// use this environment value to set it to true, if you add search capabilities to your swiftUI app) | |
| @Environment(\.isSearching) private var isSearching | |
| /// custom environment value to avoid horizontal gesture recognition and avoid colliding with the scrollView | |
| /// this custom swipe actions do not work with LIST, you need to create ListItems inside a ScrollView | |
| @Environment(\.isScrolling) private var isScrolling | |
| // TODO: - this would be something I would add for even more adaptive layouts | |
| /// For now this is used to calculate minimum and max unveiling and the action triggering max-width (for deletion) adaptive to the size class | |
| /// so... in "longer" (aka "regular") size classes the user can swipe a little bit more | |
| /// this is based on the assumption that the user will swipe left to make the Delete button appear on a listItem and they need to | |
| /// swipe left at least XX pixels before the button get fully visible | |
| @Environment(\.horizontalSizeClass) private var horizontalSizeClass | |
| /// required to call the "on delete" action for example, or any other action on your custom swipe actions | |
| @Bindable private var viewModel: ViewModel | |
| /// by using an scaled height, the list row will grow with the accessibility sizes | |
| @ScaledMetric private var height: CGFloat = 48 | |
| /// make the checkbox accessible, by make it grow and scale | |
| @ScaledMetric private var checkboxSize: CGFloat = 24 | |
| /// accessibility values for the pill view | |
| @ScaledMetric private var pillWidth: CGFloat = 20 | |
| @ScaledMetric private var pillHeight: CGFloat = 12 | |
| private var isDragging: Bool { offset != 0 } | |
| private var isResting: Bool { | |
| let isResting = offset == offsetDeleteButton && | |
| offsetDeleteButton == -maximalUnveilingOffset && | |
| previousOffset == offset | |
| return isResting | |
| } | |
| private let shadowRadius = 10.0 | |
| /// used to customize a drag gesture for the swipe - since SwiftUI swipeActions has some pitfalls and automatic management of the text and icons | |
| /// sometimes showing only the icon, depending on the size of a list's rows. The swipe design has required a customized handling. | |
| /// Check this article for more insigths: https://samwize.com/2024/06/09/pitfall-swiftui-swipeactions-not-working/ | |
| @State var offset: CGFloat = 0 | |
| /// when the user releases the row, without finishing the swipe... needs to be immediately hidden... | |
| @State var offsetDeleteButton: CGFloat = 0 | |
| @State var previousOffset: CGFloat = 0 | |
| private let rowHeight = 50.0 | |
| @State var previousOffsetHeight: CGFloat = 0 | |
| /// auxiliar property to show or hidden the rounded border and shadows on the listItem while it's been dragged | |
| private var dragGesture: some Gesture { | |
| DragGesture(minimumDistance: 30) | |
| .onChanged { value in | |
| // do not pay attention to the horizontal scrolling is the container scrollView is scrolling vertically | |
| // or if the user is searching | |
| let heightOffset = abs(value.translation.height - previousOffsetHeight) | |
| guard isSearching == false && | |
| isScrolling == false && | |
| heightOffset <= rowHeight else { | |
| stopDragging() | |
| return | |
| } | |
| // do not allow swipe to right gestures | |
| offset = min(0, previousOffset + value.translation.width) | |
| offsetDeleteButton = offset | |
| } | |
| .onEnded { value in | |
| // do not pay attention to the horizontal scrolling is the container scrollView is scrolling vertically | |
| // or if the user is searching | |
| let heightOffset = abs(value.translation.height - previousOffsetHeight) | |
| guard isSearching == false && isScrolling == false && heightOffset <= rowHeight else { | |
| stopDragging() | |
| return | |
| } | |
| guard -offset < maximalActionTriggeringOffset else { | |
| // TODO: make it generalized and reactive to the type of device / sizeClass | |
| let maximumOffset: CGFloat = 600 | |
| // time to apply the action (delete)... and ease out the row... | |
| withAnimation(.easeOut(duration: 0.4)) { | |
| offsetDeleteButton = -maximumOffset | |
| offset = -maximumOffset | |
| } completion: { | |
| viewModel.wantsToDelete() | |
| stopDragging() | |
| } | |
| return | |
| } | |
| // if the gesture already reached the unveiling width.. then, this is "resting state" == leave the offsets as they are | |
| guard -offset < maximalUnveilingOffset else { | |
| previousOffset = -maximalUnveilingOffset | |
| withAnimation(.easeIn(duration: 0.3)) { | |
| offsetDeleteButton = -maximalUnveilingOffset | |
| offset = -maximalUnveilingOffset | |
| } | |
| return | |
| } | |
| offsetDeleteButton = 0 | |
| previousOffset = 0 | |
| previousOffsetHeight = 0 | |
| withAnimation(.easeIn(duration: 0.3)) { | |
| offset = 0 | |
| } | |
| } | |
| } | |
| /// unless the drag reaches this point... the gesture does not start unveiling the delete button | |
| private var minimalUnveilingOffset: CGFloat = 25 | |
| /// when the button reaches the maximal unveiling width, there will be no more blurring on top, and the shadow + border of the listItem row will be totally visible (aka opactity == 1) | |
| private var maximalUnveilingOffset: CGFloat = 102 | |
| /// by this point the action will be triggered and the list row deleted | |
| private var maximalActionTriggeringOffset: CGFloat = 175 | |
| /// the x shift/offset for the list item depends on the trailing of the view | |
| /// the current drag(offset) value and a proportional amount calculated based | |
| /// of the stretching-area when the user starts swipping left - only if the drag gesture is enabled/been executed | |
| private var offsetXForListItem: CGFloat { | |
| let designLeading = 12.0 | |
| let trailingPadding: CGFloat = 8 | |
| let trailingMargin: CGFloat = 16 | |
| guard isDragging else { | |
| let leading = previousOffset != 0 ? (designLeading + trailingPadding + trailingMargin) : 0 | |
| return leading | |
| } | |
| return | |
| (offset - trailingPadding | |
| - proportionalIncrease( | |
| for: designLeading, | |
| with: maximalUnveilingOffset - trailingMargin - trailingPadding, | |
| and: maximalActionTriggeringOffset | |
| )) | |
| } | |
| private var restingOffsetXForListItem: CGFloat { | |
| // if it is not resting... no need to add more padding | |
| guard isResting else { return 0 } | |
| // if it is resting, set it to the design leading | |
| let designLeading = 12.0 | |
| let trailingPadding: CGFloat = 8 | |
| return -designLeading + offsetDeleteButton - trailingPadding | |
| } | |
| // MARK: - INIT | |
| init(viewModel: ViewModel) { | |
| self.viewModel = viewModel | |
| } | |
| // MARK: - BODY | |
| var body: some View { | |
| HStack(spacing: 0) { | |
| itemView | |
| .overlay(alignment: .trailing) { | |
| isDragging | |
| ? deleteButton | |
| : nil | |
| } | |
| .onChange(of: isSearching) { oldValue, newValue in | |
| // when the user starts searching, let's stop the dragging, to avoid the UI to remain in dragging / deletion state | |
| guard newValue == true else { return } | |
| stopDragging() | |
| } | |
| } | |
| } | |
| // MARK: - PRIVATE | |
| private func stopDragging() { | |
| offsetDeleteButton = 0 | |
| previousOffset = 0 | |
| offset = 0 | |
| } | |
| private var itemView: some View { | |
| HStack(spacing: 0) { | |
| colorPill | |
| .padding(.leading, 2) | |
| Text(viewModel.tag.tag) | |
| .padding(.leading, 10) | |
| .font(.body) | |
| .fixedSize(horizontal: true, vertical: false) | |
| .allowsTightening(true) | |
| .minimumScaleFactor(0.4) | |
| .dynamicTypeSize(...DynamicTypeSize.accessibility1) | |
| Spacer() | |
| Toggle(isOn: $viewModel.tag.selected) {} | |
| .toggleStyle(RoundedCheckbox()) | |
| .frame(width: checkboxSize, height: checkboxSize) | |
| .padding(.vertical, 12) | |
| } | |
| .padding(.horizontal, 20) | |
| .offset(x: isResting ? restingOffsetXForListItem : offsetXForListItem) | |
| .frame(height: height) | |
| .overlay { | |
| isDragging | |
| ? RoundedRectangle(cornerRadius: 12) | |
| .inset(by: 0.5) | |
| .stroke(.border, lineWidth: 0.5) | |
| .foregroundStyle(.white) | |
| .offset(x: isResting ? restingOffsetXForListItem : offsetXForListItem) | |
| .opacity( | |
| proportionalIncrease( | |
| for: 1.0, | |
| with: minimalUnveilingOffset, | |
| and: maximalUnveilingOffset | |
| ) | |
| ) | |
| : nil | |
| } | |
| .contentShape(Rectangle()) | |
| .simultaneousGesture(dragGesture) | |
| .highPriorityGesture(dragGesture) | |
| .background( | |
| isDragging | |
| ? RoundedRectangle(cornerRadius: 12) | |
| .fill( | |
| .white | |
| .opacity( | |
| proportionalIncrease( | |
| for: 1.0, | |
| with: minimalUnveilingOffset, | |
| and: maximalUnveilingOffset | |
| ) | |
| ) | |
| ) | |
| .shadow( | |
| color: .shadow, | |
| radius: proportionalIncrease( | |
| for: shadowRadius, | |
| with: minimalUnveilingOffset, | |
| and: maximalUnveilingOffset | |
| ), | |
| x: 0, | |
| y: 0 | |
| ) | |
| .offset(x: isResting ? restingOffsetXForListItem : offsetXForListItem) | |
| .opacity( | |
| proportionalIncrease( | |
| for: 1.0, | |
| with: minimalUnveilingOffset, | |
| and: maximalUnveilingOffset | |
| ) | |
| ) | |
| : nil | |
| ) | |
| .onTapGesture { | |
| viewModel.tag.selected.toggle() | |
| } | |
| .onChange(of: isScrolling, { oldValue, newValue in | |
| guard oldValue != newValue else { return } | |
| guard newValue == true else { return } | |
| // if the user opened the delete button but then he started scrolling.. close it | |
| stopDragging() | |
| }) | |
| } | |
| /// if this tiny view would become reused somewhere else, it could also be a component | |
| private var colorPill: some View { | |
| RoundedRectangle(cornerRadius: 6) | |
| .fill(Color(hex: viewModel.tag.hexColor)) | |
| .frame(width: pillWidth, height: pillHeight) | |
| .font(.body) | |
| } | |
| private var deleteButton: some View { | |
| Button { | |
| viewModel.wantsToDelete() | |
| offsetDeleteButton = 0 | |
| previousOffset = 0 | |
| offset = 0 | |
| } label: { | |
| HStack(spacing: 0) { | |
| Spacer() | |
| Image(systemName: "trash") | |
| .padding(.leading, 16) | |
| .padding(.trailing, 4) | |
| .padding(.vertical, 12) | |
| .foregroundStyle(.white) | |
| Text("Delete") | |
| .font(.subheadline) | |
| .fixedSize(horizontal: true, vertical: false) | |
| .foregroundStyle(.white) | |
| .padding(.trailing, 16) | |
| .dynamicTypeSize(...DynamicTypeSize.accessibility1) | |
| } | |
| .frame(height: height) | |
| .opacity( | |
| curveEaseInIncrease( | |
| for: 1.0, | |
| with: minimalUnveilingOffset, | |
| and: maximalUnveilingOffset | |
| ) | |
| ) | |
| } | |
| .frame( | |
| width: (-offsetDeleteButton > minimalUnveilingOffset) | |
| ? -offsetDeleteButton : 0 | |
| ) | |
| .background(.error) | |
| .clipShape(RoundedRectangle(cornerRadius: 12)) | |
| .overlay { | |
| RoundedRectangle(cornerRadius: 12) | |
| .foregroundStyle(.ultraThickMaterial) | |
| .frame( | |
| width: (-offsetDeleteButton > minimalUnveilingOffset) | |
| ? -offsetDeleteButton : 0 | |
| ) | |
| .opacity( | |
| (1 | |
| - proportionalIncrease( | |
| for: 1.0, | |
| with: minimalUnveilingOffset, | |
| and: maximalUnveilingOffset | |
| )) | |
| ) | |
| } | |
| .padding(.trailing, 8) | |
| } | |
| /// if the offset has not yet reached the minimal amount of point.. then the leading padding is 0 | |
| /// if the offset has reached the maximum amount of unveiling.. then everything is totally visible and the leading | |
| /// should not keep growing, but stay at 12. (maximun leading padding by the design) | |
| /// meanwhile... if the offset falls into the range of min-max unveiling... make it grow nicely | |
| /// proporpionality, to get a sticky effect - this is also the case for the shadow, and the border of the row | |
| private func proportionalIncrease( | |
| for uiValueMaxPossible: CGFloat, | |
| with rangeMinimum: CGFloat, | |
| and rangeMaximum: CGFloat | |
| ) -> CGFloat { | |
| guard -offset < rangeMaximum else { return uiValueMaxPossible } | |
| guard -offset > rangeMinimum else { return 0 } | |
| let normalizedOffset = -offset - rangeMinimum | |
| let normalizedInterval = rangeMaximum - rangeMinimum | |
| let proportional = normalizedOffset / normalizedInterval | |
| return uiValueMaxPossible * proportional | |
| } | |
| private func curveEaseInIncrease( | |
| for uiValueMaxPossible: CGFloat, | |
| with rangeMinimum: CGFloat, | |
| and rangeMaximum: CGFloat | |
| ) -> CGFloat { | |
| guard -offset < rangeMaximum else { return uiValueMaxPossible } | |
| guard -offset > rangeMinimum else { return 0 } | |
| let proportional = -offset / maximalUnveilingOffset | |
| return UnitCurve.easeIn.value(at: proportional) | |
| } | |
| } | |
| #Preview { | |
| ListItem( | |
| viewModel: ListItemViewModel( | |
| tag: Tag( | |
| id: 1, | |
| tag: "Positive feedback", | |
| hexColor: "#fffa4f", | |
| selected: false | |
| ), | |
| onDelete: { _ in } | |
| ) | |
| ) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment