coco
coco

Reputation: 3136

SwiftUI publishing an environment change from within view update

The app has a model that stores the user's current preference for light/dark mode, which the user can change by clicking on a button:

class DataModel: ObservableObject {
    @Published var mode: ColorScheme = .light

The ContentView's body tracks the model, and adjusts the colorScheme when the model changes:

struct ContentView: View {
    @StateObject private var dataModel = DataModel()

var body: some View {
        NavigationStack(path: $path) { ...
        }
        .environmentObject(dataModel)
        .environment(\.colorScheme, dataModel.mode)

As of Xcode Version 14.0 beta 5, this is producing a purple warning: Publishing changes from within view updates is not allowed, this will cause undefined behavior. Is there another way to do this? Or is it a hiccup in the beta release? Thanks!

Upvotes: 20

Views: 10022

Answers (4)

iDeasTouch
iDeasTouch

Reputation: 61

That solution is great, but I think that we can make it even better if we add an onAppear to set the initial value. Here is the final sync modifier:

extension View{
func sync<T:Equatable>(_ published:Binding<T>, with binding:Binding<T>)-> some View{
    self
        .onChange(of: published.wrappedValue) {
            binding.wrappedValue = $0
        }
        .onChange(of: binding.wrappedValue) {
            published.wrappedValue = $0
        }
        .onAppear {
            binding.wrappedValue = published.wrappedValue
        }
}

Upvotes: 0

markb
markb

Reputation: 1295

Update: 2022-09-28

Xcode 14.1 Beta 3 (finally) fixed the "Publishing changes from within view updates is not allowed, this will cause undefined behavior"

See: https://www.donnywals.com/xcode-14-publishing-changes-from-within-view-updates-is-not-allowed-this-will-cause-undefined-behavior/


Full disclosure - I'm not entirely sure why this is happening but these have been the two solutions I have found that seem to work.

The error message

Error in action

Example Code

// -- main view
@main
struct MyApp: App {
  @StateObject private var vm = ViewModel()
  var body: some Scene {
    WindowGroup {
      ViewOne()
       .environmentObject(vm)
    }
  }
}

// -- initial view
struct ViewOne: View {
  @EnvironmentObject private var vm: ViewModel
  var body: some View {
    Button {
      vm.isPresented.toggle()
    } label: {
      Text("Open sheet")
    }
    .sheet(isPresented: $vm.isPresented) {
      SheetView()
    }
  }
}

// -- sheet view
struct SheetView: View {
  @EnvironmentObject private var vm: ViewModel
  var body: some View {
    Button {
      vm.isPresented.toggle()
    } label: {
      Text("Close sheet")
    }
  }
}

// -- view model
class ViewModel: ObservableObject {
  @Published var isPresented: Bool = false
}

Solution 1

Note: from my testing and the example below I still get the error to appear. But if I have a more complex/nested app then the error disappears..

Adding a .buttonStyle() to the button that does the initial toggling.

So within the ContentView on the Button() {} add in a .buttonStyle(.plain) and it will remove the purple error:

struct ViewOne: View {
  @EnvironmentObject private var vm: ViewModel
  var body: some View {
    Button {
      vm.isPresented.toggle()
    } label: {
      Text("Open sheet")
    }
    .buttonStyle(.plain) // <-- here
    .sheet(isPresented: $vm.isPresented) {
      SheetView()
    }
  }
}

^ This is probably more of a hack than solution since it'll output a new view from the modifier and that is probably what is causing it to not output the error on larger views.

Solution 2

This one is credit to Alex Nagy (aka. Rebeloper)

As Alex explains:

.. with SwiftUI 3 and SwiftUI 4 the data handling kind of changed. How SwiftUI handles, more specifically the @Published variable ..

So the solution is to have the boolean trigger to be a @State variable within the view and not as a @Published one inside the ViewModel. But as Alex points out it can make your views messy and if you have a lot of states in it, or not be able to deep link, etc.

@State only - no ViewModel

However, since this is the way that SwiftUI 4 wants these to operate, we run the code as such:

// -- main view
@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      ViewOne()
    }
  }
}

// -- initial view
struct ViewOne: View {
  @State private var isPresented = false
  var body: some View {
    Button {
      isPresented.toggle()
    } label: {
      Text("Open sheet")
    }
    .sheet(isPresented: $isPresented) {
      SheetView(isPresented: $isPresented)
      // SheetView() <-- if using dismiss() in >= iOS 15
    }
  }
}

// -- sheet view
struct SheetView: View {
  // I'm showing a @Binding here for < iOS 15
  // but you can use the dismiss() option if you
  // target higher
  // @Environment(\.dismiss) private var dismiss
  @Binding var isPresented: Bool
  var body: some View {
    Button {
      isPresented.toggle()
      // dismiss()
    } label: {
      Text("Close sheet")
    }
  }
}

Using the @Published and the @State

Continuing from the video, if you need to still use the @Published variable as it might tie into other areas of your app you can do so with a .onChange and a .onReceive to link the two variables:

struct ViewOne: View {
  @EnvironmentObject private var vm: ViewModel
  @State private var isPresented = false
  var body: some View {
    Button {
      vm.isPresented.toggle()
    } label: {
      Text("Open sheet")
    }
    .sheet(isPresented: $isPresented) {
      SheetView(isPresented: $isPresented)
    }
    .onReceive(vm.$isPresented) { newValue in
      isPresented = newValue
    }
    .onChange(of: isPresented) { newValue in
      vm.isPresented = newValue
    }
  }
}

With the @State and the @Published variables

However, this can become really messy in your code if you have to trigger it for every sheet or fullScreenCover.

Creating a ViewModifier

So to make it easier for you to implement it you can create a ViewModifier which Alex has shown works too:

extension View {
  func sync(_ published: Binding<Bool>, with binding: Binding<Bool>) -> some View {
    self
      .onChange(of: published.wrappedValue) { newValue in
        binding.wrappedValue = newValue
      }
      .onChange(of: binding.wrappedValue) { newValue in
        published.wrappedValue = newValue
      }
  }
}

View extension

And in use on the View:

struct ViewOne: View {
  @EnvironmentObject private var vm: ViewModel
  @State private var isPresented = false
  var body: some View {
    Button {
      vm.isPresented.toggle()
    } label: {
      Text("Open sheet")
    }
    .sheet(isPresented: $isPresented) {
      SheetView(isPresented: $isPresented)
    }
    .sync($vm.isPresented, with: $isPresented)

//    .onReceive(vm.$isPresented) { newValue in
//      isPresented = newValue
//    }
//    .onChange(of: isPresented) { newValue in
//      vm.isPresented = newValue
//    }
  }
}

^ Anything denoted with this is my assumptions and not real technical understanding - I am not a technical knowledgeable :/

Upvotes: 17

glyvox
glyvox

Reputation: 58089

Try running the code that's throwing the purple error asynchronously, for example, by using DispatchQueue.main.async or Task.

DispatchQueue.main.async {
    // environment changing code comes here
}

Task {
    // environment changing code comes here
}

Upvotes: 4

egzoticc
egzoticc

Reputation: 51

Improved Solution of Rebel Developer as a generic function. Rebeloper solution It helped me a lot.

1- Create extension for it:

extension View{
func sync<T:Equatable>(_ published:Binding<T>, with binding:Binding<T>)-> some View{
    self
        .onChange(of: published.wrappedValue) { published in
            binding.wrappedValue = published
        }
        .onChange(of: binding.wrappedValue) { binding in
            published.wrappedValue = binding
        }
}

}

2- sync() ViewModel @Published var to local @State var

struct ContentView: View {

@EnvironmentObject var viewModel:ViewModel
@State var fullScreenType:FullScreenType?

var body: some View {
    //..
}
.sync($viewModel.fullScreenType, with: $fullScreenType)

Upvotes: 2

Related Questions