@ObservedObject的@Published属性作为UIViewControllerRepresentable内@Binding输入时动画失效的解决咨询
我之前搞定了一个相关问题后,现在又卡在UIViewControllerRepresentable的另一个坑上了,想请教下大家怎么解决:
我写了个PickerView,用@Binding var value: String来展示和更新值,值变化时会触发自定义的上下滑+透明度的过渡动画。在普通SwiftUI视图里用的时候完全正常,但要是把@ObservedObject的@Published属性作为@Binding传入UIViewControllerRepresentable内部的PickerView,值变化的动画就直接失效了。反而用本地的@State属性当输入时,动画从头到尾都正常。
具体现象就是:黄色容器区域里,绑定@ObservedObject的那个PickerView更新值时完全没动画,而绑定@State的就正常;但在容器外面的普通视图里,不管是绑定@ObservedObject还是@State,动画都没问题。
注:因为咱们App要兼容iOS16+,所以暂时没法用新的@Observable宏。
下面是我写的完整代码:
struct PickerView: View { @Binding var value: String var body: some View { VStack { Text(value) .transition(textTransition) .id(value) Button("Change Value") { withAnimation { value = String(UUID().uuidString.prefix(8)) } } } } private var textTransition: AnyTransition { .asymmetric( insertion: .move(edge: .bottom).combined(with: .opacity), removal: .move(edge: .top).combined(with: .opacity) ) } } class PickerViewModel: ObservableObject { @Published var value: String = "viewModelValue" } struct PickerContainer: View { @ObservedObject var viewModel: PickerViewModel @State var stateValue: String = "stateValue" var body: some View { VStack { ContainerView{ VStack(spacing: 20) { Text("Container: ViewModel Fails") .font(.headline) PickerView(value: $viewModel.value) // 这里更新时无动画 PickerView(value: $stateValue) } } VStack(spacing: 20) { Text("Both OK") .font(.headline) PickerView(value: $viewModel.value) PickerView(value: $stateValue) } } } } struct PickerPreview: View { @StateObject var viewModel = PickerViewModel() var body: some View { PickerContainer(viewModel: viewModel) } } struct ContainerView<Content: View>: UIViewControllerRepresentable { let content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } func makeUIViewController(context: Context) -> UIViewController { let containerVC = UIViewController() containerVC.view.backgroundColor = .yellow let contentVC = UIHostingController(rootView: self.content) contentVC.view.backgroundColor = .clear context.coordinator.contentVC = contentVC contentVC.willMove(toParent: containerVC) containerVC.addChild(contentVC) contentVC.view.translatesAutoresizingMaskIntoConstraints = false containerVC.view.addSubview(contentVC.view) NSLayoutConstraint.activate([ contentVC.view.topAnchor.constraint(equalTo: containerVC.view.topAnchor), contentVC.view.bottomAnchor.constraint(equalTo: containerVC.view.bottomAnchor), contentVC.view.leadingAnchor.constraint(equalTo: containerVC.view.leadingAnchor), contentVC.view.trailingAnchor.constraint(equalTo: containerVC.view.trailingAnchor) ]) contentVC.didMove(toParent: containerVC) return containerVC } func updateUIViewController(_ uiViewController: UIViewController, context: Context) { context.coordinator.contentVC?.rootView = content } func makeCoordinator() -> Coordinator { return Coordinator() } class Coordinator: NSObject, UIScrollViewDelegate { var contentVC: UIHostingController<Content>? } } #Preview { PickerPreview() }
我找到的解决办法
这个问题的根子在ContainerView的updateUIViewController方法里——我之前直接写了context.coordinator.contentVC?.rootView = content,这会直接把原来的rootView换掉。当@ObservedObject的@Published属性更新时,updateUIViewController会被调用,这时候直接替换rootView相当于把原来的视图树整个重建了一遍,动画上下文直接被打断,自然就看不到动画了。
解决的核心就是不要直接替换rootView,让UIHostingController自己处理rootView的动态更新,保持视图树的连续性。修改后的ContainerView代码如下:
struct ContainerView<Content: View>: UIViewControllerRepresentable { @ViewBuilder let content: () -> Content func makeUIViewController(context: Context) -> UIViewController { let containerVC = UIViewController() containerVC.view.backgroundColor = .yellow // 直接用content()初始化,因为@ViewBuilder会处理动态内容 let contentVC = UIHostingController(rootView: content()) contentVC.view.backgroundColor = .clear context.coordinator.contentVC = contentVC contentVC.willMove(toParent: containerVC) containerVC.addChild(contentVC) contentVC.view.translatesAutoresizingMaskIntoConstraints = false containerVC.view.addSubview(contentVC.view) NSLayoutConstraint.activate([ contentVC.view.topAnchor.constraint(equalTo: containerVC.view.topAnchor), contentVC.view.bottomAnchor.constraint(equalTo: containerVC.view.bottomAnchor), contentVC.view.leadingAnchor.constraint(equalTo: containerVC.view.leadingAnchor), contentVC.view.trailingAnchor.constraint(equalTo: containerVC.view.trailingAnchor) ]) contentVC.didMove(toParent: containerVC) return containerVC } // 关键:删掉原来替换rootView的代码! func updateUIViewController(_ uiViewController: UIViewController, context: Context) { // 啥也不用做,SwiftUI的绑定系统会自动更新content里的视图内容 } func makeCoordinator() -> Coordinator { Coordinator() } class Coordinator: NSObject { var contentVC: UIHostingController<Content>? } }
为啥这样能行?
- 原来的写法直接替换rootView,等于把之前的视图全删了重建,动画的上下文直接就没了,
withAnimation自然触发不了。 - 删掉替换rootView的代码后,
UIHostingController会保留原来的rootView引用,SwiftUI的绑定系统会自动把@ObservedObject的更新传递给子视图,视图树不会被重建,动画上下文得以保留,自定义的过渡动画就能正常触发了。
亲测这样修改后,不管是绑定@ObservedObject还是@State的属性,在容器里的动画都能正常工作了。
内容来源于stack exchange

