It’s not always safe to access a Binding within a callback that isn’t part of SwiftUI’s event system.
An example is illustrated in the following sample code:
extension Notification.Name {
static let event = Notification.Name("event")
}
struct Item: Identifiable {
var id: Int
var name: String
}
struct ItemView: View {
@Binding
var item: Item
var body: some View {
TextEditor(text: $item.name)
// This is a non-SwiftUI event callback.
.onReceive(NotificationCenter.default.publisher(for: .event)) { _ in
// 3. This block is called immediately in the same call from `NotificationCenter.default.post`.
// 4. This `item` references `items[0]`, which crashes the app due to an index out of bounds.
print(item)
}
}
}
struct MainView: View {
@State
private var items: [Item] = [
Item(id: 1, name: "item")
]
var body: some View {
VStack {
ForEach($items) { $item in
ItemView(item: $item)
}
}
Button("Clear") {
// 1. `items` is cleared.
items = []
// 2. Before `body` is updated, post the notification.
NotificationCenter.default.post(name: .event, object: nil)
}
}
}First of all, the right approach is to avoid using non-SwiftUI event callbacks in SwiftUI-based applications when they access SwiftUI states.
However, sometimes it’s unavoidable.
To work around this situation, it’s necessary to ensure events are received only after the body has been updated.
The timing of body updates differs depending on the platform.
On iOS, it occurs immediately within the source event call, such as __CFRunLoopRunSource0 or __CFRunLoopRunSource1.
Therefore, the following change can delay the callback safely:
.onReceive(
NotificationCenter.default.publisher(for: .event)
.receive(on: RunLoop.main)
) { _ in
// ...This works because RunLoop.main schedules the call on the next __CFRunLoopDoBlock, which always happens after any source event calls in __CFRunLoopRun.
However, on macOS, body updates happen not within __CFRunLoopRun, but in __CFRunLoopDoObserver for kCFRunLoopExit.
This is because NSApplication handles system events within its own NSApplication.run loop using NSEvent, and runs NSRunLoop alongside it.
Therefore, RunLoop.main alone is not enough to delay the callback.
It’s tricky, but adding a new CFRunLoopObserver at the end of kCFRunLoopExit can watch for that moment.
RunOnMainRunLoopExitScheduler is an example implementation.
.onReceive(
NotificationCenter.default.publisher(for: .event)
.receive(on: RunOnMainRunLoopExitScheduler())
) { _ in
// ...With this change, the call order would look like this:
Button("Clear") {
// 1. `__CFRunLoopRunSource0` or `__CFRunLoopRunSource1`,
// a touch event calls this block (or on macOS, a mouse event).
// 2. `items` is cleared.
items = []
// 3. Before `body` is updated, post the notification.
NotificationCenter.default.post(name: .event, object: nil)
}
.onReceive(
// 4. Notification is delivered here.
NotificationCenter.default.publisher(for: .event)
// 5. Delayed until the next `__CFRunLoopDoBlock` call,
// after `__CFRunLoopRunSource0` finishes.
.receive(on: RunLoop.main)
// Or, on macOS, delayed until the end of the next
// `__CFRunLoopRunObserver(kCFRunLoopExit)`.
.receive(on: RunOnMainRunLoopExitScheduler())
) { _ in
// ...
var body: some View {
// 6. `body` is updated within the same call from the touch event,
// or during `__CFRunLoopRunObserver(kCFRunLoopExit)`
// in the call following a mouse `NSEvent` on macOS.
VStack {
// `items` are gone.
ForEach($items) { $item in
// Therefore, `ItemView` disappears.
ItemView(item: $item)
}
}
// 7. Since `ItemView` has disappeared,
// this publisher seems to be cancelled and gone.
.onReceive(
NotificationCenter.default.publisher(for: .event)There’s an even more complicated situation when such Binding access happens inside lazy container views such as LazyVStack, LazyHStack, or List.
LazyVStack {
ForEach($items) { $item in
ItemView(item: $item)
}
}In this case, surprisingly, even after body is updated and ItemView disappears, the onReceive handler may still be called.
To detect this, an additional visibility check is required.
struct ItemView: View {
@Binding
var item: Item
@State
private var isAppeared: Bool = false
var body: some View {
TextEditor(text: $item.name)
.onAppear {
isAppeared = true
}
.onDisappear {
isAppeared = false
}
// This is a non-SwiftUI event callback.
.onReceive(
NotificationCenter.default.publisher(for: .event)
.receive(on: RunLoop.main) // for iOS
.receive(on: RunOnMainRunLoopExitScheduler()) // for macOS
) { _ in
guard isAppeared else {
return
}
print(item)
}
}
}Then, the call order would be as follows:
Button("Clear") {
// 1. `__CFRunLoopRunSource0` or `__CFRunLoopRunSource1`,
// touch event calls this block, or on macOS, a mouse event.
// 2. `items` is cleared.
items = []
// 3. Before `body` is updated, post the notification.
NotificationCenter.default.post(name: .event, object: nil)
}
.onReceive(
// 4. Notification is delivered here.
NotificationCenter.default.publisher(for: .event)
// 5. Delayed until the next `__CFRunLoopDoBlock`,
// after `__CFRunLoopRunSource0` ends.
.receive(on: RunLoop.main)
// Or, on macOS, delayed until the end of the next
// `__CFRunLoopRunObserver(kCFRunLoopExit)`.
.receive(on: RunOnMainRunLoopExitScheduler())
) { _ in
// ...
var body: some View {
// 6. `body` is updated within the same call from the touch event,
// or within `__CFRunLoopRunObserver(kCFRunLoopExit)`
// in the next call after the mouse `NSEvent` on macOS.
VStack {
// `items` are now gone.
ForEach($items) { $item in
// Therefore, `ItemView` disappears.
ItemView(item: $item)
}
}
// 7. Since `ItemView` disappeared, `onDisappear` is called.
.onDisappear {
isAppeared = false
}
// 8. Within a lazy view, the publisher is not cancelled immediately,
// so the delayed callback still executes during `__CFRunLoopDoBlock`
// or `__CFRunLoopRunObserver(kCFRunLoopExit)`.
.onReceive(
NotificationCenter.default.publisher(for: .event)
.receive(on: RunLoop.main) // on iOS
.receive(on: RunOnMainRunLoopExitScheduler()) // on macOS
) { _ in
// 9. Since `onDisappear` has been called and
// `isAppeared` is now false, guard and return.
guard isAppeared else {
return
}