SwiftUI:在视图更新内部发布环境更改

20

该应用程序具有一个模型,用于存储用户对浅色/深色模式的当前偏好,用户可以通过点击按钮进行更改:

该应用程序包含一个模型,可存储用户目前喜欢使用浅色或深色模式的设置,用户可以通过点击按钮进行更改。

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

ContentView的body会跟踪模型,并在模型更改时调整colorScheme:

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

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

从 Xcode 版本 14.0 beta 5 开始,这将生成紫色警告:不允许在视图更新中发布更改,这将导致未定义的行为。 还有其他方法吗?还是这只是 beta 发布版本的小问题?谢谢!


无法在此处重现,因此似乎取决于您的其他代码。需要最小可重现示例(MRE)。 - Asperi
2
@Asperi 这篇关于苹果开发者论坛的帖子有一个最小可重现示例。https://developer.apple.com/forums/thread/711899 - Magnas
1
...which the user can change by clicking on a button:..., show us the code of the Button and how you change the dataModel. You can usually resolve this type of issue, using DispatchQueue.main.async {....} - workingdog support Ukraine
可能将一个值设置为环境变量,而该值也在另一个环境变量中,可能会被禁止,因为可能会导致不一致。但这只是一个猜测。 - Ptit Xav
4
我不确定问题的具体情况,因为作者没有发布完整的代码。但对于https://developer.apple.com/forums/thread/711899中的问题,我在这里添加了我的分析和解决方案[here](https://developer.apple.com/forums/thread/711899?page=1#724014022)。希望能有所帮助。 - rayx
显示剩余2条评论
4个回答

16

更新:2022-09-28

Xcode 14.1 Beta 3(终于)修复了“不允许在视图更新中发布更改,这将导致未定义的行为”错误。

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


完全透明地说——我不确定为什么会发生这种情况,但这是我找到的两个似乎可行的解决方案。

错误信息

错误示例

代码示例

// -- 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
}

解决方案 1

注意: 经过我的测试和下面的例子,我仍然会看到错误。但如果我的应用更复杂/嵌套,则错误会消失。

在执行初始切换的按钮上添加.buttonStyle()

因此,在ContentView中的Button() {}中加入.buttonStyle(.plain),它将删除紫色错误:

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()
    }
  }
}

^这可能更像是一种hack而不是解决方案,因为它将从修改器输出一个新视图,这可能是导致它在较大的视图上不输出错误的原因。

解决方案2

这个解决方案来自于Alex Nagy(又名Rebeloper)。

正如Alex所解释的:

..随着SwiftUI 3和SwiftUI 4数据处理方式有所改变。SwiftUI的处理方式,更具体地说是@Published变量的处理方式...

因此,解决方案是将布尔触发器作为视图中的@State变量,而不是作为ViewModel中的@Published变量。但正如Alex所指出的,这可能会使您的视图变得混乱,如果您在其中有很多状态,或者无法进行深度链接等。

@State only - no ViewModel

然而,由于这是SwiftUI 4希望它们操作的方式,我们按如下方式运行代码:

// -- 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")
    }
  }
}

使用 @Published@State

如果你需要继续使用 @Published 变量,因为它可能与您的应用程序的其他区域相关联,您可以通过 .onChange.onReceive 来链接这两个变量:

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
    }
  }
}

使用 @State 和 @Published 变量

但是如果您需要为每个 sheetfullScreenCover 触发它,这样的代码将变得非常混乱。

创建一个 ViewModifier

因此,为了让您更容易实现它,您可以创建一个 ViewModifier,Alex 已经展示了它也可以工作:

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
      }
  }
}

查看扩展名

在查看中使用:

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
//    }
  }
}

^ 任何带有此标识的内容均为我的假设,而非实际技术理解 - 我不具备专业技术知识 :/



1
简而言之:我们不再能够将 @Published 变量 绑定。现在绑定仅适用于 @State - Rebeloper
@Rebeloper,这是否是苹果官方声明的新期望行为,还是我们看到了可能潜入Swift4/Xcode 14最终版本的错误?这似乎是一个重大的行为变化,在其他任何地方都没有提到。 - Gerry Shaw
1
@GerryShaw,看起来这不是一个 bug。苹果公司对此保持沉默。我们正在尝试解决方法。我会随时更新进展。https://dev59.com/QVEG5IYBdhLWcg3wIUla#73736513 是在列表和按钮中添加的一个奇怪但至关重要的步骤。 - Rebeloper
那么这是一个 bug 吗?在 Xcode 14 中是否像以前一样工作,还是他们真的改变了 @Published 的绑定行为? - adri567
@adri567 我在发布说明中没有看到任何内容,但是现在在14.1.0b3中运行它不会输出错误。 - markb

4

尝试将导致紫色错误的代码异步运行,例如使用 DispatchQueue.main.asyncTask

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

Task {
    // environment changing code comes here
}

2
这在离开一张表时解决了非绑定环境中的问题,但是我正在使用Task{@MainActor} - vadian

2

改进的Rebel Developer解决方案作为通用功能。

Rebeloper解决方案对我帮助很大。

1- 为此创建扩展:

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- 将ViewModel中的@Published变量与本地的@State变量同步(sync())

struct ContentView: View {

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

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

那个解决方案很棒,但我认为如果我们添加一个onAppear来设置初始值,它会变得更好。这是最终的sync修饰符:extension View{ func sync(_ published:Binding, with binding:Binding)-> some View{ self .onChange(of: published.wrappedValue) { published in binding.wrappedValue = published } .onChange(of: binding.wrappedValue) { binding in published.wrappedValue = binding } .onAppear { binding.wrappedValue = published } } } - iDeasTouch

0
那个解决方案很棒,但我认为如果我们添加一个onAppear来设置初始值,它会变得更好。这是最终的sync修饰符:
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
        }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接