I've been working on a SwiftUI wrapper around a MKMapView
, and in trying to work on a binding value for the visible area of the map, I created a custom property wrapper that allows the MKMapViewDelegate
to send out the current visible region, while only accepting incoming values through a special function. I did this because using a simple Binding<MKMapRect>
was causing problems if I tried to use it to both set the visible map area and to read it (the two would conflict while the map view was in motion).
Here's enough code to get a working sample for anyone interested and having a similar problem making a SwiftUI wrapped MKMapView
. My question is at the bottom...
First, the fancy, wrapped MapView:
struct FancyMapView: UIViewRepresentable {
private var coordinateBinding: CoordinateBinding?
init(coordinateBinding: CoordinateBinding? = nil) {
self.coordinateBinding = coordinateBinding
}
func makeUIView(context: Context) -> MKMapView {
let view = MKMapView()
view.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// If coordinateBinding was used to request a new center coordinate...
if let (requestedCoord, animate) = coordinateBinding?.externalSetValue {
// send the view to the coordinate, and remove the request
uiView.setCenter(requestedCoord, animated: animate)
DispatchQueue.main.async {
coordinateBinding?.externalSetValue = nil
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: FancyMapView
init(_ parent: FancyMapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
// Send the mapView's current centerCoordinate to parent's binding
DispatchQueue.main.async {
self.parent.coordinateBinding?.wrappedValue = mapView.centerCoordinate
}
}
}
}
Next, the property wrapper CoordinateBinding
, which is in the same file as FancyMapView
so fileprivate
access can be used.
@propertyWrapper struct CoordinateBinding: DynamicProperty {
// fileprivate setters so only CoordinateBinding and FancyMapView can set these values
@State public fileprivate(set) var wrappedValue: CLLocationCoordinate2D?
@State fileprivate var externalSetValue: (CLLocationCoordinate2D, Bool)?
// public func called to set the MapView's center coordinate
public func sendTo(coordinate: CLLocationCoordinate2D, animated: Bool) {
externalSetValue = (coordinate, animated)
}
}
And a ContentView
that makes use of FancyMapView
:
struct ContentView: View {
@CoordinateBinding var centerCoordinate: CLLocationCoordinate2D? = nil
@StateObject var viewModel = MapViewModel()
var body: some View {
FancyMapView(coordinateBinding: _centerCoordinate)
.overlay(alignment: .top) {
VStack {
Text(coordinateString)
Button("Jump!", action: jumpButtonPressed)
}
}
}
var coordinateString: String {
if let centerCoordinate {
return "\(centerCoordinate.latitude),\(centerCoordinate.longitude)"
} else {
return ""
}
}
func jumpButtonPressed() {
let coord = viewModel.nextSampleCoordinate()
_centerCoordinate.sendTo(coordinate: coord, animated: true)
}
}
And a simple ViewModel class:
class MapViewModel: ObservableObject {
var sampleIndex = 0
var sampleCoordinates: [CLLocationCoordinate2D] {
[
CLLocationCoordinate2D(latitude: 51.49863, longitude: -0.07518), // london
CLLocationCoordinate2D(latitude: 40.71342, longitude: -73.98839), // new york
CLLocationCoordinate2D(latitude: -37.90055, longitude: 144.98106) // melbourne
]
}
func nextSampleCoordinate() -> CLLocationCoordinate2D {
let result = sampleCoordinates[sampleIndex]
if sampleIndex == sampleCoordinates.count - 1 {
sampleIndex = 0
} else {
sampleIndex += 1
}
return result
}
}
With all that I have a map view that jumps from one center coordinate to another as I press the button, and if the map is still animating a move to the previous coordinate it just changes course and ends at the right one. (Obviously, this could all be done with the native SwiftUI Map
view, but this is a simplified version of what I'm working on, so please humor me here)
The Question:
The problem arises if I want to simplify the ContentView
file by moving the wrapped @CoordinateBinding
into the MapViewModel
, along with the code that sets the coordinate. I can't find a way to do that and have the functions still work.
If I put this as a property in MapViewModel
:
@CoordinateBinding var coordinateBinding: CLLocationCoordinate2D?
Then the ContentView
can't access the underlying CoordinateBinding
struct to feed into the FancyMapView
initializer.
If I instead use this property in MapViewModel
:
var coordinateBinding = CoordinateBinding()
Then the ContentView
doesn't read updates from coordinateBinding
. Same result if I make that property @Published
.
Not the end of the world if I have to keep the wrapped property in the view rather than the ViewModel, but if anyone knows a way to make this work, I'd love to hear it. Thanks!