GeometryReader sometimes gives wrong .global frame origin for Map annotations - swiftui

Test setup:
I have a View that displays modally a Map with annotations:
struct Annotation: Identifiable {
let id = UUID()
let name: String
let coordinate: CLLocationCoordinate2D
}
struct MapViewSWUI: View {
#Binding var show_map_modal: Bool
#State var region: MKCoordinateRegion
var body: some View {
let _ = Self._printChanges()
VStack {
ZStack { // Top bar
Text("Oo")
.bold()
HStack {
Button(action: {
show_map_modal = false
}) {
HStack(spacing: 10) {
Image(systemName: "chevron.left")
Text(NSLocalizedString("BACK", comment: " "))
}
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 0))
}
Spacer()
}
}
let annotations = [Annotation(name: "0",
coordinate: CLLocationCoordinate2D(latitude: 49.450534, longitude: 8.6694930)),
Annotation(name: "1",
coordinate: CLLocationCoordinate2D(latitude: 49.450701, longitude: 8.6677427))
]
Map(coordinateRegion: $region,
interactionModes: .all,
showsUserLocation: false,
annotationItems: annotations) { annotation in
MapAnnotation(coordinate: annotation.coordinate, anchorPoint: CGPoint(x: 0.5, y: 1)) {
GeometryReader { geo in
Color(.green)
.onAppear {
print("Annotation coordinate: (\(annotation.coordinate.latitude),\(annotation.coordinate.longitude))")
print(".global origin: \(geo.frame(in: .global).origin)")
}
}
.frame(width: 30, height: 30)
}
}
} // VStack
}
}
When show_map_modal is set to true, the map is shown with the test annotations:
Problem:
When the map is shown the following is normally logged:
MapViewSWUI: #self, #identity, _region, _annotationDragged changed.
2023-01-07 09:19:17.254324+0100 ShopEasy![47736:11270478] Metal API Validation Enabled
MapViewSWUI: _region changed.
Annotation coordinate: (49.450534,8.669493)
.global origin: (271.94092664926427, 438.1697202246186)
Annotation coordinate: (49.450701,8.6677427)
.global origin: (91.00613205620596, 411.6026828329208)
However it may also happen that the following is logged:
MapViewSWUI: #self, #identity, _region, _annotationDragged changed.
MapViewSWUI: _region changed.
Annotation coordinate: (49.450701,8.6677427)
.global origin: (-15.0, 76.66666666666666)
Annotation coordinate: (49.450534,8.669493)
.global origin: (-15.0, 76.66666666666666)
When I dismiss the modally shown map using the back button ("Zurück"), and display the map again, the same happens (one of the logs above).
Workarounds tried:
When, within GeometryReader .onAppear, I embed the print statements in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // … }, the .global frame coordinates seem always to be correct. But this seems to be a hack.
When I replace .onAppear by .onTapGesture, the .global frame coordinates also seem always to be correct. Unfortunately, I need the coordinates when the annotations appear on the screen, without further user interaction.
Questions:
How is it possible that the annotations (green square) are correctly placed on the map, but the GeometryReader gives sometimes the correct .global screen coordinates, but sometimes another strange value (-15.0, 76.66666666666666) that is, if it is given, always the same? Is this a SwiftUI bug?
Is something wrong with my code? And if so, how to do it right?

Related

Why is SwiftUI Map modifying the state of its calling struct, if no frame is assigned?

