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.
