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

- Gestures
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- swipe · row · swipe actions · delete · archive · list · drag · deslizar · fila · acciones · borrar · archivar · lista · arrastrar
Description
Section titled “Description”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 } } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
actions | [SwipeActionRowAction] | Required |
buttonWidth | CGFloat | 80 |
content | () -> Content | Required |
Copy this code into your project, or download the file.
// 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()}