【问题标题】:Accessing another UIViewRepresentable's controller or UIView访问另一个 UIViewRepresentable 的控制器或 UIView
【发布时间】:2022-10-24 23:00:39
【问题描述】:

UIViewRepresentable 对于将 UIKit 视图引入 SwiftUI 上下文很有用。它们的主要限制是 UIKit 方面的实例化不在我们的控制之下 - 它根据 SwiftUI 子系统的需要发生。

当两个 UIView 需要相互了解才能进行协作时,这会造成困难。例如MKMapViewMKCompassButton。后者需要前者的实例与之同步。

在单独的UIViewRepresentable 值之间传递这样的引用是很困难的,因为我们无法直接使用控制器或视图。

struct MapView: UIViewRepresentable {
    func makeUIView(context: Context) -> MKMapView { .init() }
}
struct CompassButton: UIViewRepresentable {
    func makeUIView(context: Context) -> MKCompassButton { .init(mapView: ???) }
}
/// or
struct MapView: UIViewRepresentable {
    let compass = CompassButton()

    func makeUIView(context: Context) -> MKMapView { .init() }

    struct CompassButton: UIViewRepresentable {
        func makeUIView(context: Context) -> MKCompassButton { .init(mapView: ???) }
    }
}

有谁知道一种机制,我们可以通过这种机制允许两个基于UIViewRepresentable 的 SwiftUI 视图使用它们的底层 UIKit 视图进行协作,可能是通过共享控制器实例或其他方式?

我的第一个想法是将控制器的实例化移出makeController 并直接作为var 移入var,但这可能会干扰控制器的SwiftUI 生命周期管理。

