Initializing Map from an Observable view model in SwiftUI - swiftui

I'm attempting to implment a Map on a SwiftUI view from a view model. Every example I find online hard codes a coordinate. In my case, I'm initializing a view model with a Codable struct and I have no idea what the coordinate is going to be.
I do not encounter compiler issues when I build the project, but canvas crashes. I've tried closing Xcode, cleaning derived data, etc., but that doesn't seem to resolve it.
Any suggestions re: where my mistake is are greatly appreciated.
class EarthquakeViewModel: ObservableObject {
#Published private(set) var quakeData: Feature
#State var region: MKCoordinateRegion
init(quakeData: Feature) {
self.quakeData = quakeData
let center = CLLocationCoordinate2D(latitude: quakeData.geometry.coordinates[0],
longitude: quakeData.geometry.coordinates[1])
let span = MKCoordinateSpan(latitudeDelta: 1.0, longitudeDelta: 1.0)
region = MKCoordinateRegion(center: center,
span: span)
}
public lazy var title: String = {
quakeData.properties.title
}()
}
This is my ContentView:
struct EarthquakeView: View {
#ObservedObject var viewModel: EarthquakeViewModel
var body: some View {
VStack {
Text(viewModel.title)
// makeMapView()
Map(coordinateRegion: $viewModel.region)
}
}
}
// I tried this, too, but it doesn't work.
extension EarthquakeView {
#ViewBuilder func makeMapView() -> some View {
Map(coordinateRegion: $viewModel.region)
}
}
Update
This is the message from Diagnostics. Cleaning derived data with Xcode closed doesn't seem to resolve it, so I think my issue lies with one of my declarations:
RemoteHumanReadableError: Failed to update preview.
The preview process appears to have crashed.
Error encountered when sending 'render' message to agent.
==================================
| RemoteHumanReadableError: The operation couldn’t be completed. (BSServiceConnectionErrorDomain error 3.)
|
| BSServiceConnectionErrorDomain (3):
| ==BSErrorCodeDescription: OperationFailed
Update 2
I tweaked my data model and added a computed region var off of it, so here's how I'm getting the region now:
extension Feature /* Feature is a Codable struct */ {
var region: MKCoordinateRegion {
let center = CLLocationCoordinate2D(latitude: geometry.coordinates[0],
longitude: geometry.coordinates[1])
let span = MKCoordinateSpan(latitudeDelta: 1.0, longitudeDelta: 1.0)
let region = MKCoordinateRegion(center: center,
span: span)
return region
}
}
At jnpdx's suggestion, I updated the region on my view model to #Published.
class EarthquakeViewModel: ObservableObject {
`#Published private(set) var quakeData: Feature
`#Published var region: MKCoordinateRegion
init(quakeData: Feature) {
self.quakeData = quakeData
region = quakeData.region
}
public lazy var title: String = {
quakeData.properties.title
}()
}
And lastly, my View, as follows:
struct EarthquakeView: View {
#ObservedObject var viewModel: EarthquakeViewModel
#State var region: MKCoordinateRegion
init(viewModel: EarthquakeViewModel) {
self.viewModel = viewModel
_region = State(initialValue: viewModel.region)
}
var body: some View {
VStack {
Text(viewModel.title)
Map(coordinateRegion: $region)
}
}
}
The new error is this. Closing Xcode, rebooting, cleaning derived data, etc. doesn't seem to resolve it, so I am quickly concluding I'm missing something basic:
PreviewUpdateTimedOutError: Updating took more than 5 seconds Updating
a preview from EarthquakeView_Previews in CombineQuake.app (16766)
took more than 5 seconds.
Update 3
Preview initialization:
struct EarthquakeView_Previews: PreviewProvider {
static var previews: some View {
let quakeData = EarthQuakeData(mag: 6.5,
place: "32km W of Sola, Vanuatu",
time: 1388592209000,
updated: 1594407529032,
tz: 660,
url: "https://earthquake.usgs.gov/earthquakes/eventpage/usc000lvb5",
detail: "https://earthquake.usgs.gov/fdsnws/event/1/query?eventid=usc000lvb5&format=geojson",
felt: nil,
cdi: nil,
mmi: nil,
alert: nil,
status: "reviewed",
tsunami: 1,
sig: 650,
net: "us",
code: "c0001vb5",
ids: ",pt14001000,at00myqcls,usc000lvb5,",
sources: "pt,at,us",
types: "cap,geoserve,impact-link,losspager,moment-tensor,nearby-cities,origin,phase-data,shakemap,tectonic-summary",
nst: nil,
dmin: 3.997,
rms: 0.76,
gap: 14.0,
magType: "mww",
type: "earthquake",
title: "M 6.5 - 32km W of Sola, Vanuatu")
let geometry = Geometry(type: "Point",
coordinates: [167.249, -13.8633, 187.0])
let earthquake = Feature(type: "Feature",
properties: quakeData,
geometry: geometry,
id: "usc000lvb5")
let viewModel = EarthquakeViewModel(quakeData: earthquake)
EarthquakeView(viewModel: viewModel)
}
}

