Xcode 14: Publishing changes from within view updates

After updating to Xcode 14.0 beta 5 (14A5294e) I'm seeing runtime warnings on iOS when simply changing a published variable from within a button's action.

I'm wondering if this is a false positive on the warning or if there is a new pattern we're supposed to be following here.

This appears to only happen on iOS. The same reproducing code does not output that warning while running on macOS.

Here's a contrived yet complete example that reproduces it on an iPhone 13 Pro simulator while running Xcode 14 beta 5. Does not reproduce the warning when running on Ventura.



class ViewModel: ObservableObject {
    @Published var sheetVM: SheetVM?

    func showSheet() {
        sheetVM = SheetVM(delegate: self)
    }

}

extension ViewModel: SheetDelegate {
    func close() {
        sheetVM = nil
    }

}

class SheetVM: ObservableObject, Identifiable {

    weak var delegate: SheetDelegate?

    init(delegate: SheetDelegate) {
        self.delegate = delegate
    }

    func close() {
        delegate?.close()
    }
}

protocol SheetDelegate: AnyObject {
    func close()
}

struct ContentView: View {

    @ObservedObject
    var viewModel: ViewModel

    var body: some View {
        VStack {
            Button {
                viewModel.showSheet()
            } label: {
                Text("Show Sheet")
            }
        }
        .padding()
        .sheet(item: $viewModel.sheetVM) { sheetVM in
            SheetView(sheetVM: sheetVM)
        }
    }
}

struct SheetView: View {

    let sheetVM: SheetVM

    var body: some View {
        NavigationView {
            Text("Sheet View")
                .toolbar {
                    ToolbarItem(placement: .automatic) {
                        Button {
                            sheetVM.close()
                        } label: {
                            Text("Done")
                                .fontWeight(.semibold)

                        }

                    }

                }
        }

    }

}

Here's the warning and trace while tapping done on the sheet:

warning run: Publishing changes from within view updates is not allowed, this will cause undefined behavior.

Thread 1
#0	0x0000000000000000 in ___lldb_unnamed_symbol155969 ()
#1	0x0000000000000000 in ___lldb_unnamed_symbol155968 ()
#2	0x0000000000000000 in ___lldb_unnamed_symbol155988 ()
#3	0x0000000000000000 in ___lldb_unnamed_symbol180158 ()
#4	0x0000000000000000 in ObservableObjectPublisher.Inner.send() ()
#5	0x0000000000000000 in ObservableObjectPublisher.send() ()
#6	0x0000000000000000 in PublishedSubject.send(_:) ()
#7	0x0000000000000000 in specialized static Published.subscript.setter ()
#8	0x0000000000000000 in static Published.subscript.setter ()
#9	0x0000000000000000 in ViewModel.sheetVM.setter ()
#10	0x0000000000000000 in key path setter for ViewModel.sheetVM : ViewModel ()
#11	0x0000000000000000 in NonmutatingWritebackBuffer.__deallocating_deinit ()
#12	0x0000000000000000 in _swift_release_dealloc ()

Post not yet marked as solved Up vote post of Cwie Down vote post of Cwie
26k views
  • I'm seeing the same warning, as well as significantly decreased performance in my SwiftUI app on iOS 16 (none of these issues on iOS 15). Unsure if these are due to to an Xcode/iOS bug or if I'm actually doing something wrong.

  • The problem comes from using a Binding variable as a ObservableObject, in ios 16 it gives you this warning not to use Bindings with Observables. For a good explanation of how and why, here's a link to " Rebeloper - Rebel Developer" video on YouTube.

    https://youtu.be/3a7tuhVpoTQ

    Hope this will help.

  • I send feedback to Apple and received answer to retest this with iOS 16.2. After retesting this is not longer shown as a warning so it looks like Apple has fixed it behind the scenes.

Add a Comment

Replies

Same problem. I'm not sure: is there a problem with using DispatchQueue.main.async {} to fix it? What did you do? I'm looking forward to official news (or a fix???) from Apple.

It would be a huge issue if views can't change @Published variables in the View Model.

A workaround was provided above by rayx: it's not a beautiful solution in my opinion but it works. To simplify it, I found a way to modify only the main view, so we don't need to touch any of the sheet views. Here below, I apply the solution to Cwie's code:

struct ContentView: View {

    @ObservedObject
    var viewModel: ViewModel

    // Local variable
    @State private var isPresented = false

    var body: some View {
        VStack {
            Button {
                viewModel.showSheet()
            } label: {
                Text("Show Sheet")
            }
        }
        .padding()

        // Use isPresented
        .sheet(isPresented: $isPresented) {
            if viewModel.sheetVM != nil {
                SheetView(sheetVM: sheetVM!)
            }
        }

        // The View Model controls the local variable
        .onChange(of: viewModel.sheetVM) { value in
            if value != nil {
                isPresented = true
            } else {
                isPresented = false
            }

        }        
    }
}

I hope Apple clarifies and solves this quickly.

  • I'm using a much simpler workaround now. See the comments under my original post. I'll post the code below.

Add a Comment

For those who are still struggling with the issue, this is probably the simplest workaround (in both concept and implementation).

func delayedWrite<Value>(_ value: Binding<Value?>) -> Binding<Value?> {
    return Binding() {
        value.wrappedValue
    } set: { newValue in
        DispatchQueue.main.async {
            value.wrappedValue = newValue
        }
    }
}

Then just wrap the binding which causes the issue with the above function. That's it.

It's so simple that I don't mind any more if Apple would fix the issue or not. Happy hacking :)

