Unexpected acting of MapKit in SwiftUI - swiftui

I'm stuck with the really strange problem. I'm implementing map into my SwiftUI app. It should act like a normal map (drag, scroll and so on). When changing position (that is binding point) the app gets an address via geocoder.
Also user can click "Change" button and enter address manually (with autocompletion). After selecting the address, the map should move to the reverse geocoded point.
Built-in SwiftUI Map() is a good thing, but... it's unreal to make it show buildings. And in the app it's something that matters. So, going with UIViewRepresentable gives me another strange problem.
If I set the center coordinate in UpdateUIView, the map stops any interactivity. Otherwise changing the address manually doesn't work.
What could be wrong with this?
struct MapView: UIViewRepresentable {
#Binding var point: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.showsBuildings = true
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// uiView.setCenter(point, animated: true)
}
func makeCoordinator() -> MapView.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
self.parent.point = mapView.centerCoordinate
}
}
}
I tried wrapping everything into DispatchQueue.main.async {} - not working (and honestly I don't think it could)
I also tried this solution, but it worked neither: https://www.reddit.com/r/SwiftUI/comments/kti9r9/uiviewrepresentable_how_to_update_bindings/

I also had the same problem. I solved this using #state. So every time the mapView changes, the corresponding function of the coordinator is definitely called. Hope it helps.
struct YourView: View {
#State mapView: MKMapView = .init()
#State var point: CLLocationCoordinate2D = [...]
var body: some View {
MapView(mapView: $mapView, point: $point)
...
}
struct MapView: UIViewRepresentable {
#Binding var mapView: MKMapView
#Binding var point: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
self.mapView = MKMapView()
self.mapView.showsBuildings = true
self.mapView.delegate = context.coordinator
return self.mapView
}
...
func updateUIView(_ uiView: MKMapView, context: Context) {
// uiView.setCenter(point, animated: true)
}
func makeCoordinator() -> MapView.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
self.parent.point = self.mapView.centerCoordinate
}
}
}

Related

SwiftUI and UIKit Interoperability with displaying multiple views

Overview: I'm using SwiftUI, but wanted to use UIKit-MapKit. I used UIViewRepresentable to be able to wrap the UIKit feature.
Problem: I'm learning about swiftui-uikit-interoperability and I'm getting stuck on being able to display multiple SwiftUI views.
Code Snippet:
ContentView
struct ContentView: View {
#ObservedObject var viewModel: MapView.PinViewModel
init() {
self.viewModel = MapView.PinViewModel()
}
var body: some View {
NavigationView {
MapView()
.sheet(isPresented: $viewModel.showPinForm) {
PinForm()
}
.navigationTitle("SwiftUI UIKit Interop").scaledToFill()
}
}
}
MapView
struct MapView: UIViewRepresentable {
class PinViewModel: ObservableObject {
#Published var showPinForm: Bool
init() {
self.showPinForm = false
}
func updateShowPinVar() {
self.showPinForm = true
}
}
func showPinForm() {
pinViewModel.updateShowPinVar()
}
func makeCoordinator() -> MapViewCoordinator {
let coordinator = MapViewCoordinator()
coordinator.delegate = self
return coordinator
}
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
let coordinate = CLLocationCoordinate2D(latitude: 40.7209, longitude: -74.0007)
let span = MKCoordinateSpan(latitudeDelta: 0.03, longitudeDelta: 0.03)
let mapRegion = MKCoordinateRegion(center: coordinate, span: span)
mapView.setRegion(mapRegion, animated: true)
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
}
}
In this I have a #Published var showPinForm that gets toggled in MapView. ContentView is supposed to watch this variable and when it is true it will cause the sheet to pull up. However, I believe when I enter MapView() from ContentView() then I no longer recognize ContentView.
Using the UIViewRepresentable, what is the best way to display another swiftui view? Does not have to use .sheet (Although, it would be nice)
I have tried to simplify the code to show the main problem, so I left out a lot of additional info and took out basic patterns that I used (MVVM)
Please let me know if you need any clarifications
try to follow this pattern, you can toggle the flag both inside and outside your MapView
struct MapView: UIViewRepresentable {
#Binding var switcher: Bool // -> use binding
func makeUIView(context: Context) -> MKMapView { MKMapView() }
func updateUIView(_ uiView: MKMapView, context: Context) { }
}
struct MainView: View {
#ObservedObject var viewModel = MainViewModel()
var body: some View {
MapView(switcher: $viewModel.flag)
.sheet(isPresented: $viewModel.flag) {
Text("Pin pin")
}
}
}
class MainViewModel: ObservableObject {
#Published var flag: Bool = false
}