If you run this on the simulator rather than the preview, you get a more helpful error:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Invalid Region <center:+167.24900000, -13.86330000 span:+1.00000000, +1.00000000>'
I changed:
let geometry = Geometry(type: "Point",
coordinates: [167.249, -13.8633, 187.0])
to
let geometry = Geometry(type: "Point",
coordinates: [45, 34, 187.0])
and it worked fine.
Your latitude of 167.249 is beyond the bounds of -90 to 90, which is the valid range.

Related

SwiftUI Map() view error: Publishing changes from within view updates is not allowed, this will cause undefined behavior

I am trying to build a small Map app where location for user changes all the time. In general I get latitude and longitude updates all the time. And I need to display them and show the change with sliding animation, simular to Apple FindMyFriend, when it slides over map when they are moving in live.
This is my view:
struct ContentView: View {
#StateObject var request = Calls()
#State private var mapRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 56.946285, longitude: 24.105078), span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02))
var body: some View {
Map(coordinateRegion: $mapRegion, annotationItems: $request.users){ $user in
withAnimation(.linear(duration: 2.0)) {
MapAnnotation(coordinate: CLLocationCoordinate2D(latitude: user.latitude, longitude: user.longitude)){
Circle()
}
}
}
}
}
And function call in view model, whitch changes user location, the response is just incoming string from API:
func collectUsers(_ response: String){
if users.count != 0{
var data = response.components(separatedBy: "\n")
data.removeLast()
let updates = self.users.map{ user -> User in
let newData = updateUserLocation(user: user, input: data)
return User(id: user.id, name: user.name, image: user.image, latitude: Double(newData[1])!, longitude: Double(newData[2])!)
}
DispatchQueue.main.async {
self.users = updates
}
}else{
var userData = response.components(separatedBy: ";")
userData.removeLast()
let users = userData.compactMap { userString -> User? in
let userProperties = userString.components(separatedBy: ",")
var idPart = userProperties[0].components(separatedBy: " ")
if idPart.count == 2{
idPart.removeFirst()
}
guard userProperties.count == 5 else { return nil }
guard let id = Int(idPart[0]),
let latitude = Double(userProperties[3]),
let longitude = Double(userProperties[4]) else { return nil }
return User(id: id, name: userProperties[1], image: userProperties[2], latitude: latitude, longitude: longitude)
}
DispatchQueue.main.async {
self.users = users
}
}
}
And ofcourse my #Published:
class Calls: ObservableObject{
#Published var users = [User]()
When I use the MapMarker instead of MapAnnotation the error does not appier. I would use marker, but I need each user view in map to be different.
If any one stumbles with the same issue. I spent entire day to solve this, but the awnser is that in Xcode 14 it is a bug. After I installer Xcode 13.4.1 error messages disappiered.

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

Accessing value from ObservableObject

Note: I'm a beginner to Swift and MapKit so bear with me please. I appreciate it.
I have a SwiftUI view that takes an ObservableObject as input
#ObservedObject var viewModel: PostRowViewModel
And within that ObservableObject there is a #Published field:
#Published var post: Post
Now what I want to do is to display a map of using the lat and long values that are fields in this post object. Now the Map view in MapKit is as follows:
Map(coordinateRegion: Binding<MKCoordinateRegion>)
So I need to provide it with a binding of the region of the post I'm trying to display. What I tried to do is to initialize the region as follows:
#State var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: viewModel.post.location.coordinate.latitude, longitude: viewModel.post.location.coordinate.latitude), span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02))
Using the info from the viewModel. However I get this error:
Cannot use instance member 'viewModel' within property initializer; property initializers run before 'self' is available.
So I searched online and found that to solve the issue you need to use an init function however since the viewModel is given as input to the view and I have Environment variable I dont want to include an init function. I've also tried to create a function inside the viewModel that returns a Binding as follows:
func createMapRegion() -> Binding<MKCoordinateRegion> {
#State var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: post.location.coordinate.latitude, longitude: post.location.coordinate.longitude), span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02))
return $region
}
But I get the warning:
Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update.
Because the state is being accessing outside since when I then create the map I do it like so:
Map(coordinateRegion: viewModel.createMapRegion())
So I'm not sure how I can access this info from the post published object using the viewModel and create a Map when the viewModel is given as input to the view.
Any help would be very much appreciated!
Code:
ViewModel:
import Foundation
import SwiftUI
import MapKit
#MainActor
#dynamicMemberLookup
class PostRowViewModel: ObservableObject, StateManager {
..
#Published var post: Post
..
init(post: Post...) {
self.post = post
...
}
subscript<T>(dynamicMember keyPath: KeyPath<Post, T>) -> T {
post[keyPath: keyPath]
}
}
View:
import SwiftUI
import MapKit
struct PostRowView: View {
.
.
.
#ObservedObject var viewModel: PostRowViewModel
.
.
var body: some View {
// I want to create a map here that uses the post stored in the viewModel.
Map(coordinateRegion: ...)
}
}
}
Where I want to access the values for lat and long using the Post struct which is:
struct Post: Identifiable, Codable, Equatable {
.
.
.
var location: LocationInfo
.
.
.
}
Where LocationInfo is:
struct LocationInfo: Codable, Equatable, Identifiable {
var name: String
var countryCode: String
var coordinate: Coordinate
var id = UUID()
}
And coordinate is:
struct Coordinate: Codable, Hashable {
let latitude, longitude: Double
}
As suggested in the comments, the path of least resistance is probably to put region in a #Published variable on your ObservableObject:
class PostRowViewModel: ObservableObject {
#Published var post: Post
#Published var region: MKCoordinateRegion
init(post: Post) {
self.post = post
self.region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: post.location.coordinate.latitude, longitude: post.location.coordinate.longitude), span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02))
}
}
struct PostRowView: View {
#ObservedObject var viewModel: PostRowViewModel
var body: some View {
Map(coordinateRegion: $viewModel.region)
}
}
Note that you don't actually need a view model for this. You could also do something like:
struct PostRowView: View {
var post: Post
#State private var region : MKCoordinateRegion = .init()
var body: some View {
Map(coordinateRegion: $region)
.onAppear {
region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: post.location.coordinate.latitude, longitude: post.location.coordinate.longitude), span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02))
}
}
}

