Skip to content

Instantly share code, notes, and snippets.

@barbaramartina
Last active September 28, 2025 10:17
Show Gist options
  • Select an option

  • Save barbaramartina/ea984e91d3fdb38ab870723ec40050dc to your computer and use it in GitHub Desktop.

Select an option

Save barbaramartina/ea984e91d3fdb38ab870723ec40050dc to your computer and use it in GitHub Desktop.
Custom Swipe Actions in Lists. An alternative solution.
//
// 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