We are adopting the iOS 27 support for swipeActions on rows inside a ScrollView { LazyVStack } (with swipeActionsContainer() on the scroll view). We expected the row modifier to behave like it does inside List, but on iOS 27.0 (24A437, iPhone 17; also 27.0 simulator) the content closure appears to be evaluated only once, when the row is first registered, and never again while the row stays on screen. Is this the intended behavior, or a bug?
What we observe
- Stale button labels / actions. A button whose title depends on state (
Button(isPinned ? "Unpin" : "Pin")) keeps its first title after the state changes. The row body itself re-renders with the new state, but the swipe actions do not, until the row is recycled by the lazy stack (scroll it off-screen and back) — then it shows the new value. - Conditional content is ignored.
swipeActions { if !editing { Button(...) } }keeps showing the buttons aftereditingbecomes true, and the row can still be swiped. - Views placed inside the content that observe state themselves (an
@Observable/@ObservedObjectchild view) do update their text. But anifinside that child view does not remove the buttons, and emptying aForEachinside it removes the buttons while the row still slides open (onPresentationChangedfires withtrue). onLongPressGesture(minimumDuration: 0.7)on the same row fires only on touch-up (after ~3 s of holding) when the row has swipe actions in aScrollView; insideList, and in aScrollViewwithout swipe actions, it fires at ~0.7 s while still pressed.
The same row code inside List behaves as expected for all four points. Attaching or omitting swipeActionsContainer() and using the old swipeActions(edge:allowsFullSwipe:content:) overload instead of the new onPresentationChanged overload makes no difference.
Minimal reproduction (iOS 27 only, single file)
import SwiftUI
@Observable final class Model {
var editing = false
var pinned: Set<Int> = []
}
@available(iOS 27, *)
struct ContentView: View {
@State private var model = Model()
@State private var lastLongPress = "-"
var body: some View {
VStack(spacing: 8) {
Toggle("Editing (swipe should be off)", isOn: $model.editing)
Text("long press: \(lastLongPress)").font(.caption)
ScrollView {
LazyVStack(spacing: 0) {
ForEach(1...30, id: \.self) { n in
HStack {
Text("Row \(n)")
Spacer()
Text(model.pinned.contains(n) ? "pinned" : "").foregroundStyle(.secondary)
}
.frame(height: 56)
.contentShape(Rectangle())
.onLongPressGesture(minimumDuration: 0.7) {
lastLongPress = "row \(n) @ \(Date().formatted(date: .omitted, time: .standard))"
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if !model.editing { // (2) ignored after first registration
Button(model.pinned.contains(n) ? "Unpin" : "Pin") { // (1) title stays "Pin"
if model.pinned.contains(n) { model.pinned.remove(n) } else { model.pinned.insert(n) }
}
.tint(.blue)
}
} onPresentationChanged: { _ in }
}
}
}
.swipeActionsContainer()
}
.padding(.horizontal)
}
}
Steps:
- Swipe row 3, tap Pin. The row shows "pinned".
- Swipe row 3 again → the button still says Pin (in
Listit says Unpin). Tapping it runs the closure with the current model, so it unpins, but the label was wrong. - Turn Editing on, swipe any row → it still opens with the button (in
Listit does not open). - Press and hold a row for 3 s → "long press" timestamp is written only when the finger lifts.
Questions
- Is "evaluate the
swipeActionscontent once per row registration" the intended contract for swipe actions outsideList? The documentation forswipeActionsContainer()only describes coordination (single open row, dismiss on scroll / outside tap), andswipeActions(edge:allowsFullSwipe:content:)doesn't mention any difference betweenListand other containers. - If it is intended, what is the recommended way to (a) disable swipe for a row temporarily (e.g. during an edit mode) and (b) update button titles from state, without recreating the row view? We currently work around it with a child view that observes the model for titles, and
allowsHitTesting(false)on the row to disable swiping, but both feel like they rely on undocumented behavior. - Is the delayed
onLongPressGesture(fires on touch-up) expected when a row carries swipe actions in aScrollView?simultaneousGesture(LongPressGesture(...))fires while pressed, which suggests the swipe recognizer imposes a failure requirement on the row's gestures.