Skip to content

Swipe Action Row

A row that reveals action buttons under a horizontal swipe and snaps open or closed.

Swipe Action Row, playing
Download .swift
Origin
Original work
Found by
swipe · row · swipe actions · delete · archive · list · drag · deslizar · fila · acciones · borrar · archivar · lista · arrastrar

A row that reveals action buttons under a horizontal swipe and snaps open or closed.

Only one row is open at a time: opening another closes it, as in a system list. Vertical drags are left to the scroll view around it. VoiceOver reaches the same buttons as custom actions, and with Reduce Motion on the row settles without animating.

struct SwipeActionRowDemo: View {
private struct Message: Identifiable {
let id: String
let preview: String
}
private static let allMessages = [
Message(id: "Weekly report", preview: "The numbers look great this week"),
Message(id: "Flight update", preview: "Your gate has changed to B12"),
Message(id: "Dinner on Friday?", preview: "I found a new place downtown"),
Message(id: "Design review", preview: "Notes from this morning's session"),
]
@State private var messages = allMessages
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("Inbox")
.font(.largeTitle.bold())
.padding(.bottom, 4)
ForEach(messages) { message in
SwipeActionRow(actions: actions(for: message)) {
row(message)
}
.clipShape(.rect(cornerRadius: 18))
}
// Only for this demo: a way back once every row is gone.
if messages.isEmpty {
ContentUnavailableView {
Label("Inbox zero", systemImage: "tray")
} actions: {
Button("Start over") {
withAnimation(reduceMotion ? nil : .snappy) { messages = Self.allMessages }
}
.buttonStyle(.borderedProminent)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 24)
}
.background(Color(uiColor: .systemGroupedBackground))
}
private func row(_ message: Message) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(message.id)
.font(.headline)
Text(message.preview)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
}
private func actions(for message: Message) -> [SwipeActionRowAction] {
[
SwipeActionRowAction(id: "archive", title: "Archive", systemImage: "archivebox.fill", tint: .indigo) {
remove(message)
},
SwipeActionRowAction(id: "delete", title: "Delete", systemImage: "trash.fill", tint: .red) {
remove(message)
},
]
}
private func remove(_ message: Message) {
withAnimation(reduceMotion ? nil : .snappy) {
messages.removeAll { $0.id == message.id }
}
}
}
ParameterTypeDefault
actions[SwipeActionRowAction]Required
buttonWidthCGFloat80
content() -> ContentRequired

Copy this code into your project, or download the file.

