3

I am quite new to Swift and SwiftUI, and I want to add a user tracking button on top of the mapview, so user's current location can be back in the center of the screen when tapped. I have already have the mapview and the button, but failed to make it work.

here is the ContentView.swift file, and I am stuck at the place with ****:

import SwiftUI
import MapKit

struct ContentView: View {
    var body: some View {
      ZStack {
        MapView(locationManager: $locationManager)
        .edgesIgnoringSafeArea(.bottom)

        HStack {
          Spacer()
          VStack {
            Spacer()
            Button(action: {
                ******
            }) {
              Image(systemName: "location")
                .imageScale(.small)
                .accessibility(label: Text("Locate Me"))
                .padding()
            }
            .background(Color.white)
            .cornerRadius(10)
            .padding()
          }
        }
      }
    }

And here is the MapView.swift:

import SwiftUI
import MapKit
import CoreLocation
import ECMapNavigationAble


struct MapView: UIViewRepresentable, ECMapNavigationAble{

    var locationManager = CLLocationManager()

    func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
        MKMapView()
    }

    func updateUIView(_ view: MKMapView, context: UIViewRepresentableContext<MapView>){
        view.showsUserLocation = true
        view.isPitchEnabled = false

        self.locationManager.requestAlwaysAuthorization()
        self.locationManager.requestWhenInUseAuthorization()

        if CLLocationManager.locationServicesEnabled() {
            self.locationManager.desiredAccuracy = kCLLocationAccuracyBest

        }

        if let userLocation = locationManager.location?.coordinate {
            let userLocationEC = ECLocation(coordinate : userLocation, type: .wgs84)
            let viewRegion = MKCoordinateRegion(center: userLocationEC.gcj02Coordinate, latitudinalMeters: 200, longitudinalMeters: 200)
            view.userTrackingMode = .follow
            view.setRegion(viewRegion, animated: true)
        }

        DispatchQueue.main.async{
            self.locationManager.startUpdatingLocation()

        }
    }
}
Yanchen Li
  • 31
  • 1
  • 2
  • Have you found an answer to your problem? I'm having the same issue I guess. I had it working, but as I wanted to handle all possible authorisation changes, I found myself stuck with a weird bug If I find something I'll let you know. – Rémi B. Dec 10 '19 at 20:57
  • I have a solution I just need to show an alert and after that I'll share it here – Rémi B. Dec 11 '19 at 18:48

4 Answers4

6

I had the same problem, and after a few hours, I managed to get something working as required

  • At launch, it shows the user location if he authorised it, but waits for him to tap the button to activate the follow.
  • If he authorised and taps the button, it follows him.
  • If for some reason the app cannot access his location (denied access, restrictions…), it will tell him to change it in Settings and redirect him there.
  • If he changes the authorization while running the app, it will change automatically.
  • If the map is following him, the button disappears.
  • If he drags the map (stop following), the button appears again.
  • (The button supports Dark Mode)

I really hope it'll help you, I think I'll put it on GitHub some day. I'll add the link here if I do.

First, I didn't want to recreate the MKMapView every time, so I put it in a class I called MapViewContainer

I don't think it's a good practice, though ‍♂️

If you don't want to use it, just replace @EnvironmentObject private var mapViewContainer: MapViewContainer by let mapView = MKMapView(frame: .zero) in MKMapViewRepresentable (and change the mapViewContainer.mapView for mapView)

import MapKit

class MapViewContainer: ObservableObject {
    
    @Published public private(set) var mapView = MKMapView(frame: .zero)
    
}

Then, I could create my MapViewRepresentable

import SwiftUI
import MapKit

// MARK: - MKMapViewRepresentable

struct MKMapViewRepresentable: UIViewRepresentable {
    
    var userTrackingMode: Binding<MKUserTrackingMode>
    
    @EnvironmentObject private var mapViewContainer: MapViewContainer
    
    func makeUIView(context: UIViewRepresentableContext<MKMapViewRepresentable>) -> MKMapView {
        mapViewContainer.mapView.delegate = context.coordinator
        
        context.coordinator.followUserIfPossible()
        
        return mapViewContainer.mapView
    }
    