How can I show/hide the title of my MKPointAnnotation() pin after I set it inside of my UIViewRepresentable updateUIView (SwiftUI)?

I would like to toggle the visibility of the title of my MKPointAnnotation after I tap the pin. I tried changing the title directly in
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) but it tells me that it's only a get property and I cannot change it inside of my Coordinator class.
Any help would be much appreciated!
Here is the relevant code...
import SwiftUI
import MapKit
import CoreLocationUI
struct MapViewTest: UIViewRepresentable {
#EnvironmentObject var viewModel: MapViewModel
#Binding var region: MKCoordinateRegion
#Binding var lineCoordinates: [[CLLocationCoordinate2D]]
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
mapView.region = region
mapView.showsUserLocation = true
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
view.setRegion(region, animated: true)
for i in viewModel.locations {
let pin = MKPointAnnotation()
pin.coordinate = i.coordinate
pin.title = i.name
view.addAnnotation(pin)
}
for i in lineCoordinates{
let polyline = MKPolyline(coordinates: i, count: i.count)
view.addOverlay(polyline)
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapViewTest
init(_ parent: MapViewTest) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
}
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let routePolyline = overlay as? MKPolyline {
let renderer = MKPolylineRenderer(polyline: routePolyline)
renderer.strokeColor = UIColor.systemBlue
renderer.lineWidth = 10
return renderer
}
return MKOverlayRenderer()
}
}
Whenever you are making a MapKit annotation, you should include func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView?. This function allows you to configure your pins, but it also allows you to reuse unused pins. Whenever a pin disappears from a map (scrolling around, etc.), that pin is not destroyed, but it is held to be reused in another pin is needed. This saves processor and memory.
In your Coordinator class add the following function:
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// create a unique identifier for pin reuse
let identifier = "Placemark"
// see if there already is a created pin
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
if annotationView == nil {
// there wasn't a pin, so we make a new one
annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier)
// this is where your title is allowed to be shown when tapping the pin
annotationView?.canShowCallout = true
// this gives you an information button in the callout if needed
// if you use the rightCalloutAccessoryView you must implement:
// func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl)
annotationView?.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)
} else {
// we have an old annotation, so update it
annotationView?.annotation = annotation
}
return annotationView
}

Customize Mapbox style with layers in SwiftUI

I have an issue to customize a Mapbox view's style, like for instance adding some information on the map if a switch is switched on. Not sure if it's important, but the layers I need to add are MGLSymbolStyleLayer and MGLLineStyleLayer.
Let's start with code for the main view containing a switch representing a state used to customize the map's style, and an UIViewRepresentable for the Mapbox view.
struct Test_MapBox: View {
#State private var styleURL: URL = MGLStyle.outdoorsStyleURL
#State private var switchButton: Bool = false
var body: some View {
ZStack(alignment: .bottom) {
MapView(switchButton: switchButton)
.styleURL(styleURL)
.edgesIgnoringSafeArea(.all)
Toggle(isOn: $switchButton, label: {
Text("Switch")
})
}
}
}
struct MapView: UIViewRepresentable {
var switchButton: Bool
var mapView = MGLMapView(frame: .zero)
func makeUIView(context: UIViewRepresentableContext<MapView>) -> MGLMapView {
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MGLMapView, context: UIViewRepresentableContext<MapView>) {
print("Style: \(uiView.style)")
print("Update view, switch: \(switchButton)")
}
func makeCoordinator() -> MapView.Coordinator {
Coordinator(self)
}
func styleURL(_ styleURL: URL) -> MapView {
mapView.styleURL = styleURL
return self
}
final class Coordinator: NSObject, MGLMapViewDelegate {
var parent: MapView
init(_ control: MapView) {
self.parent = control
}
func mapViewDidFinishLoadingMap(_ mapView: MGLMapView) {
print("Map loaded, switch: \(parent.switchButton)")
}
func mapView(_ mapView: MGLMapView, didFinishLoading style: MGLStyle) {
print("Style loaded, switch: \(parent.switchButton)")
}
}
}
The issue is the following: inside the delegate functions, switchButton is never up to date, always false (and I don't understand why)... And in updateUIView(), switchButton is OK, but the style is not yet loaded, so usually you get a nil when accessing it ...
Have you got a solution ?

How to declare GMSMapViewDelegate on SwiftUI

I'm trying to implement a solution with GoogleMapsApi with a map where user can touch the map and do things. For that, I understand delegate must be implemented, but I can't figure out how to achieve that with SwifUI. There's a lot of code samples on web, when in Swift, or even Objective C, but I couldn't find any on SwifUI.
Here's what I did (I'm trying to keep this code as simple as it could be):
struct GoogleMapsHomeView: UIViewRepresentable {
func makeUIView(context: Self.Context) -> GMSMapView {
let mapView = GMSMapView.map()
return mapView
}
func updateUIView(_ mapView: GMSMapView, context: Context) {
}
}
struct HomeView: View {
var body: some View {
GoogleMapsHomeView()
}
}
struct HomeView_Previews: PreviewProvider {
static var previews: some View {
HomeView()
}
}
How to declare GMSMapViewDelegate and related listener for a user map moving detection?
The common pattern is to use coordinator as delegate
struct GoogleMapsHomeView: UIViewRepresentable {
func makeUIView(context: Self.Context) -> GMSMapView {
let mapView = GMSMapView.map()
mapView.delegate = context.coordinator
return mapView
}
func makeCoordinator() -> Coordinator {
Coordinator(owner: self)
}
func updateUIView(_ mapView: GMSMapView, context: Context) {
}
class Coordinator: NSObject, GMSMapViewDelegate {
let owner: GoogleMapsHomeView // access to owner view members,
init(owner: GoogleMapsHomeView) {
self.owner = owner
}
// ... delegate methods here
}
}

