Swipe actions are no longer trapped inside List in SwiftUI


Greetings, traveler!

swipeActions has always been closely associated with List. That was fine for many screens. If your UI was a classic settings page, inbox, notification list, or table-style screen, List gave you row gestures almost for free:

List(messages) { message in
    MessageRow(message: message)
        .swipeActions {
            Button(role: .destructive) {
                delete(message)
            } label: {
                Label("Delete", systemImage: "trash")
            }
        }
}

But real apps do not always fit into List. Sometimes you need a custom feed. Or a screen whose layout simply fits better in a ScrollView with a LazyVStack, whether because animations, card-based content, pinned elements, or more complex section structures.

Before iOS 27, that usually meant a tradeoff. You could keep the native swipe behavior and accept List, or you could build the layout you wanted and implement the gesture yourself.

What iOS 27 adds

SwiftUI gets two related APIs:

swipeActions(edge:allowsFullSwipe:content:onPresentationChanged:)

and:

swipeActionsContainer()

The first one is a new overload of the familiar swipeActions modifier. It still describes the actions attached to a row, but now it also gives you an onPresentationChanged callback.

The second one is the missing piece for custom containers. You apply swipeActionsContainer() to the parent container that holds rows with swipe actions.

The mental model is simple:

RowView()
    .swipeActions {
        ...
    }

ScrollView {
    ...
}
.swipeActionsContainer()

The row owns the actions. The container coordinates them. A list-like UI is not only a collection of independent rows. It also needs shared behavior: dismissing an opened row, making sure rows do not fight each other, and keeping the gesture interaction consistent across the container.

List already knows how to do this. A custom ScrollView does not, unless you give SwiftUI that scope explicitly.

Using swipe actions inside a custom ScrollView

Here is the basic shape:

struct MessagesView: View {
    let messages: [Message]
    let delete: (Message) -> Void

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 0) {
                ForEach(messages) { message in
                    MessageRow(message: message)
                        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                            Button(role: .destructive) {
                                delete(message)
                            } label: {
                                Label("Delete", systemImage: "trash")
                            }
                        }
                }
            }
        }
        .swipeActionsContainer()
    }
}

The code no longer needs a custom drag gesture just to reveal a delete button. You can keep the custom layout, but still use SwiftUI’s native swipe action system.

What onPresentationChanged gives you

The new onPresentationChanged closure tells you when the swipe actions become visible or hidden.

struct MessageRow: View {
    let message: Message
    let delete: (Message) -> Void

    @State private var isShowingActions = false

    var body: some View {
        HStack {
            Text(message.title)

            Spacer()

            Text(message.subtitle)
                .foregroundStyle(.secondary)
        }
        .padding()
        .opacity(isShowingActions ? 0.6 : 1)
        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
            Button(role: .destructive) {
                delete(message)
            } label: {
                Label("Delete", systemImage: "trash")
            }
        } onPresentationChanged: { isPresented in
            isShowingActions = isPresented
        }
    }
}

This is not a gesture progress callback. It does not tell you how far the user has swiped. It answers a question: are the actions currently presented?

That is enough for a few useful cases. You can dim the row, hide a floating button, pause another interaction, or store the currently opened item ID in the parent view.

For example:

struct MessagesView: View {
    let messages: [Message]
    let delete: (Message) -> Void

    @State private var openedMessageID: Message.ID?

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 0) {
                ForEach(messages) { message in
                    MessageRow(message: message)
                        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                            Button(role: .destructive) {
                                delete(message)
                            } label: {
                                Label("Delete", systemImage: "trash")
                            }
                        } onPresentationChanged: { isPresented in
                            if isPresented {
                                openedMessageID = message.id
                            } else if openedMessageID == message.id {
                                openedMessageID = nil
                            }
                        }
                }
            }
        }
        .swipeActionsContainer()
    }
}

Availability

These APIs are part of the iOS 27 SwiftUI SDK family. If your app supports older OS versions, you still need availability checks or a fallback path.

For example:

struct MessagesScreen: View {
    var body: some View {
        if #available(iOS 27.0, *) {
            CustomMessagesList()
        } else {
            LegacyMessagesList()
        }
    }
}

Conclusion

A lot of SwiftUI code ends up in this awkward middle ground where the layout wants to be custom, but the interaction wants to be native. Swipe actions were a good example of that. You could get the Apple behavior from List, or you could get full layout control from ScrollView, but combining both was annoying.

With iOS 27, the shape is much better. Put actions on the row. Put coordination on the container. Let SwiftUI handle the interaction. That feels like the right direction for the framework.