    func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MKMapViewRepresentable>) {
        if mapView.userTrackingMode != userTrackingMode.wrappedValue {
            mapView.setUserTrackingMode(userTrackingMode.wrappedValue, animated: true)
        }
    }
    
    func makeCoordinator() -> MapViewCoordinator {
        let coordinator = MapViewCoordinator(self)
        return coordinator
    }
    
    // MARK: - Coordinator
    
    class MapViewCoordinator: NSObject, MKMapViewDelegate, CLLocationManagerDelegate {
        
        var control: MKMapViewRepresentable
        
        let locationManager = CLLocationManager()
        
        init(_ control: MKMapViewRepresentable) {
            self.control = control
            
            super.init()
            
            setupLocationManager()
        }
        
        func setupLocationManager() {
            locationManager.delegate = self
            locationManager.desiredAccuracy = kCLLocationAccuracyBest
            locationManager.pausesLocationUpdatesAutomatically = true
        }
        
        func followUserIfPossible() {
            switch CLLocationManager.authorizationStatus() {
            case .authorizedAlways, .authorizedWhenInUse:
                control.userTrackingMode.wrappedValue = .follow
            default:
                break
            }
        }
        
        private func present(_ alert: UIAlertController, animated: Bool = true, completion: (() -> Void)? = nil) {
            // UIApplication.shared.keyWindow has been deprecated in iOS 13,
            // so you need a little workaround to avoid the compiler warning
            // https://stackoverflow.com/a/58031897/10967642
            
            let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }
            keyWindow?.rootViewController?.present(alert, animated: animated, completion: completion)
        }
        
        // MARK: MKMapViewDelegate
        
        func mapView(_ mapView: MKMapView, didChange mode: MKUserTrackingMode, animated: Bool) {
            #if DEBUG
            print("\(type(of: self)).\(#function): userTrackingMode=", terminator: "")
            switch mode {
            case .follow:            print(".follow")
            case .followWithHeading: print(".followWithHeading")
            case .none:              print(".none")
            @unknown default:        print("@unknown")
            }
            #endif
            
            if CLLocationManager.locationServicesEnabled() {
                switch mode {
                case .follow, .followWithHeading:
                    switch CLLocationManager.authorizationStatus() {
                    case .notDetermined:
                        locationManager.requestWhenInUseAuthorization()
                    case .restricted:
                        // Possibly due to active restrictions such as parental controls being in place
                        let alert = UIAlertController(title: "Location Permission Restricted", message: "The app cannot access your location. This is possibly due to active restrictions such as parental controls being in place. Please disable or remove them and enable location permissions in settings.", preferredStyle: .alert)
                        alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in
                            // Redirect to Settings app
                            UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
                        })
                        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

                        present(alert)
                        
                        DispatchQueue.main.async {
                            self.control.userTrackingMode.wrappedValue = .none
                        }
                    case .denied:
                        let alert = UIAlertController(title: "Location Permission Denied", message: "Please enable location permissions in settings.", preferredStyle: .alert)
                        alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in
                            // Redirect to Settings app
                            UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
                        })
                        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
                        present(alert)
                        
                        DispatchQueue.main.async {
                            self.control.userTrackingMode.wrappedValue = .none
                        }
                    default:
                        DispatchQueue.main.async {
                            self.control.userTrackingMode.wrappedValue = mode
                        }
                    }
                default:
                    DispatchQueue.main.async {
                        self.control.userTrackingMode.wrappedValue = mode
                    }
                }
            } else {
                let alert = UIAlertController(title: "Location Services Disabled", message: "Please enable location services in settings.", preferredStyle: .alert)
                alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in
                    // Redirect to Settings app
                    UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
                })
                alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
                present(alert)
                
                DispatchQueue.main.async {
                    self.control.userTrackingMode.wrappedValue = mode
                }
            }
        }
        
        // MARK: CLLocationManagerDelegate
        
        func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
            #if DEBUG
            print("\(type(of: self)).\(#function): status=", terminator: "")
            switch status {
            case .notDetermined:       print(".notDetermined")
            case .restricted:          print(".restricted")
            case .denied:              print(".denied")
            case .authorizedAlways:    print(".authorizedAlways")
            case .authorizedWhenInUse: print(".authorizedWhenInUse")
            @unknown default:          print("@unknown")
            }
            #endif
            
            switch status {
            case .authorizedAlways, .authorizedWhenInUse:
                locationManager.startUpdatingLocation()
                control.mapViewContainer.mapView.setUserTrackingMode(control.userTrackingMode.wrappedValue, animated: true)
            default:
                control.mapViewContainer.mapView.setUserTrackingMode(.none, animated: true)
            }
        }
        
    }
    
}

And finally, put it in a SwiftUI View

import SwiftUI
import CoreLocation.CLLocation
import MapKit.MKAnnotationView
import MapKit.MKUserLocation

struct MapView: View {
    
    @State private var userTrackingMode: MKUserTrackingMode = .none
    