UIViewRepresentable: "Modifying state during view update, this will cause undefined behavior"

I have made a simple UIViewRepresentable from MKMapView. You can scroll the mapview, and the screen will be updated with the coordinates in the middle.
Here's the ContentView:
import SwiftUI
import CoreLocation
let london = CLLocationCoordinate2D(latitude: 51.50722, longitude: -0.1275)
struct ContentView: View {
#State private var center = london
var body: some View {
VStack {
MapView(center: self.$center)
HStack {
VStack {
Text(String(format: "Lat: %.4f", self.center.latitude))
Text(String(format: "Long: %.4f", self.center.longitude))
}
Spacer()
Button("Reset") {
self.center = london
}
}.padding(.horizontal)
}
}
}
Here's the MapView:
struct MapView: UIViewRepresentable {
#Binding var center: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
uiView.centerCoordinate = self.center
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
parent.center = mapView.centerCoordinate
}
init(_ parent: MapView) {
self.parent = parent
}
}
}
Tapping the reset button should simply set mapView.center to london. The current method will make the map scrolling super slow, and when the button is tapped, cause the error "Modifying state during view update, this will cause undefined behavior."
How should resetting the coordinates be communicated to the MKMapView, such that the map scrolling is fast again, and the error is fixed?
The above solution with an ObservedObject will not work. While you wont see the warning message anymore, the problem is still occurring. Xcode just isn't able to warn you its happening anymore.
Published properties in ObservableObjects behave almost identically to #State and #Binding. That is, they trigger a view update any time their objectWillUpdate publisher is triggered. This happens automatically when an #Published property is updated. You can also trigger it manually yourself with objectWillChange.send()
Because of this, it is possible to make properties that do not automatically cause view state to update. And we can leverage this to prevent unwanted state updates for UIViewRepresentable and UIViewControllerRepresentable structs.
Here is an implementation that will not loop when you update its view model from the MKMapViewDelegate methods:
struct MapView: UIViewRepresentable {
#ObservedObject var viewModel: Self.ViewModel
func makeUIView(context: Context) -> MKMapView{
let mapview = MKMapView()
mapview.delegate = context.coordinator
return mapview
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// Stop update loop when delegate methods update state.
guard viewModel.shouldUpdateView else {
viewModel.shouldUpdateView = true
return
}
uiView.centerCoordinate = viewModel.centralCoordinate
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
private var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView){
// Prevent the below viewModel update from calling itself endlessly.
parent.viewModel.shouldUpdateView = false
parent.viewModel.centralCoordinate = mapView.centerCoordinate
}
}
class ViewModel: ObservableObject {
#Published var centerCoordinate: CLLocationCoordinate2D = .init(latitude: 0, longitude: 0)
var shouldUpdateView: Bool = true
}
}
If you really dont want to use an ObservableObject, the alternative is to put the shouldUpdateView property into your coordinator. Although I still prefer to use a viewModel because it keeps your UIViewRepresentable free of multiple #Bindings. You can also use the ViewModel externally and listen to it via combine.
Honestly, I'm surprised apple didn't consider this exact issue when they created UIViewRepresentable.
Almost all UIKit views will have this exact problem if you need to keep your SwiftUI state in sync with view changes.