1

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.

  • Selecting an annotation does not trigger the DetailsView even though the annotation shows as selected on the map.

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?

  • Important: The animation must be kept when selected/unselected action is triggered.

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)
  }
}
1
  • you probably need a computed binding for the sheet's item so it doesn't interfere with the map selection
    – malhal
    Commented Jun 27 at 18:56

1 Answer 1

0

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 }
    }
  }
}

Not the answer you're looking for? Browse other questions tagged or ask your own question.