// 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: 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() }