【问题讨论】:

    标签: swiftui uikit


    【解决方案1】:

    要直接解决您的问题,您可以将状态保持在两个视图(即父级)之上的级别。例如,这有效:

    class AppState: ObservableObject {
        var mapView = MKMapView()
    }
    
    @main
    struct CustomCardViewApp: App {
        @StateObject var appState = AppState()
        
        var body: some Scene {
            WindowGroup {
                ContainerView(mapView: appState.mapView)
            }
        }
    }
    
    struct MapView: UIViewRepresentable {
        var mapView: MKMapView
        
        func makeUIView(context: Context) -> MKMapView { mapView }
        func updateUIView(_ uiView: MKMapView, context: Context) { }
    }
    
    struct CompassButton: UIViewRepresentable {
        var mapView: MKMapView
        
        func makeUIView(context: Context) -> MKCompassButton {
            let button = MKCompassButton(mapView: mapView)
            button.compassVisibility = .visible
            return button
        }
        func updateUIView(_ uiView: MKCompassButton, context: Context) { }
    }
    
    struct ContainerView: View {
        var mapView: MKMapView
        
        var body: some View {
            ZStack {
                MapView(mapView: mapView)
                CompassButton(mapView: mapView)
                    .fixedSize()
            }
        }
    }
    

    话虽如此,如果您真的不需要单独引用指南针视图,您可以简化并将其作为子视图添加到MKMapView

    struct MapView: UIViewRepresentable {
        func makeUIView(context: Context) -> MKMapView {
            let mapView = MKMapView()
            let button = MKCompassButton(mapView: mapView)
            button.compassVisibility = .visible
            button.frame.origin = .init(x: 10, y: 10)
            mapView.addSubview(button)
            return mapView
        }
        func updateUIView(_ uiView: MKMapView, context: Context) { }
    }
    
    struct ContainerView: View {
        var body: some View {
            ZStack {
                MapView()
            }
        }
    }
    

    【讨论】:

    • 谢谢!不幸的是,这个解决方案迫使用户将实现细节外部化。在我们的示例中,管理上游状态将MapView 的内部外部化,因为MKMapView 应该是一个实现细节。此外,应该能够为给定的地图视图创建单独的指南针视图,并且它们之间的链接应该作为不通过合同公开的内部细节来处理。
    • 如果您有其他要求,我建议您将它们添加到问题中。
    • 请注意,问题是关于让一个视图访问另一个视图的 UIView。让两个视图共享一个外部 UIView 是一种解决方法,但不能解决问题的直接需求。
    • 所以要求是一个视图必须访问另一个视图,但是不能从共享的父状态中了解这一点?
    • 当然,但共享状态实际上是 UIView (MKMapView)。此外,值得注意的是makeUIView() 可能希望创建一个新的 UIView,而不是对现有 UIView 的引用,该 UIView 可能已经或可能不存在于该视图控制之外的布局中。
    【解决方案2】:

    除了为什么您需要在不使用协调员,包装器或其他方法,这是您的答案:

    1. 添加一种从外部获取依赖项的方法:
      struct CompassButton: UIViewRepresentable {
          weak var mapView: MKMapView? // ? Get it from outside (weak for avoiding leak)
          func updateUIView(_ uiView: MKCompassButton, context: Context) { uiView.compassVisibility = .visible }
          func makeUIView(context: Context) -> MKCompassButton { .init(mapView: mapView) }
      }
      
      1. 使依赖项对外部可见:
      struct MapView: UIViewRepresentable {
          private (set) var mapView: MKMapView = .init() // ? make it visible
          func updateUIView(_ uiView: MKMapView, context: Context) { }
          func makeUIView(context: Context) -> MKMapView { mapView }
      }
      
      1. 保留参考并稍后使用:
      struct ContentView: View {
          let mapView = MapView() // ? Keep a reference
          var body: some View {
              ZStack {
                  mapView
                  CompassButton(mapView: mapView.mapView) // ? Use the reference
                      .fixedSize()
              }
          }
      }
      

      我认为这不是您应该实现的方式,但我认为这就是您正在寻找的

    【讨论】:

    • 如果在视图层次结构的最顶层使用此策略,它永远不会重新加载,我可以看到它是如何可行的,但如果不是,private (set) var mapView: MKMapView = .init() 不保证一个新的MKMapView 的实例将在每次通过他的层次结构时创建?
    • 你应该make it visible。保持指针的责任在你身上,取决于用例
    • 您可能至少需要var mapView: MKMapView 上的@State,以确保ContentView 值类型正确管理MKMapView 引用类型的生命周期。
    • 我一直担心的一个问题是makeUIView 可能想要控制UIView 的生命周期。在这个解决方案中,UIView 的生命周期被外部化,SwiftUI 不再受控制。例如。如果它出于某种原因希望 UIView 被销毁,然后再创建一个新的,它就不能再这样做了。
    【解决方案3】:

    您无法访问UIViewRepresentable 的内部结构,如果您坚持使用UIView 变量,您将开始收到非常流行的“不允许更新视图时更新视图错误”。 Apple 只是不允许使用 SwiftUI 访问内部结构。

    创建一个在 UIKit 和 SwiftUI 之间共享的通用“ViewModel”/Controller 是最简单的方法。 UIView 将存在于 UIViewController 中,因此您可以获得 UIKit 的所有好处。

    import SwiftUI
    import MapKit
    ///Source of truth for both SwiftUI and UIKit
    class MapCompassViewModel: ObservableObject, MapController{
        var provider: (any MapProvider)!
        
        func toggleCompassVisibility(){
            provider.toggleCompassVisibility()
        }
        func addCompass(){
            provider.addCompass()
        }
    }
    

    您可以使用protocols 隐藏内部实现。

    protocol MapProvider{
        var map : MKMapView {get set}
        func toggleCompassVisibility()
        func addCompass()
    }
    protocol MapController{
        var provider: (any MapProvider)! {get set}
    }
    

    UI 部分只是一个ViewUIViewControllerRepresentable 和一个UiViewController

    ///Plain SwiftUI View
    struct MapCompassView: View {
        @StateObject var vm: MapCompassViewModel = .init()
        var body: some View {
            VStack{
                //This is needed to for the very first frame,
                //when we are waiting for the provider
                //to be set for the UIViewController
                if vm.provider != nil{
                    Compass_UI(vm: vm)
                        .frame(width: 20, height: 20)
                }
                Button("Show/Hide compass", action: vm.toggleCompassVisibility)
                MapCompass_UI(vm: vm)
            }
        }
    }
    ///Convers UIKit `UIViewController` to a `UIViewControllerRepresentable`
    struct Compass_UI: UIViewRepresentable{
        let vm: any MapController
        func makeUIView(context: Context) -> some UIView {
            let m = MKCompassButton(mapView: vm.provider.map)
            m.frame.origin = .init(x: 40, y: 20)
            m.compassVisibility = .visible
            return m
        }
        func updateUIView(_ uiView: UIViewType, context: Context) {
            
        }
    }
    ///Converts UIKit `UIViewController` to a `UIViewControllerRepresentable`
    struct MapCompass_UI: UIViewControllerRepresentable{
        let vm: any MapController
        func makeUIViewController(context: Context) -> some UIViewController {
            MapCompassViewController(vm: vm)
        }
        func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
            
        }
    }
    ///Regular `UIViewController` that uses `MapCompassViewModel`
    ///This can be as complex as needed.
    class MapCompassViewController: UIViewController, MapProvider{
        var vm: any MapController
        lazy var map: MKMapView = {
            let m = MKMapView(frame: .zero)
            m.showsCompass = false
            return m
        }()
        lazy var compass: MKCompassButton = {
            let m = MKCompassButton(mapView: map)
            m.frame.origin = .init(x: 20, y: 20)
            m.compassVisibility = .visible
            return m
        }()
        init(vm: any MapController) {
            self.vm = vm
            super.init(nibName: nil, bundle: nil)
            //Critical connection between SwiftUI and UIKit
            self.vm.provider = self
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        override func viewDidLoad() {
            super.viewDidLoad()
            //Add map
            view.addSubview(map)
            //Pin map to edges
            map.translatesAutoresizingMaskIntoConstraints = false
            map.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
            map.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
            map.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
            map.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
            //Add compass
            map.addSubview(compass)
        }
        func toggleCompassVisibility(){
            compass.compassVisibility = compass.compassVisibility == .visible ? .hidden : .visible
        }
        func addCompass() {
            print("(#function) :: add your compass code")
        }
        
    }
    struct MapCompassView_Previews: PreviewProvider {
        static var previews: some View {
            MapCompassView()
        }
    }
    

    【讨论】:

    • 不错的作品。也许稍微调整一下,我们可以通过MapController 公开对MKMapView 的访问,因为请记住,问题的最初意图是从两个单独的SwiftUI Views 访问UIView 引用。具体来说,想象一下想要将Map_UI 放置在您的 SwiftUI 层次结构中的一个位置,而将Compass_UI 放置在 SwiftUI 层次结构中的不同位置,同时它们继续共享指向同一底层 MKMapView 引用的链接。目前,两者通过UIKit.UIView.addSubview 停留在同一层次结构中
    • @Ihunath 只需将变量添加到MapProvider 即可轻松公开它,我将其添加到上面的代码中。
    猜你喜欢
    • 1970-01-01
    • 2016-05-30
    • 2018-06-28
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-06-15
    • 2014-06-06
    相关资源
    最近更新 更多