SwipeActionRow.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct SwipeActionRowAction: Identifiable {
let id: String
let title: LocalizedStringResource
let systemImage: String
let tint: Color
let handler: () -> Void
}
enum SwipeActionRowPhysics {
/// Where the row sits for a drag, negative toward the buttons: it cannot move past closed and resists past fully open.
static func offset(resting: CGFloat, translation: CGFloat, revealWidth: CGFloat) -> CGFloat {
let raw = resting + translation
if raw > 0 { return 0 }
if raw < -revealWidth { return -revealWidth + (raw + revealWidth) * 0.25 }
return raw
}
static func settlesOpen(resting: CGFloat, predictedTranslation: CGFloat, revealWidth: CGFloat) -> Bool {
revealWidth > 0 && resting + predictedTranslation < -revealWidth / 2
}
}
/// The one row that is open, shared by every row, so opening another closes it.
@MainActor @Observable private final class SwipeActionRowOpenRow {
static let shared = SwipeActionRowOpenRow()
var id: UUID?
}
/// A row that reveals action buttons under a horizontal swipe and snaps open or closed.
///
/// Only one row is open at a time: opening another closes it, as in a system list.
/// Vertical drags are left to the scroll view around it. VoiceOver reaches the same buttons as
/// custom actions, and with Reduce Motion on the row settles without animating.
struct SwipeActionRow<Content: View>: View {
let actions: [SwipeActionRowAction]
var buttonWidth: CGFloat = 80
@ViewBuilder var content: () -> Content
@State private var id = UUID()
@GestureState private var translation: CGFloat = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.layoutDirection) private var layoutDirection
/// Gestures report screen points, but layout mirrors in right-to-left layouts: 1 or -1 converts between them.
private var forward: CGFloat { layoutDirection == .rightToLeft ? -1 : 1 }
private var isOpen: Bool { SwipeActionRowOpenRow.shared.id == id }
private var width: CGFloat { max(buttonWidth, 0) }
private var revealWidth: CGFloat { CGFloat(actions.count) * width }
private var resting: CGFloat { isOpen ? -revealWidth : 0 }
private var animation: Animation? {
reduceMotion ? nil : .snappy(duration: 0.3)
}
var body: some View {
content()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.accessibilityElement(children: .combine)
.accessibilityActions {
ForEach(actions) { action in
Button(action.title, action: action.handler)
}
}
.overlay {
if isOpen {
Color.clear
.contentShape(.rect)
.onTapGesture { close() }
.accessibilityHidden(true)
}
}
.offset(x: SwipeActionRowPhysics.offset(resting: resting, translation: translation, revealWidth: revealWidth))
.background(alignment: .trailing) { buttons }
.clipped()
.simultaneousGesture(swipe)
// A tap on another row closes the one that is open, and still reaches that row.
.simultaneousGesture(TapGesture().onEnded {
if !isOpen { withAnimation(animation) { SwipeActionRowOpenRow.shared.id = nil } }
})
.animation(animation, value: translation == 0)
}
private var buttons: some View {
HStack(spacing: 0) {
ForEach(actions) { action in
Button {
close()
action.handler()
} label: {
VStack(spacing: 4) {
Image(systemName: action.systemImage)
.font(.title3)
Text(action.title)
.font(.caption.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.foregroundStyle(SwipeActionRowContrastingForeground(background: action.tint))
.padding(.horizontal, 4)
.frame(width: width)
.frame(maxHeight: .infinity)
.background(action.tint)
}
.buttonStyle(.plain)
}
}
.accessibilityHidden(true)
}
private var swipe: some Gesture {
DragGesture(minimumDistance: 14)
.updating($translation) { value, state, _ in
if state != 0 || abs(value.translation.width) > abs(value.translation.height) {
state = value.translation.width * forward
}
}
.onEnded { value in
guard abs(value.translation.width) > abs(value.translation.height) else { return }
let opens = SwipeActionRowPhysics.settlesOpen(
resting: resting,
predictedTranslation: value.predictedEndTranslation.width * forward,
revealWidth: revealWidth
)
withAnimation(animation) { setOpen(opens) }
}
}
private func close() {
withAnimation(animation) { setOpen(false) }
}
private func setOpen(_ opens: Bool) {
let openRow = SwipeActionRowOpenRow.shared
if opens {
openRow.id = id
} else if openRow.id == id {
openRow.id = nil
}
}
}
/// White or black, whichever reads better on `background`. Black only on a
/// light color, so the system's own colors keep white.
nonisolated struct SwipeActionRowContrastingForeground: ShapeStyle {
let background: Color
func resolve(in environment: EnvironmentValues) -> Color {
let color = background.resolve(in: environment)
let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue
return luminance > 0.5 ? .black : .white
}
}
// MARK: - Demo
struct SwipeActionRowDemo: View {
private struct Message: Identifiable {
let id: String
let preview: String
}
private static let allMessages = [
Message(id: "Weekly report", preview: "The numbers look great this week"),
Message(id: "Flight update", preview: "Your gate has changed to B12"),
Message(id: "Dinner on Friday?", preview: "I found a new place downtown"),
Message(id: "Design review", preview: "Notes from this morning's session"),
]
@State private var messages = allMessages
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("Inbox")
.font(.largeTitle.bold())
.padding(.bottom, 4)
ForEach(messages) { message in
SwipeActionRow(actions: actions(for: message)) {
row(message)
}
.clipShape(.rect(cornerRadius: 18))
}
// Only for this demo: a way back once every row is gone.
if messages.isEmpty {
ContentUnavailableView {
Label("Inbox zero", systemImage: "tray")
} actions: {
Button("Start over") {
withAnimation(reduceMotion ? nil : .snappy) { messages = Self.allMessages }
}
.buttonStyle(.borderedProminent)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 24)
}
.background(Color(uiColor: .systemGroupedBackground))
}
private func row(_ message: Message) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(message.id)
.font(.headline)
Text(message.preview)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
}
private func actions(for message: Message) -> [SwipeActionRowAction] {
[
SwipeActionRowAction(id: "archive", title: "Archive", systemImage: "archivebox.fill", tint: .indigo) {
remove(message)
},
SwipeActionRowAction(id: "delete", title: "Delete", systemImage: "trash.fill", tint: .red) {
remove(message)
},
]
}
private func remove(_ message: Message) {
withAnimation(reduceMotion ? nil : .snappy) {
messages.removeAll { $0.id == message.id }
}
}
}
#Preview {
SwipeActionRowDemo()
}