Search code examples
swiftuiuikit

Accessing another UIViewRepresentable's controller or UIView


UIViewRepresentable is useful for bringing UIKit views into SwiftUI context. Their primary limitation is that the instantiation of the UIKit side of things is not under our control - it happens as-needed by the SwiftUI subsystem.

This creates difficulties when two UIViews need to have knowledge of each other in order to collaborate. An example could be an MKMapView and an MKCompassButton. The latter needs an instance of the former to sync with.

Passing such a reference between separate UIViewRepresentable values is difficult since the controller or view is not available to us directly.

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: ???) }
    }
}

Does anyone know of a mechanism by which we can allow two SwiftUI views based on UIViewRepresentable to collaborate using their underlying UIKit views, perhaps through sharing a controller instance, or other means?

My first thought would be to move the instantiation of the controller out of makeController and into the UIViewRepresentable directly as a var, but this would likely interfere with the SwiftUI life-cycle management of the controller.


Solution

  • You can't access the internals of a UIViewRepresentable and if you hold on to the UIView variable you'll start getting the "updating view while updating view isn't allowed error" that is quite popular. Apple just doesn't allow access to internals with SwiftUI.

    Creating a common "ViewModel"/Controller that is shared between UIKit and SwiftUI is the simplest way to do this. The UIView's would exist in a UIViewController so you get all the UIKit benefits.

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

    You can use protocols to hide the internal implementations.

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

    The UI part is just a View a UIViewControllerRepresentable and a 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 let provider = vm.provider {
                    Compass_UI(provider: provider)
                        .frame(width: 20, height: 20)
                }
                Button("Show/Hide compass", action: vm.toggleCompassVisibility)
                MapCompass_UI(vm: vm)
            }
        }
    }
    ///Converts UIKit `UIView` to a `UIViewRepresentable`
    struct Compass_UI: UIViewRepresentable{
        let provider: any MapProvider
        func makeUIView(context: Context) -> some UIView {
            let m = MKCompassButton(mapView: provider.map)
            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
            DispatchQueue.main.async{
                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")
        }
        deinit{
            vm.provider = nil
        }
    }
    struct MapCompassView_Previews: PreviewProvider {
        static var previews: some View {
            MapCompassView()
        }
    }