Search code examples
swiftuimapkitcore-locationmapkitannotationmkmapviewdelegate

MapKit/MKMapViewDelegate on didSelect: bottom sheet doesn't open if user location is enabled


I'm trying to rebuild Apple Maps's annotation selection behaviour on iOS. If I click on a map pin it should open a bottom sheet with further information about the place.

While already having coded all basic components my bottom sheet doesn't open if the user location is enabled on the map.

User Location disabled: bottom sheet opens correctly

User Location enabled: bottom sheet doesn’t open, but WHY?

Why does my bottom sheet not open if the user location is enabled? I would really appreciate some inputs. Thanks!

How to replicate it:

  1. Change the variable showCurrentLocation in MapMainView.swift depending on what you want to test.
  2. Please don't forget to add the Info.plist entries Privacy - Location When In Use Usage Description AND Privacy - Location Always Usage Description to access the native location of your device.

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        MapMainView()
    }
}

MapMainView.swift

import Foundation
import SwiftUI
import MapKit

struct MapMainView: View {
    let showCurrentLocation = false
    let locationFetcher = LocationFetcher()
    @State var selectedPin: MapPin? = nil
    @State var isBottomSheetOpen: Bool = false
    @State var examplePins = [MapPin]()

    var body: some View {
        GeometryReader { geometry in
            ZStack() {
                VStack() {
                    Spacer()
                    BottomSheetView(isOpen: self.$isBottomSheetOpen, maxHeight: geometry.size.height * 0.3) {
                        Text(String(self.selectedPin?.title ?? "no title")).foregroundColor(Color.black)
                    }
                }
                .edgesIgnoringSafeArea(.all)
                .zIndex(1)
            
                MapView(locationFetcher: self.locationFetcher, showCurrentLocation: self.showCurrentLocation, displayedPins: self.$examplePins, selectedPin: self.$selectedPin, isBottomSheetOpen: self.$isBottomSheetOpen)
                    .edgesIgnoringSafeArea(.all)
                    .onAppear{
                        var currentLat: Double
                        var currentLng: Double
                    
                        if self.showCurrentLocation {
                            currentLat = self.locationFetcher.getCurrentCoordinates()?.latitude ?? 46.9457590197085
                            currentLng = self.locationFetcher.getCurrentCoordinates()?.longitude ?? 8.007923669708498
                        } else {
                            currentLat = 46.9457590197085
                            currentLng = 8.007923669708498
                        }
                    
                        self.examplePins.append(MapPin(coordinate: CLLocationCoordinate2D(latitude: currentLat - 0.004, longitude: currentLng - 0.002), title: "First Pin"))
                        self.examplePins.append(MapPin(coordinate: CLLocationCoordinate2D(latitude: currentLat + 0.002, longitude: currentLng + 0.002), title: "Second Pin"))
                        self.examplePins.append(MapPin(coordinate: CLLocationCoordinate2D(latitude: currentLat - 0.002, longitude: currentLng + 0.004), title: "Third Pin"))
                }
            }
        }
    }
}

class MapPin: NSObject, MKAnnotation {
    let coordinate: CLLocationCoordinate2D
    let title: String?

    init(coordinate: CLLocationCoordinate2D, title: String? = nil) {
        self.coordinate = coordinate
        self.title = title
    }
}

MapView.swift

import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
    let locationFetcher: LocationFetcher
    let showCurrentLocation: Bool
    @Binding var displayedPins: [MapPin]
    @Binding var selectedPin: MapPin?
    @Binding var isBottomSheetOpen: Bool

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView()
        mapView.delegate = context.coordinator
    
        if showCurrentLocation {
            mapView.showsUserLocation = true
            self.locationFetcher.attemptLocationAccess()
            centerLocation(mapView: mapView, locationCoordinate: locationFetcher.getCurrentCoordinates())
        } else {
            centerLocation(mapView: mapView, locationCoordinate: CLLocationCoordinate2D(latitude: 46.9457590197085, longitude: 8.007923669708498))
        }
    
        return mapView
    }

    func updateUIView(_ mapView: MKMapView, context: Context) {
        if self.displayedPins.count != mapView.annotations.count {
            mapView.removeAnnotations(mapView.annotations)
            mapView.addAnnotations(self.displayedPins)
        }
    }

    func centerLocation(mapView: MKMapView, locationCoordinate: CLLocationCoordinate2D?) {
        if locationCoordinate != nil {
            let kilometerRadius = 1.5;
            let scalingFactor = abs((cos(2 * Double.pi * locationCoordinate!.latitude / 360.0)));
            let span = MKCoordinateSpan(latitudeDelta: kilometerRadius/111, longitudeDelta: kilometerRadius/(scalingFactor * 111))
            let region = MKCoordinateRegion(center: locationCoordinate!, span: span)
            mapView.setRegion(region, animated: true)
        }
    }
}

