SwiftUI: avoid recreating/rerendering view in TabView with MKMapView+UIViewRepresentable - ios

I have a TabView with three tabs, one of which contains a map view that's implemented like this:
struct MapView: UIViewRepresentable {
let region: MKCoordinateRegion
let animatedRegion: Bool
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView(frame: .zero)
mapView.delegate = context.coordinator
mapView.showsUserLocation = true
mapView.setRegion(region, animated: animatedRegion)
return mapView
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func updateUIView(_ mapView: MKMapView, context: Context) {
}
class Coordinator: NSObject, MKMapViewDelegate {
var control: MapView
init(_ control: MapView) {
self.control = control
}
}
}
The tab view is implemented like this:
TabView(selection: $selection) {
MapView(/* params */)
.tabItem {
Image(systemName: "1.square.fill")
Text("map")
}.tag(1)
Text("Screen #2")
.tabItem {
Image(systemName: "2.square.fill")
Text("2")
}.tag(2)
Text("Screen #3")
.tabItem {
Image(systemName: "3.square.fill")
Text("3")
}.tag(3)
}
The problem is that the makeUIView(:context) method is executed every time I switch back to the map tab from one of the other two tabs. It appears that the underlying MKMapView instance is deallocated when I switch to another tab, then it's recreated when I switch back. In UIKit it doesn't rerender the whole view like that. Am I doing something wrong, or is there anything I can do to make sure that the underlying MKMapView instance is retained when I switch back so that it doesn't have to recreate it every single time?

You need to store instance of MKMapView for each tab and destroy before you leaving the view because SwiftUI will destroy MapView when it need to render for example the binding vars have changed.
struct MapView: UIViewRepresentable {
#Binding var currentTab: Int
static private var mapViews = [Int: MKMapView]()
let region: MKCoordinateRegion
let animatedRegion: Bool
func makeUIView(context: Context) -> MKMapView {
guard MapView.mapViews[currentTab] != nil else { return MapView.mapViews[currentTab]! }
let mapView = MKMapView(frame: .zero)
mapView.delegate = context.coordinator
mapView.showsUserLocation = true
mapView.setRegion(region, animated: animatedRegion)
MapView.mapViews[currentTab] = mapView
return mapView
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func updateUIView(_ mapView: MKMapView, context: Context) {
}
class Coordinator: NSObject, MKMapViewDelegate {
var control: MapView
init(_ control: MapView) {
self.control = control
}
}
}

use EnvironmentObject. always update it and pass it in TabView {MapView(coordinates: Object.coordinates)}

Related

Map annotations only appear after map is moved

I have a map which loads annotations from the Google API, when the map initially loads all the annotations they are 'placed' as seen through the print in the console, however they won't show up on the map until I move the map once. Does anyone know if I need to call a method to update the map after placing the annotations?
struct ContentView: View {
var locationSearch = LocationSearch()
#State private var mapView = MapView()
#State var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: -33.7944, longitude: 151.2649), span: MKCoordinateSpan(latitudeDelta: 0.015, longitudeDelta: 0.015))
#EnvironmentObject var sheetManager: SheetManager
var body: some View {
mapView
.popup(with: SheetManager())
.frame(width: UIScreen.screenWidth, height: UIScreen.screenHeight)
}
}
struct MapView: UIViewRepresentable {
#State var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: -33.7944, longitude: 151.2649), span: MKCoordinateSpan(latitudeDelta: 0.015, longitudeDelta: 0.015))
func updateUIView(_ uiView: MKMapView, context: Context) {
print("FLF: MapView updated")
uiView.setNeedsDisplay()
}
var locationManager = CLLocationManager()
let mapView = MKMapView(frame: CGRect(x: 0, y: 0, width: UIScreen.screenWidth, height: UIScreen.screenHeight))
func setupManager() {
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.requestWhenInUseAuthorization()
locationManager.requestAlwaysAuthorization()
}
func makeUIView(context: Context) -> MKMapView {
setupManager()
mapView.region = ContentView().region
mapView.showsUserLocation = true
mapView.userTrackingMode = .follow
mapView.delegate = context.coordinator // set the delegate to the coordinator
placeMarkersForRegion(region: region)
return mapView
}
func placeMarkersForRegion(region: MKCoordinateRegion) {
var locationSearch = LocationSearch()
locationSearch.performSearch(region: region) { venues in
print("FLF: Placing \(venues.count) marker(s)")
for marker in venues {
let annotation = MKPointAnnotation()
annotation.coordinate = marker.location
annotation.title = marker.name
mapView.addAnnotation(annotation)
}
}
}
func makeCoordinator() -> MapViewCoordinator {
MapViewCoordinator(self) // pass self to the coordinator so it can call `regionDidChangeAnimated`
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
// Use the 'coordinate' property to get the current location of the map view
let currentRegion = mapView.region
print("FLF: Map has moved")
self.placeMarkersForRegion(region: currentRegion)
// Do something with the current region (e.g. update a state variable or perform a search)
}
}
class MapViewCoordinator: NSObject, MKMapViewDelegate {
var parent: MapView // add a property to hold a reference to the parent view
init(_ parent: MapView) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
// Call the parent's implementation of this method
parent.mapView(mapView, regionDidChangeAnimated: animated)
}
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
view.canShowCallout = true
view.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)
// Get the tapped annotation
guard let annotation = view.annotation else { return }
// Print the title of the annotation
print(annotation.title ?? "Unknown")
}
func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
print("FLF: Marker tapped")
}
}
The UIViewRepresentable and Coordinator aren't implemented correctly. E.g. makeUIView has to init it, but you are initing it as a property on the struct which is immediately lost. Also MapViewCoordinator(self) is a mistake because self, i.e. the struct, is immediately disgarded after SwiftUI has updated.
Another issue is the #State shouldn't hold a View like how your ContentView has a #State for the MapView.
Here is an example of how to use MKMapView with UIViewRepresentable:
struct MKMapViewRepresentable: UIViewRepresentable {
#Binding var userTrackingMode: MapUserTrackingMode
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> MKMapView {
context.coordinator.mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// MKMapView has a strange design that the delegate is called when setting manually so we need to prevent an infinite loop
context.coordinator.userTrackingModeChanged = nil
uiView.userTrackingMode = userTrackingMode == .follow ? MKUserTrackingMode.follow : MKUserTrackingMode.none
context.coordinator.userTrackingModeChanged = { mode in
userTrackingMode = mode == .follow ? MapUserTrackingMode.follow : MapUserTrackingMode.none
}
}
class Coordinator: NSObject, MKMapViewDelegate {
lazy var mapView: MKMapView = {
let mv = MKMapView()
mv.delegate = self
return mv
}()
var userTrackingModeChanged: ((MKUserTrackingMode) -> Void)?
func mapView(_ mapView: MKMapView, didChange mode: MKUserTrackingMode, animated: Bool) {
userTrackingModeChanged?(mode)
}
}
}

