UIKit update of SwiftUI #State causing "Modifying state during view update" - swift

I have a SwiftUI component loading a MapKit like this :
struct AddressView: View {
#State private var showingPlaceDetails = false
var body: some View {
MapView(showPlaceDetails: self.$showPlaceDetails)
}
}
The MapView component is a MapKit struct using UIKit -> SwiftUI wrapping technique :
struct MapView: UIViewRepresentable {
#Binding var showingPlaceDetails: Bool
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator
return map
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, MKMapViewDelegate {
var control: MapView
init(_ control: MapView) {
self.control = control
}
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
// This is where the warning happen
self.control.showingPlaceDetails = true
}
}
}
So mutating showPlaceDetails is firing this warning : [SwiftUI] Modifying state during view update, this will cause undefined behavior.
How should i clean this code ? Is this implementation correct ?
I understand that i am changing thru a #Binding a parent #State property that will re-render AddressView with MapView.
I understand why its bad, like to change a state property inside a render in React and that this mutation should occur outside the body but how can it be done as i need MapView inside the body ?
XCode Version 11.3.1 (11C504)
macOS Version 10.15.4 Beta (19E224g)

The usual fix for this is pending modification, like below
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
DispatchQueue.main.async {
self.control.showingPlaceDetails = true
}
}

Related

Error when adding polygon overlay to MKMapView