class Coordinator: NSObject, MKMapViewDelegate {
    var parent: MapView

    init(_ parent: MapView) {
        self.parent = parent
    }

    func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
        guard let pin = view.annotation as? MapPin else {
            return
        }
        mapView.setCenter(pin.coordinate, animated: true)
    
        DispatchQueue.main.async {
           self.parent.selectedPin = pin
           self.parent.isBottomSheetOpen = true
        }
    }


    func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
        guard (view.annotation as? MapPin) != nil else {
            return
        }
    
        DispatchQueue.main.async {
           self.parent.selectedPin = nil
           self.parent.isBottomSheetOpen = false
        }
    }
}

BottomSheetView.swift

import Foundation
import SwiftUI

struct BottomSheetView<Content: View>: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    @Binding var isOpen: Bool

    let maxHeight: CGFloat
    let minHeight: CGFloat
    let content: Content

    @GestureState private var translation: CGFloat = 0

    private var offset: CGFloat {
        isOpen ? 0 : maxHeight - minHeight
    }

    private var indicator: some View {
        RoundedRectangle(cornerRadius: Constants.RADIUS)
            .fill(Color.black)
            .frame(
                width: Constants.INDICATOR_WIDTH,
                height: Constants.INDICATOR_HEIGHT
        ).onTapGesture {
            self.isOpen.toggle()
        }
    }

    init(isOpen: Binding<Bool>, maxHeight: CGFloat, @ViewBuilder content: () -> Content) {
        self.minHeight = maxHeight * Constants.MIN_HEIGHT_RATIO
        self.maxHeight = maxHeight
        self.content = content()
        self._isOpen = isOpen
    }

    var body: some View {
        GeometryReader { geometry in
            VStack(spacing: 0) {
                self.indicator.padding()
                self.content
            }
            .frame(width: geometry.size.width, height: self.maxHeight, alignment: .top)
            .background(Color.white)
            .cornerRadius(Constants.RADIUS)
            .frame(height: geometry.size.height, alignment: .bottom)
            .offset(y: max(self.offset + self.translation, 0))
            .animation(.interactiveSpring())
            .gesture(
                DragGesture().updating(self.$translation) { value, state, _ in
                    state = value.translation.height
                }.onEnded { value in
                    let snapDistance = self.maxHeight * Constants.SNAP_RATIO
                    guard abs(value.translation.height) > snapDistance else {
                        return
                    }
                    self.isOpen = value.translation.height < 0
                }
            )
        }   
    } 
}

enum Constants {
    static let RADIUS: CGFloat = 16
    static let INDICATOR_HEIGHT: CGFloat = 6
    static let INDICATOR_WIDTH: CGFloat = 60
    static let SNAP_RATIO: CGFloat = 0.25
    static let MIN_HEIGHT_RATIO: CGFloat = 0
}

LocationFetcher.swift

import CoreLocation
import SwiftUI

class LocationFetcher: NSObject, CLLocationManagerDelegate {
    let locationManager = CLLocationManager()

    override init() {
        super.init()
        locationManager.delegate = self
    }

    func attemptLocationAccess() {
        guard CLLocationManager.locationServicesEnabled() else {
          return
        }
        locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
        if CLLocationManager.authorizationStatus() == .notDetermined {
          locationManager.requestWhenInUseAuthorization()
        } else {
          locationManager.requestLocation()
        }
        locationManager.startUpdatingLocation()
    }

    func getCurrentCoordinates() -> CLLocationCoordinate2D? {
        return locationManager.location?.coordinate
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {   
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        fatalError("error: \(error.localizedDescription)")
    }
}

Or you can download all files here https://gofile.io/d/LSfli5


Solution

  • I have found the problem by myself. Not only my MapPins, but also the blue location circle counts as mapView annotation. Therefore, if the location service is enabled my updateUIView() removes and adds all my annotations on every didSelect because self.displayedPins.count != mapView.annotations.count is always false which causes the sudden disappearance of the bottom sheet.

    After filtering the annotations it finally worked:

    func updateUIView(_ mapView: MKMapView, context: Context) {
        let displayedMapPins = mapView.annotations.filter { annotation in
            return annotation is MapPin
        }
        
        if self.mapPinsToDisplay.count != displayedMapPins.count {
            mapView.removeAnnotations(displayedMapPins)
            mapView.addAnnotations(self.mapPinsToDisplay)
        }
    }