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!

2
  • 1
    Does this answer your question? Combining custom property wrapper with @Published Commented Sep 1, 2023 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. Commented Sep 2, 2023 at 11:07

1 Answer 1

-2

In SwiftUI you shouldn't even have a view model object because the View struct hierarchy is designed to hold the view data and the body func is called when SwiftUI detects changes in lets and states you would lose that behaviour if you try to use objects. The issue is because of a couple of mistakes in your UIViewRepresentable, e.g.

Coordinator(self)

Should be

Coordinator()

self is the value of the struct which is in init many times so it becomes out of date.

Also, you can't use dispatchAsync in value type and your update needs to use the new value of the binding like this

@Binding var coordinate: CLLocationCoordinate2D

func updateUIView(_ uiView: MKMapView, context: Context) {

    context.coordinator.centerDidChange = nil // prevents update loop
    uiView.centerCoordinate = coordinate
    context.coordinator.centerDidChange = c in {
        coordinate = c
    }
}

Your coordinator needs a centerDidChange closure you call from a map delegate func.

If you want to animate then check the context has animations turned on first.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.