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:
showCurrentLocation
in MapMainView.swift
depending on what you want to test.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
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)
}
}