Can I use an ObservedObject inside a Coordinator? - swift

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

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.

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.

Update MapView with current location on SwiftUI

Trying to update the mapview of the Project 14 of 100daysOfSwiftUI to show my current location, the problem i can´t zoom in move around
i have this code i add #Binding var currentLocation : CLLocationCoordinate2D and view.setCenter(currentLocation, animated: true) to my MapView so i have a button that send thats value and the view actually move so slow to the location but then i can move away anymore
import SwiftUI
import MapKit
struct MapView: UIViewRepresentable {
#Binding var centerCoordinate: CLLocationCoordinate2D
#Binding var selectedPlace: MKPointAnnotation?
#Binding var showingPlaceDetails: Bool
#Binding var currentLocation : CLLocationCoordinate2D
var annotations: [MKPointAnnotation]
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
if annotations.count != view.annotations.count {
view.removeAnnotations(view.annotations)
view.addAnnotations(annotations)
}
view.setCenter(currentLocation, animated: true)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate{
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
parent.centerCoordinate = mapView.centerCoordinate
}
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
let identifier = "PlaceMark"
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
if annotationView == nil {
annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier)
annotationView?.canShowCallout = true
annotationView?.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)
} else {
annotationView?.annotation = annotation
}
return annotationView
}
func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
guard let placemark = view.annotation as? MKPointAnnotation else {return}
parent.selectedPlace = placemark
parent.showingPlaceDetails = true
}
}
}
an this is my swiftUI view
...
#State private var currentLocation = CLLocationCoordinate2D()
var body: some View {
ZStack{
MapView(centerCoordinate: $centerCoordinate, selectedPlace: $selectedPlace, showingPlaceDetails: $showingPlaceDetails, currentLocation: $currentLocation , annotations: locations)
// MapView(centerCoordinate: $centerCoordinate, selectedPlace: $selectedPlace, showingPlaceDetails: $showingPlaceDetails, annotations: locations)
.edgesIgnoringSafeArea(.all)
VStack{
Spacer()
HStack{
Spacer()
Button(action: {
self.getCurrentLocation()
}){
ButtonIcon(icon: "location.fill")
}
}
.padding()
}
}
.onAppear(perform: getCurrentLocation)
}
func getCurrentLocation() {
let lat = locationManager.lastLocation?.coordinate.latitude ?? 0
let log = locationManager.lastLocation?.coordinate.longitude ?? 0
self.currentLocation.latitude = lat
self.currentLocation.longitude = log
}
...
UPDATE
thanks for the support I using this class to call locationManager.requestWhenInUseAuthorization()
import Foundation
import CoreLocation
import Combine
class LocationManager: NSObject, ObservableObject {
override init() {
super.init()
self.locationManager.delegate = self
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
self.locationManager.requestWhenInUseAuthorization()
self.locationManager.startUpdatingLocation()
}
#Published var locationStatus: CLAuthorizationStatus? {
willSet {
objectWillChange.send()
}
}
#Published var lastLocation: CLLocation? {
willSet {
objectWillChange.send()
}
}
var statusString: String {
guard let status = locationStatus else {
return "unknown"
}
switch status {
case .notDetermined: return "notDetermined"
case .authorizedWhenInUse: return "authorizedWhenInUse"
case .authorizedAlways: return "authorizedAlways"
case .restricted: return "restricted"
case .denied: return "denied"
default: return "unknown"
}
}
let objectWillChange = PassthroughSubject<Void, Never>()
private let locationManager = CLLocationManager()
}
extension LocationManager: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
self.locationStatus = status
print(#function, statusString)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
self.lastLocation = location
print(#function, location)
}
}
i just want to center my mapview on my current location when i press the button
No where here do you ever call locationManager.requestWhenInUseAuthorization(). When I did that (of course, making sure the Info.plist had an entry for NSLocationWhenInUseUsageDescription), it updated the location correctly.
E.g.
func getCurrentLocation() {
if CLLocationManager.authorizationStatus() == .notDetermined {
locationManager.requestWhenInUseAuthorization()
}
if let coordinate = locationManager.location?.coordinate {
currentLocation = coordinate
}
}
Now, this is just a quick and dirty fix to demonstrate that it works. But it’s not quite right, because the first time you call getCurrentLocation, if it has to ask the user for permission, which it does asynchronously, which means that it won’t yet have a location when you get to the lastLocation line in your implementation. This is a one time thing, but still, it’s not acceptable. You’d want your CLLocationManagerDelegate update currentLocation if needed. But hopefully you’ve got enough here to diagnose why your location is not being captured correctly by the CLLocationManager.
FWIW, you might consider using a userTrackingMode of .follow, which obviates the need for all of this manual location manager and currentLocation stuff. The one caveat I’ll mention (because I spent hours one day trying to diagnose this curious behavior), is that the userTrackingMode doesn’t work if you initialize your map view with:
let mapView = MKMapView()
But it works if you do give it some frame, e.g.:
let mapView = MKMapView(frame: UIScreen.main.bounds)
So, for user tracking mode:
struct MapView: UIViewRepresentable {
#Binding var userTrackingMode: MKUserTrackingMode
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView(frame: UIScreen.main.bounds)
mapView.delegate = context.coordinator
mapView.userTrackingMode = userTrackingMode
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
view.userTrackingMode = userTrackingMode
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
// MARK: - MKMapViewDelegate
func mapView(_ mapView: MKMapView, didChange mode: MKUserTrackingMode, animated: Bool) {
DispatchQueue.main.async {
self.parent.$userTrackingMode.wrappedValue = mode
}
}
// note, implementation of `mapView(_:viewFor:)` is generally not needed if we register annotation view class
}
}
And then, we can have a “follow” button that appears when user tracking is turned off (so that you can turn it back on):
struct ContentView: View {
#State var userTrackingMode: MKUserTrackingMode = .follow
private var locationManager = CLLocationManager()
var body: some View {
ZStack {
MapView(userTrackingMode: $userTrackingMode)
.edgesIgnoringSafeArea(.all)
VStack {
HStack {
Spacer()
if self.userTrackingMode == .none {
Button(action: {
self.userTrackingMode = .follow
}) {
Text("Follow")
}.padding()
}
}
Spacer()
}
}.onAppear { self.requestAuthorization() }
}
func requestAuthorization() {
if CLLocationManager.authorizationStatus() == .notDetermined {
locationManager.requestWhenInUseAuthorization()
}
}
}

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

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

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
}
}