1

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!

RL2000
  • 913
  • 10
  • 20
  • 1
    Does this answer your question? [Combining custom property wrapper with @Published](https://stackoverflow.com/questions/66118723/combining-custom-property-wrapper-with-published) – lorem ipsum Sep 01 '23 at 23:26
  • Thanks for the link. I tried implementing that real quick and couldn't get it to work. I'll try some more when I have some time next week. – RL2000 Sep 02 '23 at 11:07

0 Answers0