I have a map view that is conforming to UIViewRepresentable to use it in my SwiftUI app. When my view model updates with data after a successful API call, I add to my array of overlays with an array of coordinates to represent a map boundary. This array of overlays is then passed to the view and then the overlay is added as an MKPolygon in updateUIView. I return a render this polygon in the delegate method.
struct MapViewUIKit: UIViewRepresentable {
#Binding var annotations: [MKPointAnnotation]
#Binding var mapRect: MKMapRect
#Binding var overlays: [MKOverlay]
let mapView = MKMapView()
func makeUIView(context: Context) -> MKMapView {
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
view.setVisibleMapRect(mapRect, animated: true)
if annotations.count != view.annotations.count {
view.removeAnnotations(view.annotations)
view.addAnnotations(annotations)
}
if overlays.count != view.overlays.count {
view.removeOverlays(view.overlays)
view.addOverlays(overlays)
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapViewUIKit
init(_ parent: MapViewUIKit) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
parent.mapRect = mapView.visibleMapRect
}
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if overlay is MKPolygon {
let renderer = MKPolygonRenderer(overlay: overlay)
renderer.strokeColor = .magenta
renderer.fillColor = .blue
return renderer
}
return MKOverlayRenderer()
}
}
}
However, when I zoom into the area where the polygon should be rendered, I get the following error and there is no overlay
Wrapped around the polygon without finishing... :-(
List has 6 nodes:
1 2 4 5 6 0
2022-03-14 15:01:36.159966-0400 Synop[3702:59086] [VKDefault] Building failed to triangulate!
I've tried searching what this error means to no avail. I know my overlay is being added and with the proper coordinates as well with print statements in the relevant places.

UIViewRepresentable wrapped UIKit Map jumps back on moving

I'm trying to draw routes on a map and I had to come to the conclusion that this is not yet possible with SwiftUI. Many tutorials suggested wrapping the old MKMapVIew into a UIViewRepresentable which seems to work fine but I have one problem.
Because I need to be able to change the region from outside, I've added this line into the updateUIView method but this causes issues when I'm moving the map. I think what happens is when I scroll the map it updates the state but it does not do it for all the frames of the movements so when this updated region 'comes back' through the updateUIView method, my map is already in an other position so it jumps back.
How can I only update the region when it is intended?
Here is my code:
struct CustomMap: UIViewRepresentable{
typealias UIViewType = MKMapView
#Binding var region: MKCoordinateRegion
var onMapTaped: ((_ item: MKMapItem) -> Void)?
private let mapview: MKMapView = MKMapView()
func makeUIView(context: Context) -> MKMapView {
mapview.delegate = context.coordinator
mapview.setRegion(region, animated: true)
mapview.showsUserLocation = true
mapview.addGestureRecognizer(context.coordinator.tapRecognizer)
return mapview
}
func updateUIView(_ uiView: MKMapView, context: Context) {
uiView.setRegion(region, animated: false)
}
func makeCoordinator() -> MapViewCoordinator {
return MapViewCoordinator(customMapView: self)
}
class MapViewCoordinator: NSObject, MKMapViewDelegate{
let customMapView: CustomMap
var tapRecognizer: UITapGestureRecognizer = UITapGestureRecognizer()
init(customMapView: CustomMap){
self.customMapView = customMapView
super.init()
self.tapRecognizer = UITapGestureRecognizer(target:self, action: #selector(tapOnMap))
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
DispatchQueue.main.async {
self.customMapView.region = mapView.region
}
}
}
}
No the most elegant solution in my opinion but it seems like there is no better one at the time. I got the inspiration from #Asperi's comment.
I passed a closure to the initializer of my view that accepts an MKMapView . I save that map view in my state so I can update the region on a button tap or whenever I want.

Display overlay view when selecting annotation in SwiftUI

I'm using UIRepresentable to show annotations on a map, and want to be able to show a view when tapping on the selected pin.
I was previously using Map() so was able to use the .onTapGesture for the annotations, but now that the annotations are made from UIKit, how to I pass the selected item to the main view?
What I previously had working:
var body: some View {
ZStack {
Map(region: $region, annotationItems: $model.locations) { location in
MapPin(coordinate: location.coord)
.onTapGesture {
modelData.selectedLocation = location
modelData.isShowingDetail = true
}
}
if modelData.isShowingDetail {
DetailView(
isShowingDetail: $modelData.isShowingDetail,
location: modelData.selectedLocation!
)
}
}
}
Now I have the UIViewRepresentable:
struct UIMapView: UIViewRepresentable {
// default setup - coordinator, makeUI, updateUI
class Coordinator: NSObject, MKMapViewDelegate {
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
// how to trigger the overlay??
}
}
}
Any help would be appreciated as I am very stuck on this :)
You want to know the selected annotation in your SwiftUI view. So you have to store it somewhere. Declare a #State :
struct ContentView: View {
let locations: [MKAnnotation]
#State private var selectedLocation: MKAnnotation?
var body: some View {
// ... //
}
}
Now in your wrapper (UIViewRepresentable) you have to make a binding with this MKAnnotation? :
struct MapView: UIViewRepresentable {
#Binding var selectedLocation: MKAnnotation? // HERE
let annotations: [MKAnnotation]
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.region = // .... //
mapView.addAnnotations(annotations)
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
// .... //
}
Now you should be able to access this variable in your Delegate (Coordinator). For that you have to pass the UIViewRepresentable to the Coordinator:
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
And finally in func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) you can copy the MKAnnotation in parent.selectedLocation .
With the #Binding this MKAnnotation is now accessible in your parent view (ContentView). You can display its properties in your DetailView.
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// ... //
}
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
parent.selectedLocation = view.annotation
}
}
}
For example :
struct ContentView: View {
let locations: [MKAnnotation]
#State private var selectedLocation: MKAnnotation?
var body: some View {
VStack {
Text("\(selectedLocation?.coordinate.latitude ?? 0)")
// Don't forget the Binding : $selectedLocation
MapView(selectedLocation: $selectedLocation, annotations: locations)
}
}
}
In your class Coordinator: NSObject, MKMapViewDelegate {...} you can have access to functions such as:
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
...
}
func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
...
}
use these (and the others) to do what you do what you want to achieve.

Can I use an ObservedObject inside a Coordinator?

I have a MapView using MKMapView, and I have a HomeView which shows the MapView and ResultsCarouselView (for now just replacing it with Circle for ease).
If I have an observable object to manage the state for this HomeView, is it possible to use this from the coordinator? For example, anything I call from HomeState in the coordinator does not update HomeView:
MapCoordinator
final class MapCoordinator: NSObject, MKMapViewDelegate {
#ObservedObject var homeState = HomeState()
// other code, init, etc.
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
self.homeState.setUserLocated(true)
}
}
HomeState:
class HomeState: NSObject, ObservableObject {
#Published var userLocated = false
func setUserLocated(_ value: Bool) {
self.userLocated = value // debugger comes here, but is this another instance or something?
}
}
HomeView:
struct HomeView: View {
#ObservedObject var homeState = HomeState()
var body: some View {
ZStack(alignment: .bottom) {
MapView()
if (homeState.userLocated) {
Circle() // this doesn't show up
}
}
}
}
struct MapView: UIViewRepresentable {
#StateObject var homeState = HomeState()
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
self.parent.homeState.setUserLocated(true)
}
}
}
You use the parent variable in the Coordinator to modify any #State or #ObservableObject such as your HomeState #ObservedObject

