swiftuimapkitios17

How to fix map annotation selection issues in iOS 17?


I'm working on MapKit for SwiftUI using iOS 17, where I have a custom annotation. When an annotation is selected, a DetailsView should be shown, but it does not work as expected.

Following the WWDC23 Video: Meet MapKit for SwiftUI at 17:07, I have created a custom annotation that when selected, expands its size as expected. It is clearly stated that the annotation must have a tag(_:) attached to it for the selection to work.

But the Map(selection:) parameter does not seem to update correctly in order to trigger the DetailsView using the sheet(item:_:) modifier.

How can I ensure that the DetailsView is triggered as expected, and also have the annotation unselected when the view is dismissed?

This is the minimalistic code that I have extracted from my project:

import MapKit
import SwiftUI

struct ContentView: View {

  private var annotations = generateRandomLocations()
  @State private var selection: AnnotationModel?

  var body: some View {
    Map(selection: $selection) {
      ForEach(annotations) { annotation in
        AnnotationMarker(annotation: annotation)
      }
    }
    .sheet(item: $selection) { station in
      DetailsView(id: station.id)
        .presentationDetents([.medium])
    }
  }
}

struct DetailsView: View {
  var id: UUID
  var body: some View {
    Text("DetailsView: \(id)")
  }
}

#Preview {
  ContentView()
}

struct AnnotationMarker: MapContent {

  var annotation: AnnotationModel
  @State private var isSelected = false

  var body: some MapContent {
    Annotation(coordinate: annotation.coordinate) {
      CustomMarker(isSelected: $isSelected)
    } label: {
      Text(annotation.id.uuidString)
    }
    .tag(annotation)
    .annotationTitles(isSelected ? .visible : .hidden)
  }
}

struct CustomMarker: View {

  @Binding var isSelected: Bool

  var body: some View {
    ZStack {
      Circle()
        .frame(width: isSelected ? 52 : 28, height: isSelected ? 52 : 28)
        .foregroundStyle(.green)

      Image(systemName: "house")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(width: isSelected ? 32 : 16)
        .foregroundStyle(.white)
    }
    .onTapGesture { withAnimation { isSelected.toggle() }}
  }
}

struct AnnotationModel: Identifiable, Hashable {

  let id = UUID()
  var coordinate: CLLocationCoordinate2D {
    CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
  }
  var latitude: Double
  var longitude: Double
}

/// Create random location for testing purposes.
func generateRandomLocations(count: Int = 50) -> [AnnotationModel] {
  return (1...count).map { _ in
    let latitude = Double.random(in: (43.5673...43.6573))
    let longitude = Double.random(in: (3.8176...3.9076))
    return AnnotationModel(latitude: latitude, longitude: longitude)
  }
}

Solution

  • I have found the trick to fix this issue. First of all, it seems to be a bug with the Map item selection. If the Annotation has a label, a tap action on the label would trigger the Annotation selection as expected.

    However, since it is required to not have the title, the trick is to pass the selection: AnnotationModel as a Binding to the CustomMarker, and manage the selected and unselected behaviors from there.

    Below is the updated code with comments next to the added parts:

    The ContentView where the Map is

    struct ContentView: View {
    
      private var annotations = generateRandomLocations()
      @State private var selection: AnnotationModel?
    
      var body: some View {
        Map(selection: $selection) {
          ForEach(annotations) { annotation in
            AnnotationMarker(
              annotation: annotation, 
              selection: $selection // Pass the `selection` in the `AnnotationMarker`.
            ) 
          }
        }
        .sheet(item: $selection) { item in
          DetailsView(id: item.id)
            .presentationDetents([.medium])
        }
      }
    }
    

    The updated Annotation with the custom View

    struct AnnotationMarker: MapContent {
    
      var annotation: AnnotationModel
      @Binding var selection: AnnotationModel?
    
      var body: some MapContent {
        Annotation(coordinate: annotation.coordinate) {
          CustomMarker(
            annotation: annotation, // Pass the `annotation` from the `ForEach` to the `CustomMarker `.
            selection: $selection) // Pass the `selection` to the `CustomMarker `.
        } label: {
          Text(String())
        }
        .tag(annotation) // The tag can now be set here.
        .annotationTitles(.hidden)
      }
    }
    

    And this is where the selection actions will be handled, within the CustomMarker.

    struct CustomMarker: View {
    
      @State private var isSelected = false
    
      var annotation: AnnotationModel // Pass the `annotation` of this `CustomMarker`.
      @Binding var selection: AnnotationModel? // Defining which `annotation` is selected, if any.
    
      var body: some View {
        ZStack {
          Circle()
            .frame(width: isSelected ? 52 : 28, height: isSelected ? 52 : 28)
            .foregroundStyle(.green)
    
          Image(systemName: "house")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: isSelected ? 32 : 16)
            .foregroundStyle(.white)
        }
        .onTapGesture { // If it is the selected `annotation` from the `ForEach`, define the selection.
          selection = annotation
          withAnimation(.bouncy) { isSelected = true }
        }
        .onChange(of: selection) { // If the previous selected `annotation` from the `ForEach` is unselected, perform the changes.
          guard isSelected, $1 == nil else { return } // Avoid having actions on unselected `annotations`.
          withAnimation(.bouncy) { isSelected = false }
        }
      }
    }