    var body: some View {
        ZStack {
            MKMapViewRepresentable(userTrackingMode: $userTrackingMode)
                .environmentObject(MapViewContainer())
                .edgesIgnoringSafeArea(.all)
            VStack {
                if !(userTrackingMode == .follow || userTrackingMode == .followWithHeading) {
                    HStack {
                        Spacer()
                        Button(action: { self.followUser() }) {
                            Image(systemName: "location.fill")
                                .modifier(MapButton(backgroundColor: .primary))
                        }
                        .padding(.trailing)
                    }
                    .padding(.top)
                }
                Spacer()
            }
        }
    }
    
    private func followUser() {
        userTrackingMode = .follow
    }
    
}

fileprivate struct MapButton: ViewModifier {
    
    let backgroundColor: Color
    var fontColor: Color = Color(UIColor.systemBackground)
    
    func body(content: Content) -> some View {
        content
            .padding()
            .background(self.backgroundColor.opacity(0.9))
            .foregroundColor(self.fontColor)
            .font(.title)
            .clipShape(Circle())
    }
    
}
Community
  • 1
  • 1
Rémi B.
  • 2,410
  • 11
  • 17
  • Good solution. If anyone wants to zooming in to the user location right from the start, change @State private var userTrackingMode: MKUserTrackingMode = .none to .follow – unequalsine Feb 27 '20 at 20:50
0

For anyone else having trouble implementing, @Remi b. answer if a very viable option and I spent many hours trying to implement this in to my project but I ended up going a different way. This allows for the location button to work and cycle just types of location tracking and the buttons image like in the Maps App. This is what I went with:

After adding my basic MKMapView I created a UIViewRepresentable for MKUserTrackingButton like this: note: The @EnvironmentObject var viewModel: ViewModel contains my mapView)

struct LocationButton: UIViewRepresentable {
    @EnvironmentObject var viewModel: ViewModel

    func makeUIView(context: Context) -> MKUserTrackingButton {
        return MKUserTrackingButton(mapView: viewModel.mapView)
    }

    func updateUIView(_ uiView: MKUserTrackingButton, context: Context) { }
}

Then in my SwiftUI ContentView or wherever you want to add the tracking button to:

struct MapButtonsView: View {
    @EnvironmentObject var viewModel: ViewModel
    
    var body: some View {
        ZStack {
            VStack {
                Spacer()
                Spacer()
                HStack {
                    Spacer()
                    VStack(spacing: 12) {
                        
                        Spacer()
                        
                        // User tracking button
                        LocationButton()
                            .frame(width: 20, height: 20)
                            .background(Color.white)
                            .padding()
                            .cornerRadius(8)
                        
                    }
                    .padding()
                }
            }
        }
    }
}
kev
  • 21
  • 5
0

Here is my way to go back to the user's location. I use Notification to notify Mapview. I uploaded the demo project in my github

First, define a notification name

extension Notification.Name {
  static let goToCurrentLocation = Notification.Name("goToCurrentLocation")
}

secondly, listen on the notification in MapView coordinator:


import SwiftUI
import MapKit
import Combine

struct MapView: UIViewRepresentable {
    private let mapView = MKMapView()

    func makeUIView(context: Context) -> MKMapView {
        mapView.isRotateEnabled = false
        mapView.showsUserLocation = true
        mapView.delegate = context.coordinator
        let categories: [MKPointOfInterestCategory] = [.restaurant, .atm, .hotel]
        let filter = MKPointOfInterestFilter(including: categories)
        mapView.pointOfInterestFilter = filter
        return mapView
    }
    
    func updateUIView(_ mapView: MKMapView, context: Context) {
    }
    func makeCoordinator() -> Coordinator {
        .init(self)
    }
    
    class Coordinator: NSObject, MKMapViewDelegate {
        private var control: MapView
        private var lastUserLocation: CLLocationCoordinate2D?
        private var subscriptions: Set<AnyCancellable> = []

        init(_ control: MapView) {
            self.control = control
            super.init()
            
            NotificationCenter.default.publisher(for: .goToCurrentLocation)
                .receive(on: DispatchQueue.main)
                .sink { [weak self] output in
                    guard let lastUserLocation = self?.lastUserLocation else { return }
                    control.mapView.setCenter(lastUserLocation, animated: true)
                }
                .store(in: &subscriptions)
        }
        
        func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
            lastUserLocation = userLocation.coordinate
        }
    }
}

lastly, send the notification when the user taps the button:

var body: some View {
        return ZStack {
            MapView()
                .ignoresSafeArea(.all)
                .onAppear() {
                    viewModel.startLocationServices()
                    goToUserLocation()
                }
            
            VStack {
                Spacer()
                
                Button(action: {
                    goToUserLocation()
                }, label: {
                    Image(systemName: "location")
                        .font(.title2)
                        .padding(10)
                        .background(Color.primary)
                        .clipShape(Circle())
                })
                
            }
            .frame(maxWidth: .infinity, alignment: .trailing)
            .padding()
        }


    private func goToUserLocation() {
        NotificationCenter.default.post(name: .goToCurrentLocation, object: nil)
    }


DàChún
  • 4,751
  • 1
  • 36
  • 39
0

Here is how I added a button to jump back to the user's location using SwiftUI, and MapKit.

This code isn't perfect (unwrapping variables could be handled better) but it will help other rookies like me.

The keys are:

  1. Use @StateObject for the location data. That way the Map can observe this object as it changes and update the map when it changes.
  2. Add a button that calls the updateMapToUsersLocation function on the MapViewModel
  3. You need to @Published var region the region so when it changes the map will update.
  4. region.center = locationManager.location!.coordinate the locationManager has the users location, set the published region equal to that, and the map is observing that object so it will update.

Main ContentView

//
//  ContentView.swift
//  MapExperiment001
//
//  Created by @joshdance
//

import SwiftUI
import MapKit

struct MapView: View {
    
    @StateObject private var mapViewModel = MapViewModel()
    
    var body: some View {
        
        ZStack {
            
            Map(coordinateRegion: $mapViewModel.region, showsUserLocation: true)
                .ignoresSafeArea()
                .accentColor(Color(.systemPink))
                .onAppear {
                    mapViewModel.checkIfLocationManagerIsEnabled()
                }
            
            VStack {
                Spacer()
                
                HStack {
                    VStack(alignment: .leading) {
                        Text("User Lat: \(String(format: "%.5f", mapViewModel.userLat))")
                        Text("User Long: \(String(format: "%.5f", mapViewModel.userLong))")
                    }.padding()
                    
                    Spacer()
                    
                    Button(action: {
                        mapViewModel.updateMapToUsersLocation()
                    }) {
                        Image(systemName: "location.fill")
                            .foregroundColor(.blue)
                            .padding()
                    }.frame(width: 64, height: 64)
                        .background(Color.white)
                    .clipShape(Circle())
                    .padding()
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        MapView()
    }
}

MapViewModel

//
//  MapViewModel.swift
//  MapExperiment001
//
//  Created by @joshdance
//

import MapKit

enum MapDetails {
    static let startingLocation = CLLocationCoordinate2D(latitude: 40.2338, longitude: -111.6585)
    static let defaultSpan = MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
}

final class MapViewModel: NSObject, ObservableObject, CLLocationManagerDelegate {
    
    @Published var userLat: Double = 1.0
    @Published var userLong: Double = 2.0
    
    @Published var region = MKCoordinateRegion(
        center: MapDetails.startingLocation,
        span: MapDetails.defaultSpan)
    
    var locationManager: CLLocationManager? //optinal because they can turn off location services.
    
    func checkIfLocationManagerIsEnabled() {
        if CLLocationManager.locationServicesEnabled() == true {
            locationManager = CLLocationManager()
            //when CLLocation manager is created it fires delegate 'locationManagerDidChangeAuthorization' event.
            locationManager!.delegate = self
            
        } else {
            //TODO show alert letting them know it is off and how to turn it on.
        }
    } // end check
    
    func updateMapToUsersLocation() {
        guard let locationManager = locationManager else { return }
        
        let coordinate = locationManager.location!.coordinate
        region.center = coordinate
        userLat = coordinate.latitude
        userLong = coordinate.longitude
    }
    
    //check app permission
    private func checkAppLocationPermission() {
        guard let locationManager = locationManager else { return }
    
        switch locationManager.authorizationStatus {
            
        case .notDetermined:
            //ask permission
            locationManager.requestWhenInUseAuthorization()
        case .restricted:
            //restricted due to parental controls etc
            print("Your location is restricted maybe due to parental controls")
        case .denied:
            print("You have denied this app location permission, you can turn it back on in Settings.")
        case .authorizedAlways:
            updateMapToUsersLocation()
        case .authorizedWhenInUse:
            updateMapToUsersLocation()
        @unknown default:
            break
        }
    }
    
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        checkAppLocationPermission()
    }
}
Joshua Dance
  • 8,847
  • 4
  • 67
  • 72