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.
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?
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)
}
}
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 }
}
}
}