Setup:
My app uses a SwiftUI Map, essentially as
struct MapViewSWUI: View {
#Binding private var show_map_modal: Bool
#State private var region: MKCoordinateRegion
//…
init(show_map_modal: Binding<Bool>) {
self._show_map_modal = show_map_modal
self.region = // Some computed region
//…
var body: some View {
//…
Map(coordinateRegion: $region)
.frame(width: 400, height: 300) // Some frame for testing
}
}
Using this code, I can show the map modally without problems.
Problem:
If I out comment the .frame view modifier, I get the runtime error
Modifying state during view update, this will cause undefined behavior.
with the following stack frame:
Question:
Why is it in my case required to set a frame for the Map? This tutorial does it, but Apple's docs don't.
How to do it right?
PS:
I have read this answer to a similar question and tried to catch the error with a runtime breakpoint, but it does not show anything interesting:
I found an answer to another questions related to the same error, but it doesn't apply here.
EDIT:
Workaround found, but not understood:
My map is presented modally from another view. This view has a state var that controls the presentation:
#State private var show_map_modal = false
The body of the view consists of a HStack with some views, and a fullScreenCover view modifier is applied to the HStack:
var body: some View {
HStack {
// …
}
.fullScreenCover(isPresented: $show_map_modal) {
MapViewSWUI(show_map_modal: $show_map_modal, itemToBeDisplayed: viewItem)
.ignoresSafeArea(edges: [.leading, .trailing])
}
}
If the map is presented in this way, no run time error is raised.
However, if I include (as it was done up to now) .top or .bottom in the edge set, the run time error Modifying state during view update is raised.
I would be glad for any hint to the reason.
My guess is that the error is not related to the frame at all, but to the update of the region once the sheet is presented.
As you can see in my code, I update the region 3 seconds after presenting the seet. Then, the error shows up.
Could the be happening in your code?
struct ContentView: View {
#State private var show_map_modal = false
var body: some View {
Button {
show_map_modal.toggle()
} label: {
Text("Show me the map!")
}
.sheet(isPresented: $show_map_modal) {
MapViewSWUI(show_map_modal: $show_map_modal)
}
}
}
struct MapViewSWUI: View {
#Binding private var show_map_modal: Bool
#State private var region: MKCoordinateRegion
init(show_map_modal: Binding<Bool>) {
self._show_map_modal = show_map_modal
self.region = MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: 51.507222,
longitude: -0.1275),
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
)
}
var body: some View {
VStack(alignment: .trailing) {
Button("Done") {
show_map_modal.toggle()
}
.padding(10)
Map(coordinateRegion: $region)
}
// .frame(width: 400, height: 300) // Some frame for testing
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.region = MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: 31.507222,
longitude: -1.1275),
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
)
}
}
}
}

SwiftUI - showing Annotation with dynamic colors on Map with MapKit

