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