Marking makeUIView func makes view NOT conform to UIViewRepresentable?

I am trying to update my map's style URL immediately when the device changes from light/dark mode in my SwiftUI app.
The error below shows on every line where "mapView" is declared inside func makeUIView...
"Cannot use mutating getter on immutable value: 'self' is immutable. Mark method 'mutating' to make 'self' mutable"
I was instructed to put mapView inside of makeUIView but there is another func called setCenter() that needs to modify the mapView.
However, when I mark the method mutating the view does not conform to UIViewRepresentable and this error shows every where "mapView" is inside of makeUIView
What can I do to fix this?
struct MapboxMapViewRepresentable: UIViewRepresentable {
#Environment(\.colorScheme) var colorScheme
func makeCoordinator() -> Coordinator {
print("Make coordinator")
return Coordinator(control: self)
}
var mapboxStyleUrl: String {
"mapbox://styles/" + (colorScheme == .dark ? "cl0318cec0sdfdsfsfsfnrshos" : "ckwih3sdfsdfdfsfdfiz7403d0h")
}
lazy var mapView: MGLMapView = {
return MGLMapView(frame: .zero, styleURL: URL(string: mapboxStyleUrl)!)
}()
// FUNC CANNOT BE MUTATING BECAUSE VIEW WILL NOT CONFORM TO UIVIEWREPRESENTABLE
func makeUIView(context: Context) -> MGLMapView {
mapView.showsUserLocation = true
mapView.delegate = context.coordinator
mapView.allowsRotating = false
mapView.direction = 0.0
return mapView
}
// NEEDS TO BE ABLE TO ACCESS mapView
func setCenterAt(coordinate : CLLocationCoordinate2D) {
mapView.setCenter(coordinate, zoomLevel: MapViewModel.startAtZoomLevel, animated: false)
}
func updateUIView(_ mapView: MGLMapView, context: Context) {
//print("updateUIView")
// clear spot data of other users only when zoomed out too far to see them
}
}
A view struct cannot be modified from within itself and makeUIView is exactly a place to create a view (life cycle is managed by SwiftUI), so the fix is to move creation inside, like
func makeUIView(context: Context) -> MGLMapView {
// HERE !!
let mapView = MGLMapView(frame: .zero, styleURL: URL(string: mapboxStyleUrl)!)
mapView.showsUserLocation = true
mapView.delegate = context.coordinator
mapView.allowsRotating = false
mapView.direction = 0.0
return mapView
}

Support swipe to dismiss for UIViewControllerRepresentable presented in sheet