hopefully someone can help me with my problem. I have an app where I show a Map with the current location of the user. Then I can tap on a button and it shows all gas stations around the location based on a certain radius. The stations are showed by little map pins. Now I want that the cheapest station has another color than the rest (yellow instead of red). The function for this is already written, but the problem is, that sometimes there is no map pin yellow or it is the wrong one which is yellow. The first tap on the button after the app starts is always good, but the following can be sporadic right or wrong. Here is my code.
Part of my MapView:
#ObservedObject var locationManager = LocationManager.shared
#EnvironmentObject var dataViewModel:DataViewModel
#EnvironmentObject var carViewModel:CarViewModel
#State private var radius: String = ""
#State private var showInput: Bool = false
var body: some View {
ZStack {
Map(coordinateRegion: $locationManager.region, interactionModes: .all, showsUserLocation: true, annotationItems: dataViewModel.annotations, annotationContent: { station in
MapAnnotation(coordinate: station.coordinate) {
if dataViewModel.annotations.count > 0 {
MapAnnotationView(dataViewModel: dataViewModel, station: station)
.onTapGesture {
dataViewModel.currentAnnotation = station
dataViewModel.showStationSheet = true
}
}
}
} )
.accentColor(Color.blue)
.ignoresSafeArea()
This is the function where I get my data from:
guard let url = URL(string: "https://creativecommons.tankerkoenig.de/json/list.php?lat=\(latitude)&lng=\(longitude)&rad=\(radius)&sort=dist&type=all&apikey=\(apiKey)") else { return }
URLSession.shared.dataTask(with: url) { data, response, error in
if error == nil {
if let data = data {
do {
let decodedResponse = try JSONDecoder().decode(HeadData.self, from: data)
DispatchQueue.main.async {
self.gasData.append(decodedResponse)
self.bestAnnotations = [Annotation]()
for bestStation in self.gasData[0].stations {
var id = UUID()
self.annoIDs.append(id)
var tempAnnotation = Annotation(id: id, name: bestStation.name, brand: bestStation.brand, street: bestStation.street, houseNumber: bestStation.houseNumber, postCode: bestStation.postCode, place: bestStation.place, distance: bestStation.dist, diesel: bestStation.diesel ?? 9.999, e5: bestStation.e5 ?? 9.999, e10: bestStation.e10 ?? 9.999, isOpen: bestStation.isOpen, address: bestStation.street, coordinate: CLLocationCoordinate2D(latitude: bestStation.lat, longitude: bestStation.lng))
self.bestAnnotations.append(tempAnnotation)
}
self.calculateBestAnnotation(activeCar: activeCar)
var i = 0
for station in self.gasData[0].stations {
var tempAnnotation = Annotation(id: self.annoIDs[i], name: station.name, brand: station.brand, street: station.street, houseNumber: station.houseNumber, postCode: station.postCode, place: station.place, distance: station.dist, diesel: station.diesel ?? 9.999, e5: station.e5 ?? 9.999, e10: station.e10 ?? 9.999, isOpen: station.isOpen, address: station.street, coordinate: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng))
i += 1
self.copiedAnnotations.append(tempAnnotation)
}
self.annotations = self.copiedAnnotations
}
} catch let jsonError as NSError {
DispatchQueue.main.async {
self.searchToastError = "Es konnten keine Daten gefunden werden."
self.presentSearchToast = true
}
}
return
}
}
}
.resume()
At first I am saving the decoded json response into an array and I calculate the cheapest gas station so that there is one element left in the array bestAnnotations. After that I append the data to the Annotation Array which is the data source of the Annotations on the MapView.
And then my MapAnnotationView looks like this:
#ObservedObject var locationManager = LocationManager.shared
#ObservedObject var dataViewModel:DataViewModel
#State var station: Annotation
var body: some View {
ZStack {
Circle()
.frame(width: 35, height: 35)
.foregroundColor(station.id == dataViewModel.bestAnnotations[0].id ? .yellow : .red)
Image(systemName: "mappin")
.resizable()
.scaledToFit()
.foregroundColor(.white)
.frame(width: 25, height: 25)
}
}
Hopefully someone can help me with the problem. Maybe there is there something wring with the Dispatch function?

How to add tooltip to Map Annotation in order to show the location name on the Map using MapKit (SwiftUI)

I'm trying to figure out how I can display the title/name of a MapAnnotation when I hover over the Annotation/Marker or when I simply tap on the annotation/Marker. Is there a simple way to do this?
I tried using .help(), but it doesn't display anything on the map...
Here is the relevant code...
Map(coordinateRegion: $viewModel.region, showsUserLocation: true, annotationItems: viewModel.locations){ location in
MapAnnotation(coordinate: location.coordinate) {
Image(systemName: "mappin.circle")
.help("\(location.name)")
}
}
Actually, .help() will work as long as you use it with a button.
This is a quick paste from a current project I’ve got.
Note:
This works with the Mac version and I have not tested anywhere else
import SwiftUI
import MapKit
struct SwiftUIMapViewTest: View {
#EnvironmentObject var modelData: ModelData
#State var region: MKCoordinateRegion = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 29.548460, longitude: -98.481556),
span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1))
#Binding var selectedListing: Place?
#Binding var results: [Place]
#State var image: String = "mappin"
var body: some View {
Map(
coordinateRegion: $region,
annotationItems: results,
annotationContent: {
listing in
MapAnnotation(coordinate: listing.coordinate,
content: {Button(action: {
self.selectedListing = listing as Place?
},
label: {
VStack{
Image(systemName: "mappin.circle.fill")
.foregroundColor(.red)
.contentShape(Circle())
}
}).help("\(listing.company) \n\(listing.street) \n\(String(listing.zipCode))")
}
)})
}
}
struct SwiftUIMapViewTest_Previews: PreviewProvider {
static var modelData = [ModelData().places]
static var modelPlace = ModelData().places
static var previews: some View {
SwiftUIMapViewTest(
selectedListing: .constant(modelPlace[0]),
results: .constant(modelData[0])
)
}
}
Although not very pritty, this is the result.
Hope this helps someone.
You don't add a tooltip to a map annotation. You make your own custom view. You can display it however you want, and show and hide child views as desired. As an example:
import SwiftUI
import CoreLocation
import MapKit
struct MapAnnotationsView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 38.889499, longitude: -77.035230), span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01))
let placeArray: [Place] = [Place(title: "Washington Monument", coordinate: CLLocationCoordinate2D(latitude: 38.889499, longitude: -77.035230))]
var body: some View {
Map(coordinateRegion: $region, annotationItems: placeArray) { annotation in
// This makes a generic annotation that takes a View
MapAnnotation(coordinate: annotation.coordinate) {
// This is your custom view
AnnotationView(placeName: annotation.title)
}
}
}
}
struct AnnotationView: View {
let placeName: String
#State private var showPlaceName = false
var body: some View {
VStack(spacing: 0) {
Text(placeName)
.font(.callout)
.padding(5)
.background(Color.white)
.cornerRadius(10)
// Prevents truncation of the Text
.fixedSize(horizontal: true, vertical: false)
// Displays and hides the place name
.opacity(showPlaceName ? 1 : 0)
// You can use whatever you want here. This is a custom annotation marker
// made to look like a standard annotation marker.
Image(systemName: "mappin.circle.fill")
.font(.title)
.foregroundColor(.red)
Image(systemName: "arrowtriangle.down.fill")
.font(.caption)
.foregroundColor(.red)
.offset(x: 0, y: -5)
}
.onTapGesture {
withAnimation(.easeInOut) {
showPlaceName.toggle()
}
}
}
}
struct Place: Identifiable {
let id = UUID()
var title: String
var coordinate: CLLocationCoordinate2D
}

