iOS 27: swipeActions outside List never re-evaluates its content closure (stale labels, if gating ignored) — intended?

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

  1. 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.
  2. Conditional content is ignored. swipeActions { if !editing { Button(...) } } keeps showing the buttons after editing becomes true, and the row can still be swiped.
  3. Views placed inside the content that observe state themselves (an @Observable/@ObservedObject child view) do update their text. But an if inside that child view does not remove the buttons, and emptying a ForEach inside it removes the buttons while the row still slides open (onPresentationChanged fires with true).
  4. 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 a ScrollView; inside List, and in a ScrollView without 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:

  1. Swipe row 3, tap Pin. The row shows "pinned".
  2. Swipe row 3 again → the button still says Pin (in List it says Unpin). Tapping it runs the closure with the current model, so it unpins, but the label was wrong.
  3. Turn Editing on, swipe any row → it still opens with the button (in List it does not open).
  4. Press and hold a row for 3 s → "long press" timestamp is written only when the finger lifts.

Questions

  • Is "evaluate the swipeActions content once per row registration" the intended contract for swipe actions outside List? The documentation for swipeActionsContainer() only describes coordination (single open row, dismiss on scroll / outside tap), and swipeActions(edge:allowsFullSwipe:content:) doesn't mention any difference between List and 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 a ScrollView? simultaneousGesture(LongPressGesture(...)) fires while pressed, which suggests the swipe recognizer imposes a failure requirement on the row's gestures.
iOS 27: swipeActions outside List never re-evaluates its content closure (stale labels, if gating ignored) — intended?
 
 
Q