Post not yet marked as solved Up vote reply of rayx Down vote reply of rayx
  • I don't know how to make this work. I show this sheet: Button { ... } .familyActivityPicker(isPresented: $viewModel.isPresented, selection: $viewModel.selection)

    How would I use your delayedWrite function on the $viewModel.selection ? selection is of type FamilyActivitySelection

  • Thanks for sharing, that solved my case

  • @stm-Dude, I assumed the wrappedValue of the binding is of optional type. That's not the case in your code. You just need to change the "Value?" in the function's param type and return type to "Value".

I am having this issue merely using Map(...anotiationItems:,annotationView:...) If there are ANY annotationItems returned , then multiple times per second that error is spat out with the most useless call stack from LLDB I've seen since Xcode 5.

It has nothing to do for me, with whether map bindings are @State local to the view or in a view model.

I can see from searching online the problem isn't even acknowledged. Only option I have now is dumping apple maps I suppose. If anyone else has any ideas....

  • Exactly the same issue, looks like I too am gonna have to go elsewhere like mapbox or something

  • This could happen if your annotation items are ObservableObjects, and they are getting updated when your map is being rendered. You could try creating simple Structs to represent your annotations and convert your ObservableObject annotations to the Structs to pass into your Map(). This resolved the issue for me.

  • This helped me realize my issue was with the map annotation and not my view model specifically. I'm rendering map annotations from a CoreData fetch request and getting the error all over the place in root view and descendant views. Removing my map view temporarily and replacing it with a simple list view stopped all of the errors. Now I just need to write (a seemingly unnecessary) conversion from the core data fetched results to simple structs.

Add a Comment

I may have a slightly different opinion about whether it's a huge thing, not to allow changes of the published properties directly from views ;) In my opinion, this could also be considered a code smell if the pattern should follow MVVM principles.

When we employ the MVVM pattern, we do not have writable published properties, only readable. Changes are notified to the view model via functions. These "intents" then get processed by the view model which in turn may change the published value, or it may not. The published value is a function of the view model, that is, it's the view model which determines the view's state, not the view.

However, even when a view model determines the published value, it still must not change it during the view updates, otherwise the same warning would occur.

There is one pattern which may alleviate the issue:

For any practical reasons, a ViewModel should be an actor. A MainActor would be suitable.

So, we start with our ViewModel to be a MainActor:

@MainActor
final class SomeViewModel: ObservableObject {
    @Published private(set) var state: State = .init()
    ... 

Notice, that the published value state needs to have an initial value, and it's setter is private.

In order to let a view express an intent, which may change the state, we define a function:

@MainActor
final class SomeViewModel: ObservableObject {
    @Published private(set) var state: State = .init()
    

    nonisolated func updateValue(_ value: T) {
        ...
    } 

Note, the function updateValue(_:) is declared nonisolated.

In order to make it work with the main actor though, we need to dispatch onto the main thread. This will be accomplished with another private helper function, which will be executed on the actor:

    nonisolated func updateValue(_ value: T) {
        Task {
            await _updateValue(value)
        }
    } 

   private func _updateValue(_ value: T) {
       let newValue = someLogic(value)
       self.state.value = newValue
   }

This has two desired side effects:

  1. First we dispatch on the main thread (_updateValue(_:) which is required by the actor
  2. And because of the dispatching we ensure (or strongly assume) that the view update phase is finished when this will get executed (thus, no warnings anymore)

Note: the function someLogic(_: T) should be a pure, synchronous function, which determines the logic, i.e. the new state, based on the current state and the input.

Now in a View we can call updateValue(:_) from anywhere, for example:

    Button {
        viewModel.updateValue(false)
    }

Note: the action function is NOT declared to be executed on the main thread, even though it will be executed on the main thread. An isolated main actor function would require the closure to be dispatched explicitly onto the main thread. However, this function is nonisolated - thus, we can call it from any thread.

Of course, we also can construct a binding in the body of a parent view and pass this along to child views:

    let binding: Binding<Bool > = .init(
        get { viewModel.state.value }
        set { value in viewModel.updateValue(value) }
    )

So, with this "pattern" we get some desired benefits. We also "fix" this issue due to the dispatching.

This problem is still occurring in Xcode Version 14.0.1 (14A400).

The context is a button that's pressed to exit a view that is controlled with:

  .fullScreenCover(isPresented: $settings.showWorkout)

Wrapping it in DispatchQueue.main.asyncAfter made no difference.

I honestly have no idea what's wrong with this or how it can be done differently.

It doesn't seem to cause any harm. Will an app be rejected with these kinds of warnings?

Mine wasn't rejected and I've made several updates already also.

I get this warning for certain view hierarchies. The following code triggers the warning, but if I move the Button out of the Form or change Form to be a VStack, the warning disappears. It seems like a bug to me, and I have filed a bug report.

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    
    var body: some View {
        Form {
            Button("Save") {
                viewModel.counter += 1
            }
        }
    }
}

class ViewModel: ObservableObject {
    @Published var counter = 0
}

I face the same problem. Chaning even a simple @Published property like Int from 1 to 2 in button action triggers the purple warning: "Publishing changes from within view updates is not allowed, this will cause undefined behavior." I believe it's an Xcode bug.

This code was producing the same warning:

struct AreaMap: View {

  @Binding var region: MKCoordinateRegion

  var body: some View {
    Map(coordinateRegion: $region)
  }
}

Where region was passed in the initializer of AreaMap from its parent view.

This resolved the issue:

struct AreaMap: View {

  @Binding var region: MKCoordinateRegion

  var body: some View {
    let binding = Binding(
      get: { self.region },
      set: { newValue in
        DispatchQueue.main.async {
          self.region = newValue
        }
      }
    )
    return Map(coordinateRegion: binding)
  }
}

For what it's worth, I'm no longer seeing the error when running on a 16.1 simulator.