Transferring Coordinates to a MapView with Pin

I am trying to display a map pin on a map. Upon entry of a transaction the details are saved along with the location coordinates. In a list of transaction entries, the user may click on an entry for more detail information including a small map showing the transaction location.
Based on Asperi's suggestions at adding a MapMarker to MapKit in swiftUI 2 it appears that I need to declare an identifiable structure in order to use a map pin.
In the DetailView the latitude and longitude are copied to a coordinate parameter before transmission to MapView.
struct DetailView: View {
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: item.entryLat,
longitude: item.entryLong)
}
var body: some View {
VStack {
MapView(coordinate: coordinate)
.ignoresSafeArea(edges: .all)
.frame(height: 400)
.padding(.vertical, 10)
}
}
}
MapView is where I'm having trouble. I'm not sure how to pass in my coordinates for the region and the marker (xxxxx). Copying` coordinate to the #State region and the marker produces the error "Argument passed to call that takes no arguments".
struct Marker: Identifiable {
let id = UUID()
var location: MapMarker
}
struct MapView: View {
var coordinate: CLLocationCoordinate2D
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(xxxxxxx), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
let markers = [Marker(location: MapMarker(coordinate: CLLocationCoordinate2D(xxxxxxx), tint: .red))]
var body: some View {
Map(coordinateRegion: $region, showsUserLocation: true,
annotationItems: markers) { marker in
marker.location
}.edgesIgnoringSafeArea(.all)
}
}
Sounds like for your application, declaring the region as a constant will work. The code would look like this:
struct Marker: Identifiable {
let id = UUID()
var location: MapMarker
}
struct MapView: View {
var coordinate: CLLocationCoordinate2D
var body: some View {
Map(coordinateRegion: .constant(MKCoordinateRegion(center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))),
showsUserLocation: true,
annotationItems: [Marker(location: MapMarker(coordinate: coordinate))]) { marker in
marker.location
}.edgesIgnoringSafeArea(.all)
}
}
If you still wanted to use it as a #State variable, you could use a custom init to set the value:
struct MapView: View {
var coordinate: CLLocationCoordinate2D
#State private var region : MKCoordinateRegion
init(coordinate : CLLocationCoordinate2D) {
self.coordinate = coordinate
_region = State(initialValue: MKCoordinateRegion(center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)))
}
var body: some View {
Map(coordinateRegion: $region,
showsUserLocation: true,
annotationItems: [Marker(location: MapMarker(coordinate: coordinate))]) { marker in
marker.location
}
.edgesIgnoringSafeArea(.all)
}
}
Lastly, I'm defining the markers array inline, but you could split it out into a computed property:
var markers : [Marker] {
[Marker(location: MapMarker(coordinate: coordinate))]
}
var body: some View {
Map(coordinateRegion: $region,
showsUserLocation: true,
annotationItems: markers) { marker in
marker.location
}
.edgesIgnoringSafeArea(.all)
}

How to put a button/view on top of the SwiftUI Map?

I can't find a way to get my buttonView (just a Button) on top of the map so I can tap it.
In another more complicated setup, the button somehow turns-up on top and I can tap it, but this is by luck not by design. How to get my buttonView on the map so I can tap it?
Note, I think the issue maybe that my buttonView is "under" some map layer, hence the map captures the tap events and does not pass them to my buttonView.
Xcode 12 beta-3, mac catalina, target ios 14.
import Foundation
import SwiftUI
import MapKit
import CoreLocation
#main
struct TestMapApp: App {
var body: some Scene {
WindowGroup {
MapViewer()
}
}
}
struct MapViewer: View {
#State var cityAnno = [CityMapLocation(title: "Tokyo", subtitle: "Japan", lat: 35.685, lon: 139.7514)]
#State var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 35.685, longitude: 139.7514),span: MKCoordinateSpan(latitudeDelta: 1.0, longitudeDelta: 1.0))
var body: some View {
Map(coordinateRegion: $region, annotationItems: cityAnno) { city in
MapAnnotation(coordinate: city.coordinate) {
buttonView(cityName: city.title!)
// tried this, does not work
// Image(systemName:"dot.circle.and.cursorarrow").foregroundColor(.white).scaleEffect(2.2)
// .onTapGesture { print("----> onTapGesture") }
}
}
}
func buttonView(cityName: String) -> some View {
Button(action: {print("----> buttonView action")}) {
VStack {
Text(cityName)
Image(systemName: "dot.circle.and.cursorarrow")
}.foregroundColor(.red).scaleEffect(1.2)
}.frame(width: 111, height: 111)
// tried combinations of these, without success
// .background(Color.gray).opacity(0.8)
// .border(Color.white)
// .contentShape(Rectangle())
// .clipShape(Rectangle())
// .zIndex(1)
// .buttonStyle(PlainButtonStyle())
// .layoutPriority(1)
// .allowsHitTesting(true)
// .onTapGesture {
// print("----> onTapGesture")
// }
}
}
class CityMapLocation: NSObject, MKAnnotation, Identifiable {
var id = UUID().uuidString
var title: String?
var subtitle: String?
dynamic var coordinate: CLLocationCoordinate2D
init(title: String?, subtitle: String?, lat: Double, lon: Double) {
self.id = UUID().uuidString
self.title = title
self.subtitle = subtitle
self.coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
}
}
Just wrap them in ZStack:
ZStack {
Map(coordinateRegion: $region, annotationItems: cityAnno){...}
Button(action: {print("----> buttonView action")}) {...}
}
You could get the onTapGesture on Just Vstack. Try bellow code by replacing body of MapViewer.
var body: some View {
Map(coordinateRegion: $region, annotationItems: cityAnno) { city in
MapAnnotation(coordinate: city.coordinate) {
VStack {
Text(city.title ?? "")
Image(systemName: "dot.circle.and.cursorarrow")
}
.foregroundColor(.red).scaleEffect(1.2)
.frame(width: 111, height: 111)
.onTapGesture {
print("Clicked")
}
}
}
}