SwiftUI: Trigger view update after user pans the map

I'm new to Swift/SwiftUI, and so please forgive me if this is trivial.
In an app I am attempting to retrieve the user's location, and fetch nearby websites from Wikipedia to display. A simple example is below:
//
// ContentView.swift
// MRE
//
// Created by Philipp Maier on 2/25/22.
//
import SwiftUI
import CoreLocationUI
import MapKit
import Foundation
struct ContentView: View {
enum ViewState {
case waiting, fetching
}
#State private var viewState = ViewState.waiting
#StateObject private var viewModel = LocationViewModel()
// some values to initialize
#State var listEntries = [
Geosearch(pageid: 45348219,
title: "Addison Apartments",
lat: 35.21388888888889,
lon: -80.84472222222222,
dist: 363.7 ),
Geosearch(pageid: 35914731,
title: "Midtown Park (Charlotte, North Carolina)",
lat: 35.2108,
lon: -80.8363,
dist: 1034.5 )
]
var body: some View {
NavigationView{
VStack{
ZStack(alignment: .leading){
Map(coordinateRegion: $viewModel.mapRegion, showsUserLocation: true)
.frame(height: 300)
LocationButton(.currentLocation, action: {
viewModel.requestAllowLocationPermission()
viewState = .fetching
print("Button Press: Latitude: \(viewModel.mapRegion.center.latitude), Longitude: \(viewModel.mapRegion.center.longitude)")
})
}
HStack{
Text("Latitude: \(viewModel.mapRegion.center.latitude), Longitude: \(viewModel.mapRegion.center.longitude)")
}
switch viewState {
case .waiting:
Text("Waiting for your location")
Spacer()
case .fetching:
List{
ForEach(listEntries) {location in
NavigationLink(destination: Text("Target") ) {
HStack(alignment: .top){
Text(location.title)
}
}
.task{
await fetchNearbyLandmarks(lat: viewModel.mapRegion.center.latitude, lon: viewModel.mapRegion.center.longitude)
}
}
}
}
}
}
}
func fetchNearbyLandmarks(lat: Double, lon: Double) async {
let urlString = "https://en.wikipedia.org/w/api.php?action=query&list=geosearch&gscoord=\(lat)%7C\(lon)&gsradius=5000&gslimit=25&format=json"
print(urlString)
guard let url = URL(string: urlString) else {
print("Bad URL: \(urlString)")
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
let items = try JSONDecoder().decode(wikipediaResult.self, from: data)
listEntries = items.query.geosearch
print ("Loadingstate: Loaded")
} catch {
print ("Loadingstate: Failed")
}
}
}
final class LocationViewModel: NSObject, ObservableObject, CLLocationManagerDelegate {
#Published var mapRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 40, longitude: -80.5), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
let locationManager = CLLocationManager()
override init() {
super.init()
locationManager.delegate = self
}
func requestAllowLocationPermission() {
locationManager.requestLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let latestLocation = locations.first else {
print("Location Error #1")
return
}
DispatchQueue.main.async {
self.mapRegion = MKCoordinateRegion(center: latestLocation.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1))
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Location Error #2:")
print(error.localizedDescription)
}
}
struct wikipediaResult: Codable {
let batchcomplete: String
let query: Query
}
struct Query: Codable {
let geosearch: [ Geosearch ]
}
struct Geosearch: Codable, Identifiable {
var id: Int { pageid }
let pageid: Int
let title: String
let lat: Double
let lon: Double
let dist: Double
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The core function of the app works, in that once the user pushes the "Location" button, the list of Wikipedia pages underneath updates. I've also added to Text fields to track the center of the map as the user pans around.
Here's my issue: If I pan the map, the list of locations underneath does not update. However, once I click on a link to display the page, and hit the "back" button, the updated list is built "correctly", i.e. with the new coordinates.
I'm sure that I'm overlooking something simple, but how can I track panning of the user and dynamically adjust the list underneath "in real time"?
Thanks, Philipp
For being new to Swift/SwiftUI this is quite cool!
You want to refetch when the coordinates of your Map change. So you could use .onChange, e.g.on the ZStack of the Map:
.onChange(of: viewModel.mapRegion.center.latitude) { _ in
Task {
await fetchNearbyLandmarks(lat: viewModel.mapRegion.center.latitude, lon: viewModel.mapRegion.center.longitude)
}
}
Generally this works, but it might be fetching too often (on every change of latitude). So you might want to add some kind of delay, or try to figure out when the drag ended.

Swiftui + Core Location: Map fails to center on user

I want to build a view with a map centered on the user location when loaded. I managed to build this, but sometimes the map loads with latitude 0, longitude: 0. This happens when I move too fast between views (there are other views in the project besides the map).
It feels like the user location is loaded too slow and the Map appears with default coordinates, but I really have no idea what I'm doing wrong. Any ideas?
Map view:
import SwiftUI
import MapKit
struct MapView: View {
#StateObject var locationManager = LocationManager()
#State var trackingMode: MapUserTrackingMode = .follow
var body: some View {
Map(coordinateRegion: $locationManager.region, interactionModes: .all, showsUserLocation: true, userTrackingMode: $trackingMode)
}
}
Location View Model:
import SwiftUI
import CoreLocation
import MapKit
class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
#Published var region = MKCoordinateRegion()
private let manager = CLLocationManager()
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.requestWhenInUseAuthorization()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
locations.last.map {
let center = CLLocationCoordinate2D(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude)
let span = MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2)
region = MKCoordinateRegion(center: center, span: span)
}
}
}
That is exactly your problem. Location data will ALWAYS lag, just like any other retrieved data. What you need to consider is a mechanism to update your views when you get updates.
The best way to do that is to import Combine in your LocationManager class and use a PassthroughSubject like this:
let objectWillChange = PassthroughSubject<Void, Never>()
#Published var region = MKCoordinateRegion() {
willSet { objectWillChange.send() }
}
That allows you to subscribe to your publisher in the map and get updates. You will find many tutorials regarding this.