It seems if you use UIViewControllerRepresentable to implement a view controller in your SwiftUI app, when you present it via sheet you cannot swipe to dismiss it. Is there something you need to do to support swipe to dismiss?
struct ContentView: View {
#State var showingPicker = false
var body: some View {
Text("Hello, world!")
.onAppear {
showingPicker = true
}
.sheet(isPresented: $showingPicker, content: {
PHPicker() //cannot swipe to dismiss
//Text("Test") //can swipe to dismiss
})
}
}
struct PHPicker: UIViewControllerRepresentable {
func makeUIViewController(context: UIViewControllerRepresentableContext<PHPicker>) -> PHPickerViewController {
let config = PHPickerConfiguration()
return PHPickerViewController(configuration: config)
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: UIViewControllerRepresentableContext<PHPicker>) { }
}
Possible solution is to add something like handle to drag (no styling - simplified for demo),
.sheet(isPresented: $showingPicker, content: {
VStack {
RoundedRectangle(cornerRadius: 8).fill(Color.gray)
.frame(width: 60, height: 8)
.padding(.top, 8)
PHPicker()
}
})
Alternate: the solution is to make presentation by UIKit completely and just pass activation binding inside representable.
Here is a demo of possible approach. Tested with Xcode 12.1 / iOS 14.1
struct PHPickerContentView: View {
#State var showingPicker = false
var body: some View {
Button("Picker") {
showingPicker = true
}
.background(PHPicker(isPresented: $showingPicker)) // << here !!
}
}
struct PHPicker: UIViewControllerRepresentable {
#Binding var isPresented: Bool
func makeUIViewController(context: Context) -> UIViewController {
UIViewController() // << picker presenter
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
// react on binding & show if not shown
if isPresented && uiViewController.presentedViewController == nil {
let config = PHPickerConfiguration()
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
uiViewController.present(picker, animated: true)
picker.presentationController?.delegate = context.coordinator
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate, UIAdaptivePresentationControllerDelegate {
let owner: PHPicker
init(_ owner: PHPicker) {
self.owner = owner
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
// picked image handling code here
picker.presentingViewController?.dismiss(animated: true)
owner.isPresented = false // << reset on action !!
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
owner.isPresented = false // << reset on swipe !!
}
}
}

How to add gestures to MapView in SwiftUI

I am trying to add gestures (tap, swipe, etc) to a Mapkit Map View in ContentView.swift to no avail. I started with googlemaps and then tried Mapkit. There are two ways I know of to add gestures to a view.
Add gestures to mapView like the following:
import SwiftUI
struct ContentView: View {
var body: some View {
MapView()
.onTapGesture(count: 2) {
print("Double tapped!")
}
.edgesIgnoringSafeArea(.all)
}
Add gestures in MapView view as follows:
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.doubleTapped))
tap.numberOfTapsRequired = 2
mapView.addGestureRecognizer(tap)
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
}
#objc func doubleTapped() {
print("Double Tapped...")
}
}
}
Neither of these methods seem to work. The preferrable way to me is #1 but I tried an alternate way as in #2. Any ideas on what I am doing wrong? I am using Xcode 11.4 (11E146).Thanks!

How to get MKMapView Directions in SwiftUI

In my application I'm using MKMapView SwiftUI implementation. My map is working good but I want to get directions when tapped a button from ContentView. I've explained in more detail below...
Here is my ContentView:
struct ContentView: View {
var body: some View {
VStack {
AppleMapView(coordinate: CLLocationCoordinate2D(latitude: 40.7127, longitude: -74.0059))
.frame(height: 400)
Button(action: {
**// !!! I want to call AppleMapView/getDirections() method here !!!**
}) {
Text("Get Directions")
}
}
}
}
Here is my MapView:
struct AppleMapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
// some codes //
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// some codes //
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
func getDirections() {
// some codes //
}
class Coordinator: NSObject, MKMapViewDelegate {
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
let renderer = MKPolylineRenderer(overlay: overlay)
renderer.strokeColor = .blue
renderer.lineWidth = 4
return renderer
}
}
}
Thanks.
Create a #Binding property on your MapView and then set the directions from your ContentView inside the Button action.
This way your updateUIView will get called accordingly with the updated values.
struct ContentView: View {
#State var directions: [CLLocation] = []
var body: some View {
VStack {
MapView(directions: $directions)
Button(action: {
// Directions are Nepal to India
self.directions = [CLLocation(latitude: 27.2041206, longitude: 84.6093928), CLLocation(latitude: 20.7712763, longitude: 73.7317739)]
}) {
Text("Get Directions")
}
}
}
}
struct MapView: UIViewRepresentable {
#Binding var directions: [CLLocation]
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
let renderer = MKPolylineRenderer(overlay: overlay)
renderer.strokeColor = .blue
renderer.lineWidth = 4
return renderer
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> MKMapView {
return MKMapView()
}
func updateUIView(_ mapView: MKMapView, context: Context) {
var coordinates = self.directions.map({(location: CLLocation!) -> CLLocationCoordinate2D in return location.coordinate})
let polyline = MKPolyline(coordinates: &coordinates, count: self.directions.count)
mapView.delegate = context.coordinator
mapView.addOverlay(polyline)
}
}

Resources