I'm working on an iOS app that includes a map view. I want the map to automatically center on the user's location when available, and if the user's location is not available or not granted, I want to set a default location.
In my MapViewModel, I have the following code for centering the map on the user's location:
func centerMapOnUserLocation() {
if let location = locationManager.location {
let region = MKCoordinateRegion(center: location.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.21,
longitudeDelta: 0.21))
userLocation = region
map.setRegion(region, animated: true)
} else {
// Set default location
userLocation = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 38.897957,
longitude: -77.036560),
span: MKCoordinateSpan(latitudeDelta: 0.21,
longitudeDelta: 0.21))
map.setRegion(userLocation, animated: true)
}
}
I have a couple of questions:
Am I correctly handling the centering of the map on the user's location and setting a default location if the user's location is not available or not granted? Is there a better approach or any best practices for handling the centering of the map based on user location? Additionally, I want to implement a feature where the map searches for nearby establishments whenever the user moves the map using pan gesture or zooms in/out. Currently, I have the following code in the regionDidChangeAnimated delegate method:
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
if !didSetInitialUserZoom {
didSetInitialUserZoom = true // Marcar que se ha establecido el zoom inicial
let mapCenterLatitude = userLocation.center.latitude
let mapCenterLongitude = userLocation.center.longitude
let centerLocation = CLLocation(latitude: mapCenterLatitude, longitude: mapCenterLongitude)
Task {
await self.findStores(centerLocation)
}
return
}
timer?.invalidate()
timer = nil
let userCenterLatitude = userLocation.center.latitude
let userCenterLongitude = userLocation.center.longitude
let mapCenterLatitude = mapView.region.center.latitude
let mapCenterLongitude = mapView.region.center.longitude
let latitudeDifference = abs(userCenterLatitude - mapCenterLatitude)
let longitudeDifference = abs(userCenterLongitude - mapCenterLongitude)
let userSpanLatitudeDelta = userLocation.span.latitudeDelta
let userSpanLongitudeDelta = userLocation.span.longitudeDelta
let mapSpanLatitudeDelta = mapView.region.span.latitudeDelta
let mapSpanLongitudeDelta = mapView.region.span.longitudeDelta
let coordinateThreshold: Double = 0.12 // Umbral para el movimiento de coordenadas
let spanThreshold: Double = 0.12 // Umbral para el cambio de span
if (latitudeDifference > coordinateThreshold || longitudeDifference > coordinateThreshold) ||
(abs(userSpanLatitudeDelta - mapSpanLatitudeDelta) > spanThreshold || abs(userSpanLongitudeDelta - mapSpanLongitudeDelta) > spanThreshold) {
timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
guard let self = self else { return }
let centerLocation = CLLocation(latitude: mapCenterLatitude, longitude: mapCenterLongitude)
Task {
await self.findStores(centerLocation)
}
}
}
}
Is this the correct approach for implementing this feature, or is there a better way to accomplish it?
Finally, I noticed that when the map is initially loaded, it automatically zooms to the user's location. Is there a way to prevent this and instead show the entire map of Latin America by default?
I would appreciate any guidance or suggestions on how to improve these functionalities in my map view implementation. Thank you!
this is my complete code
@MainActor final class MapViewModel: NSObject, ObservableObject {
var customAnnotations = [MapAnnotation]()
@Published var popTitle = false
var map = MKMapView(frame: .zero)
@Published var userLocation: MKCoordinateRegion = .init()
private(set) var locationManager: CLLocationManager = .init()
let defaultRegionRadius: CLLocationDistance = 50000
private var didSetInitialUserZoom: Bool = false
private var currentUserLocation: CLLocation?
private var timer: Timer?
override init() {
super.init()
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.delegate = self
}
// MARK: - Location Updates
func requestLocationAuthorization() {
locationManager.requestWhenInUseAuthorization()
}
func startUpdatingLocation() {
locationManager.startUpdatingLocation()
}
func stopUpdatingLocation() {
locationManager.stopUpdatingLocation()
}
func centerMapOnUserLocation() {
if let location = locationManager.location {
let region = MKCoordinateRegion(center: location.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.21,
longitudeDelta: 0.21))
userLocation = region
map.setRegion(region, animated: true)
}
}
func findStores(_ location: CLLocation) async {
let request = StoreLocatorRequest(coordinates: Coordinates(latitude: location.coordinate.latitude, longitude: location.coordinate.longitude))
let shopRepository = DIContainer.resolveShopRepo()
map.removeAnnotations(map.annotations)
do {
let stores = try await shopRepository.findNearbyStores(request: request)
stopUpdatingLocation()
if stores.isEmpty {
customAnnotations = MapAnnotation.mockAnnotations
} else {
for store in stores {
let storeCoordinate = CLLocationCoordinate2D(latitude: store.coordinates?.latitude ?? 0.0,
longitude: store.coordinates?.longitude ?? 0.0)
let storeLocation = CLLocation(latitude: storeCoordinate.latitude, longitude: storeCoordinate.longitude)
let distance = location.distance(from: storeLocation)
if distance <= defaultRegionRadius {
let storeAnnotation = MapAnnotation(title: store.title,
subtitle: nil,
coordinate: storeCoordinate,
type: .store)
customAnnotations.append(storeAnnotation)
}
}
}
} catch {
Logger.log(message: error.localizedDescription, category: .technical, type: .debug)
}
map.addAnnotations(customAnnotations)
map.showAnnotations(customAnnotations, animated: true)
stopUpdatingLocation()
}
}
extension MapViewModel: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let userCoordinates = locations.last else { return }
let centerLocation = CLLocation(latitude: userCoordinates.coordinate.latitude, longitude: userCoordinates.coordinate.longitude)
Task {
await self.findStores(centerLocation)
}
let region = MKCoordinateRegion(center: centerLocation.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.21,
longitudeDelta: 0.21))
map.setRegion(region, animated: true)
map.showsUserLocation = true
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
stopUpdatingLocation()
}
// MARK: It would be great to add a couple of alerts in the event that the user removes the location permission on this screen, but it would be a nice to have.
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
switch status {
case .notDetermined:
Logger.log(message: "not determined location permission", category: .technical, type: .error)
map.setRegion(userLocation, animated: true)
stopUpdatingLocation()
case .restricted, .denied:
Logger.log(message: "restricted location permission", category: .technical, type: .error)
userLocation = .init(center: CLLocationCoordinate2D(latitude: 38.897957,
longitude: -77.036560),
span: MKCoordinateSpan(latitudeDelta: 0.21,
longitudeDelta: 0.21))
map.setRegion(userLocation, animated: true)
stopUpdatingLocation()
case .authorizedAlways, .authorizedWhenInUse:
startUpdatingLocation()
Logger.log(message: "Authorized permission", category: .technical, type: .info)
@unknown default:
Logger.log(message: "unknown location permission", category: .technical, type: .error)
}
}
}
extension MapViewModel: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let mapAnnotation = annotation as? MapAnnotation else { return nil }
let annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: mapAnnotation.type.rawValue)
let petcoLogoImageView = UIImageView(image: UIImage(named: "PetcoTextLogo"))
let rightArrowImageView = UIImageView(image: UIImage(named: "ChevronRight"))
rightArrowImageView.frame = CGRect(x: 0, y: 0, width: 10, height: 15)
petcoLogoImageView.frame = CGRect(x: 0, y: 0, width: 55, height: 25)
annotationView.canShowCallout = true
annotationView.image = UIImage(named: mapAnnotation.type.imageName)
annotationView.leftCalloutAccessoryView = petcoLogoImageView
annotationView.rightCalloutAccessoryView = rightArrowImageView
return annotationView
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
guard didSetInitialUserZoom == false else { return }
didSetInitialUserZoom = true
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
if !didSetInitialUserZoom {
didSetInitialUserZoom = true // Marcar que se ha establecido el zoom inicial
let mapCenterLatitude = userLocation.center.latitude
let mapCenterLongitude = userLocation.center.longitude
let centerLocation = CLLocation(latitude: mapCenterLatitude, longitude: mapCenterLongitude)
Task {
await self.findStores(centerLocation)
}
return
}
timer?.invalidate()
timer = nil
let userCenterLatitude = userLocation.center.latitude
let userCenterLongitude = userLocation.center.longitude
let mapCenterLatitude = mapView.region.center.latitude
let mapCenterLongitude = mapView.region.center.longitude
let latitudeDifference = abs(userCenterLatitude - mapCenterLatitude)
let longitudeDifference = abs(userCenterLongitude - mapCenterLongitude)
let userSpanLatitudeDelta = userLocation.span.latitudeDelta
let userSpanLongitudeDelta = userLocation.span.longitudeDelta
let mapSpanLatitudeDelta = mapView.region.span.latitudeDelta
let mapSpanLongitudeDelta = mapView.region.span.longitudeDelta
let coordinateThreshold: Double = 0.12 // Umbral para el movimiento de coordenadas
let spanThreshold: Double = 0.12 // Umbral para el cambio de span
if (latitudeDifference > coordinateThreshold || longitudeDifference > coordinateThreshold) ||
(abs(userSpanLatitudeDelta - mapSpanLatitudeDelta) > spanThreshold || abs(userSpanLongitudeDelta - mapSpanLongitudeDelta) > spanThreshold) {
timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
guard let self = self else { return }
let centerLocation = CLLocation(latitude: mapCenterLatitude, longitude: mapCenterLongitude)
Task {
await self.findStores(centerLocation)
}
}
}
}
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
// Change the annotation view's image when selected
if view.annotation is MapAnnotation {
view.image = UIImage(named: "StoreMapPinGreen")
}
}
func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
if let annotation = view.annotation as? MapAnnotation {
view.image = UIImage(named: annotation.type.imageName)
}
}
}
struct MapComponent: UIViewRepresentable {
@StateObject private var viewModel = MapViewModel()
func makeUIView(context: Context) -> MKMapView {
viewModel.map.accessibilityIdentifier = "MapView"
viewModel.map.showsUserLocation = true
return viewModel.map
}
func updateUIView(_ mapView: MKMapView, context: Context) {
mapView.removeAnnotations(mapView.annotations)
mapView.delegate = viewModel
}
}
A solution in the code