How do I see if a annotation is selected with MapBox in SwiftUI?

I'm trying to update my UI accordingly when a MapBox annotation is selected using swiftUI. Everything works good until I change the bool within the MapView Coordinator. Once I do, the annotations will not update.
struct MainView: View {
#State var annotations: [MGLPointAnnotation] = []
#State var pingDetailsShown = false
var body: some View {
///...
MapView(annotations: self.$annotations, pingDetailsShown: self.$pingDetailsShown).centerCoordinate(.init(latitude: 53.460067, longitude: -114.996973)).zoomLevel(5.0)
//...
MapView
struct MapView: UIViewRepresentable {
#Binding var annotations: [MGLPointAnnotation]
#Binding var pingDetailsShown: Bool
private let mapView: MGLMapView = MGLMapView(frame: .zero, styleURL: MGLStyle.streetsStyleURL)
func makeUIView(context: UIViewRepresentableContext<MapView>) -> MGLMapView {
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MGLMapView, context: UIViewRepresentableContext<MapView>) {
updateAnnotations()
}
func makeCoordinator() -> MapView.Coordinator {
Coordinator(self, pingDetailsShown: $pingDetailsShown)
}
private func updateAnnotations() {
if let currentAnnotations = mapView.annotations {
mapView.removeAnnotations(currentAnnotations)
}
mapView.addAnnotations(annotations)
}
Here's where I run into trouble...
final class Coordinator: NSObject, MGLMapViewDelegate {
var control: MapView
var startZoom: Double = 5.0
#Binding var pingDetailsShown: Bool
init(_ control: MapView, pingDetailsShown: Binding<Bool>) {
self.control = control
self._pingDetailsShown = pingDetailsShown
}
func mapView(_ mapView: MGLMapView, didSelect annotation: MGLAnnotation) {
print(((annotation.title ?? "") ?? ""))
pingDetailsShown = true
}
func mapView(_ mapView: MGLMapView, didDeselect annotationView: MGLAnnotationView) {
pingDetailsShown = false
}
}
I've tried the pingDetailsShown as not binded also, but the same issue happens, as soon as I change the pingDetailsShown value, it no longer allows the MapView annotations to be updated.
All I am trying to do is update the MainView UI when an annotation is selected, and have the annotations still refresh after.
If you declare your coordinates array and point annotations similarly to this Annotation Views example, your annotations should still update successfully.
Declare your coordinates in the didFinishLoading method:
let coordinates = [
CLLocationCoordinate2D(latitude: 37.791329, longitude: -122.396906),
CLLocationCoordinate2D(latitude: 37.791591, longitude: -122.396566),
CLLocationCoordinate2D(latitude: 37.791147, longitude: -122.396009),
CLLocationCoordinate2D(latitude: 37.790883, longitude: -122.396349),
CLLocationCoordinate2D(latitude: 37.791329, longitude: -122.396906),
]
for coordinate in coordinates {
let point = MGLPointAnnotation()
point.coordinate = coordinate
point.title = "\(coordinate.latitude), \(coordinate.longitude)"
pointAnnotations.append(point)
}
mapView.addAnnotations(pointAnnotations)
and then in the didSelectAnnotation method, you can set whatever action you wish to occur when the annotation is selected:
func mapView(_ mapView: MGLMapView, didSelect annotation: MGLAnnotation) {
if annotation.title == "37.791329, -122.396906" {
mapView.styleURL = MGLStyle.lightStyleURL
} else if annotation.title == "37.790883, -122.396349" {
mapView.styleURL = MGLStyle.darkStyleURL
}
pingDetailsShown = true
}
You should change updateUIView and updateAnnotations to include the current view being refreshed:
func updateUIView(_ uiView: MGLMapView, context: Context) {
updateAnnotations(uiView)
}
private func updateAnnotations(_ view: MGLMapView) {
if let currentAnnotations = view.annotations {
view.removeAnnotations(currentAnnotations)
}
view.addAnnotations(annotations)
}