Issue #1065
Swipe actions used to require List. Even when a ScrollView and LazyVStack were the better fit for layout and performance, a swipeable row meant falling back to List just to get that one interaction.
SwiftUI now lets you attach swipeActions to any view, inside a scroll view, a lazy stack, or even a custom Layout, using a new modifier called swipeActionsContainer that turns on the swipe behavior for everything placed inside it.
The familiar List version
Here is the pattern most SwiftUI developers already know. A list of messages, each with a trailing swipe action to delete it.
struct ContentView: View {
@State private var messages: [Message] = [
Message(content: "Hello"),
Message(content: "World")
]
var body: some View {
NavigationStack {
List {
ForEach(messages) { message in
Text(message.content)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", role: .destructive) {
messages.removeAll { $0.id == message.id }
}
}
}
}
.navigationTitle("Inbox")
}
}
}
List has always known how to host swipe actions internally, tracking which row is open and closing it on scroll or an outside tap. None of that logic was ever exposed elsewhere, which is why swipe actions felt locked to List.
Bringing swipe actions to a plain scroll view
Now swap the list for a scroll view and a lazy stack. The swipeActions modifier attaches to each row exactly as before, but you also need to opt the container into the new behavior.
struct ContentView: View {
@State private var messages: [Message] = [
Message(content: "Hello"),
Message(content: "World")
]
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(messages) { message in
Text(message.content)
.padding()
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", role: .destructive) {
messages.removeAll { $0.id == message.id }
}
}
Divider()
}
}
}
.swipeActionsContainer()
}
}
The only new piece is .swipeActionsContainer() on the scroll view. Without it, the swipe gesture does nothing, since there is no coordinator managing which row is revealed. With it, you get the same behavior as List: only one row opens at a time, scrolling dismisses it, and tapping outside closes it.
Why the container modifier matters
Rows need a shared source of truth. If each row tracked its own open state, nothing would stop two rows from being open at once, and scrolling wouldn’t know to close them. swipeActionsContainer supplies that shared coordinator to every descendant view, no matter how deeply nested the swipeable row is.
This is also why List never needed the modifier: it already coordinates this internally. Everywhere else, from a ScrollView to a VStack to a custom layout, you add that coordination yourself with one line.
Using it inside a custom layout
Because the container modifier works on any view hierarchy, it also applies to custom Layout types. A flow layout illustrates it, even if wrapping delete buttons in a flow layout is an unusual design choice.
struct FlowLayout: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// layout sizing logic
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
// layout placement logic
}
}
struct TagsView: View {
var body: some View {
FlowLayout {
Text("Draft")
.swipeActions {
Button(role: .destructive) {
// remove tag
}
}
}
.swipeActionsContainer()
}
}
The pattern stays identical. Whatever container you choose, attach swipeActions to the individual views and swipeActionsContainer once to the parent, and SwiftUI handles the rest.
Start the conversation