UIViewControllerRepresentable容器中非可选@Binding的PickerView动画失效问题求解
UIViewControllerRepresentable容器中非可选@Binding的PickerView动画失效问题求解
我最近碰到了一个挺头疼的SwiftUI动画问题,折腾了半天没搞定,想跟大家请教下,顺便分享下我找到的解决方案。
情况是这样的:我写了一个PickerView组件,为了兼容可选和非可选的@Binding值,写了两个初始化方法。这个组件在值变化时会用自定义的不对称过渡动画切换文本显示,在普通的SwiftUI视图里跑起来完全正常,可选和非可选绑定都能触发动画,但把它放到UIViewControllerRepresentable包装的容器视图里时,非可选绑定的那个实例就彻底没动画了,可选的却一切正常,真的很奇怪。
先给大家看一下我最开始的代码:
struct PickerView: View { @Binding var value: String? private var allowNil: Bool = false // 可选Binding输入的初始化 init(value: Binding<String?>) { self._value = value allowNil = true } // 非可选Binding输入的初始化 init(value: Binding<String>) { self._value = Binding<String?>(get: { value.wrappedValue }, set: { newValue in if let newValue = newValue { value.wrappedValue = newValue } }) } var body: some View { VStack { Text("Allow Nil: \(allowNil)") 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) ) } } struct PickerPreview: View { @State var optionalValue: String? = "optional" // 容器内正常 @State var value: String = "non optional" // 容器内动画失效 var body: some View { VStack { ContainerView { VStack(spacing: 20) { VStack { Text("Container:") Text("optional OK, non optional Fails") } .font(.headline) PickerView(value: $optionalValue) PickerView(value: $value) } } VStack(spacing: 20) { Text("Both OK") .font(.headline) PickerView(value: $optionalValue) PickerView(value: $value) } } } } 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() }
问题原因分析
我翻了下SwiftUI的文档和一些资料,发现问题出在两个地方:
UIViewControllerRepresentable的实现问题:原来的ContainerView在updateUIViewController方法里每次都会重新设置rootView,这会导致整个子视图树被频繁重建,SwiftUI的状态追踪系统会丢失部分上下文,动画系统就识别不到非可选绑定的值变化了。- 非可选Binding的包装方式:最开始的非可选初始化里,手动包装的
Binding<String?>没有被SwiftUI的状态系统正确识别,尤其是在视图被频繁重建的情况下,这种手动包装的绑定无法提供足够的依赖追踪信息,动画就触发不了。
解决方案
针对这两个问题,我做了两处修改:
1. 优化ContainerView的实现,避免频繁重建rootView
把content从存储视图实例改成存储视图构建闭包,这样UIHostingController可以自己管理视图的更新,不需要每次都重置rootView:
struct ContainerView<Content: View>: UIViewControllerRepresentable { let content: () -> Content // 改成闭包类型 init(@ViewBuilder content: @escaping () -> Content) { self.content = content } func makeUIViewController(context: Context) -> UIViewController { let containerVC = UIViewController() containerVC.view.backgroundColor = .yellow 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 } func updateUIViewController(_ uiViewController: UIViewController, context: Context) { // 移除重置rootView的代码,让UIHostingController自动处理视图更新 } func makeCoordinator() -> Coordinator { return Coordinator() } class Coordinator: NSObject { var contentVC: UIHostingController<Content>? } }
2. 改进非可选Binding的初始化,显式设置allowNil
同时把非可选初始化里的allowNil显式设置为false,让组件的状态更清晰:
// 改进后的非可选Binding初始化 init(value: Binding<String>) { self._value = Binding<String?>( get: { value.wrappedValue }, set: { newValue in guard let newValue else { return } value.wrappedValue = newValue } ) self.allowNil = false // 显式标记不允许nil }
测试结果
修改完之后,不管是普通视图里还是ContainerView容器里的PickerView,不管是可选还是非可选绑定,都能正常触发过渡动画了!
如果大家有更优雅的实现方式,欢迎一起讨论~
内容来源于stack exchange
相关产品推荐
相关产品推荐

