I am trying to display a map with custom annotations (pins) and custom callout views in SwiftUI.
My Goal:
I have implemented the custom pin and the callout appears correctly when a pin is tapped. The dismissal gesture also works as expected. However, the Button inside my custom callout view is not interactive at all. The tap gesture is seemingly consumed by the map, and the button's action is never triggered.
My current workaround is to present the detail sheet at the same time as the callout (i.e., when the pin itself is tapped), which is not the desired user experience. The user should see the callout first and then choose to see more details by tapping the button.
How can I make the Button inside my custom CalloutView (which is presented in an .overlay of a MapAnnotation) interactive so that it can trigger the presentation of a sheet? I would prefer a solution that uses only SwiftUI. However, if this is a known limitation that cannot be overcome, I am open to a solution that bridges from UIKit (MKMapView), but I would appreciate an explanation of why the pure SwiftUI approach is not feasible.
import SwiftUI
import MapKit
struct Location: Identifiable, Equatable {
let id = UUID()
let name: String
let coordinate: CLLocationCoordinate2D
static func == (lhs: Location, rhs: Location) -> Bool {
lhs.id == rhs.id
}
}
// Presented as a sheet
struct LocationDetailView: View {
let location: Location
var body: some View {
VStack(spacing: 16) {
Text(location.name)
.font(.largeTitle)
Text("Details about \(location.name) would be displayed here.")
.font(.body)
.foregroundColor(.secondary)
}
.padding()
.navigationTitle("Location Details")
.navigationBarTitleDisplayMode(.inline)
}
}
// Custom Callout View
struct CustomCalloutView: View {
let location: Location
var onDetailsTapped: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(location.name)
.font(.headline)
// Button action is never called.
Button(action: {
print("Info button tapped for \(location.name)")
onDetailsTapped()
}) {
HStack {
Text("Show Details")
Image(systemName: "info.circle")
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
}
.padding(12)
.background(Color(uiColor: .systemBackground))
.cornerRadius(10)
.shadow(radius: 5)
.frame(width: 200)
}
}
struct MapContentView: View {
@State private var region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437), // Los Angeles
span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2)
)
private let locations: [Location] = [
.init(name: "City Hall", coordinate: .init(latitude: 34.0522, longitude: -118.2437)),
.init(name: "Dodger Stadium", coordinate: .init(latitude: 34.0739, longitude: -118.2400)),
.init(name: "Santa Monica Pier", coordinate: .init(latitude: 34.0100, longitude: -118.4969))
]
@State private var selectedLocation: Location?
@State private var locationForSheet: Location?
var body: some View {
NavigationView {
Map(coordinateRegion: $region, annotationItems: locations) { location in
MapAnnotation(coordinate: location.coordinate) {
Image(systemName: "mappin.and.ellipse")
.font(.title)
.foregroundColor(.red)
.background(Circle().fill(Color.white))
.shadow(radius: 1)
.overlay(
Group {
if selectedLocation == location {
CustomCalloutView(location: location) {
self.locationForSheet = location
}
.offset(y: -55)
}
}
)
.onTapGesture {
print("Pin Tapped: \(location.name)")
withAnimation(.spring()) {
selectedLocation = location
}
// Current Workaround:
// Because the callout button doesn't work, the sheet
// is triggered at the same time as the callout.
// This is the behavior I want to avoid.
self.locationForSheet = location
}
}
}
.navigationTitle("Map Example")
.navigationBarTitleDisplayMode(.inline)
.ignoresSafeArea(edges: .bottom)
.sheet(item: $locationForSheet) { location in
NavigationView {
LocationDetailView(location: location)
}
}
.simultaneousGesture(
// Tap gesture on the map to dismiss the callout
TapGesture().onEnded { _ in
print("Map Tapped: Dismissing callout")
withAnimation(.spring()) {
selectedLocation = nil
}
}
)
}
}
}
#Preview {
MapContentView()
}
I would suggest using the built-in popover to display the callout. You can write a view like this
struct MapPin: View {
let location: Location
@Binding var sheetLocation: Location?
@State private var popover = false
var body: some View {
Image(systemName: "mappin.and.ellipse")
.font(.title)
.foregroundColor(.red)
.background(Circle().fill(Color.white))
.shadow(radius: 1)
.onTapGesture {
popover = true
}
.popover(isPresented: $popover) {
CustomCalloutView(location: location) {
sheetLocation = location
}
.presentationCompactAdaptation(.popover)
}
}
}
and use that as the content of an Annotation.
@State private var mapCamera = MapCameraPosition.region(MKCoordinateRegion(...))
// ...
Map(position: $mapCamera) {
ForEach(locations) { location in
Annotation(coordinate: location.coordinate) {
MapPin(location: location, sheetLocation: $locationForSheet)
} label: {
Text(location.name)
}
}
}
If you want the popover to have your own look-and-feel, or you don't want it to be dismissed as soon as the user moves the map, it'd be more complicated.
One way I can think of is to add the popover as an overlay of the Map. Keep track of the map camera changes, and update the location of the overlay.
MapReader { mapProxy in
// Here I use the map's own 'selection:' parameter to track selection
// The 'Location' struct should conform to 'Hashable' for this
Map(position: $mapCamera, selection: $selectedLocation) {
ForEach(locations) { location in
Annotation(coordinate: location.coordinate) {
Image(systemName: "mappin.and.ellipse")
.font(.title)
.foregroundColor(.red)
.background(Circle().fill(Color.white))
.shadow(radius: 1)
} label: {
Text(location.name)
}
.tag(location)
}
}
.overlay {
if let selectedLocation, let positionForPopover {
CustomCalloutView(location: selectedLocation) {
self.locationForSheet = selectedLocation
}
// 'positionForPopover' is just a @State of type 'CGPoint?'
.position(positionForPopover)
.offset(y: -55)
}
}
.onMapCameraChange(frequency: .continuous) {
updatePopoverLocation(mapProxy)
}
.onChange(of: selectedLocation) {
updatePopoverLocation(mapProxy)
}
}
func updatePopoverLocation(_ mapProxy: MapProxy) {
if let selectedLocation,
let convertedPoint = mapProxy.convert(selectedLocation.coordinate, to: .local) {
positionForPopover = convertedPoint
} else {
positionForPopover = nil
}
}