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))
}
}
}
Related
I have a fairy simple SwiftUI code block showing a map with a pin. This works so far.
I'd like to add a new pin by a list of draggable objects. Now, I am bit stuck at the following problems:
Where to add a list of draggable items to create the pin?
How to select the item and translate it to a marker object?
How to determine the final location in MapKit where the pin is dragged?
I think it is solvable and I can do it in LeafletJS, but SwiftUI makes my head ache.
import MapKit
import CoreLocation
struct Marker: Identifiable {
let id = UUID()
var location: MapMarker
}
struct ContentView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 47.8681, longitude: 8.205), span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.50))
let markers = [Marker(location: MapMarker(coordinate: CLLocationCoordinate2D(latitude: 47.8681, longitude: 8.205), tint: .blue))]
var body: some View {
Map(coordinateRegion: $region, showsUserLocation: true, userTrackingMode: .constant(.follow),annotationItems: markers){ marker in
marker.location
}
.frame(width: 1200, height: 600)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I am working on a SwiftUI project and want to place a map in a view that uses coordinates stored in Firestore. Apple's example for MapKit in SwiftUI uses static latitude and longitude parameters in the #State property and then binds the property to the Map() view.
struct BusinessMapView: View {
#State private var region: MKCoordinateRegion = {
var mapCoordinates = CLLocationCoordinate2D(latitude: 44.621754, longitude: -66.475873)
var mapZoomLevel = MKCoordinateSpan(latitudeDelta: 5.00, longitudeDelta: 5.00)
var mapRegion = MKCoordinateRegion(center: mapCoordinates, span: mapZoomLevel)
return mapRegion
}()
var body: some View {
Map(coordinateRegion: $region)
}
}
What I want to do is the following but clearly this is not allowed since you cannot access other properties in another property.
struct BusinessMapView: View {
#ObservedObject var businessAddressRowViewModel: BusinessAddressRowViewModel
#State private var region: MKCoordinateRegion = {
var mapCoordinates = CLLocationCoordinate2D(latitude: businessAddressRowViewModel.businessAddress.latitude, longitude: businessAddressRowViewModel.businessAddress.longitude)
var mapZoomLevel = MKCoordinateSpan(latitudeDelta: 5.00, longitudeDelta: 5.00)
var mapRegion = MKCoordinateRegion(center: mapCoordinates, span: mapZoomLevel)
return mapRegion
}()
var body: some View {
Map(coordinateRegion: $region)
}
}
So my question is, is there a way to set the coordinates from a database for a Map() in SwiftUI or is the only option to use static values for latitude and longitude?
EDIT ADDED FOR MORE INFORMATION
class BusinessAddressRowViewModel: ObservableObject, Identifiable {
// Properties
var id: String = ""
public static let shared = BusinessAddressRowViewModel()
// Published Properties
#Published var businessAddress: BusinessAddress
// Combine Cancellable
private var cancellables = Set<AnyCancellable>()
// Initializer
init(businessAddress: BusinessAddress) {
self.businessAddress = businessAddress
self.startCombine()
}
// Starting Combine
func startCombine() {
// Get Bank Account
$businessAddress
.receive(on: RunLoop.main)
.compactMap { businessAddress in
businessAddress.id
}
.assign(to: \.id, on: self)
.store(in: &cancellables)
}
}
The shared property gives an error stating the parameter businessAddress is missing.
The data is coming from Firebase Firestore here.
class BusinessAddressRepository: ObservableObject {
let db = Firestore.firestore()
private var snapshotListener: ListenerRegistration?
#Published var businessAddresses = [BusinessAddress]()
init() {
startSnapshotListener()
}
func startSnapshotListener() {
// Get the currentUserUid
guard let currentUserId = Auth.auth().currentUser else {
return
}
if snapshotListener == nil {
// Add a SnapshotListener to the BusinessAddress Collection.
self.snapshotListener = db.collection(FirestoreCollection.users).document(currentUserId.uid).collection(FirestoreCollection.businessAddresses).addSnapshotListener { (querySnapshot, error) in
// Check to see if an error occured and print it. IMPLEMENT ERROR HANDLING LATER
if let error = error {
print("Error getting documents: \(error)")
} else {
print("BusinessAddressRepository - snapshotListener called")
// Check to make sure the Collection contains Documents
guard let documents = querySnapshot?.documents else {
print("No Business Addresses.")
return
}
// Documents exist.
self.businessAddresses = documents.compactMap { businessAddress in
do {
return try businessAddress.data(as: BusinessAddress.self)
} catch {
print(error)
}
return nil
}
}
}
}
}
func stopSnapshotListener() {
if snapshotListener != nil {
snapshotListener?.remove()
snapshotListener = nil
}
}
}
Data is being passed to BusinessAddressRowViewModel from the BusinessAddressViewModel. BusinessAddressView holds the list that creates all the rows.
class BusinessAddressViewModel: ObservableObject {
var businessAddressRepository: BusinessAddressRepository
// Published Properties
#Published var businessAddressRowViewModels = [BusinessAddressRowViewModel]()
// Combine Cancellable
private var cancellables = Set<AnyCancellable>()
// Intitalizer
init(businessAddressRepository: BusinessAddressRepository) {
self.businessAddressRepository = businessAddressRepository
self.startCombine()
}
// Starting Combine - Filter results for business addresses created by the current user only.
func startCombine() {
businessAddressRepository
.$businessAddresses
.receive(on: RunLoop.main)
.map { businessAddress in
businessAddress
.map { businessAddress in
BusinessAddressRowViewModel(businessAddress: businessAddress)
}
}
.assign(to: \.businessAddressRowViewModels, on: self)
.store(in: &cancellables)
}
}
You have an initialization problem here, having nothing to do with the Map(). You are trying to use businessCoordinates the instantiated ObservedObject variable in the initializer, and, I am sure, are getting a Cannot use instance member 'businessCoordinates' within property initializer; property initializers run before 'self' is available error.
If you don't need 'businessCoordinates' anywhere in the view, other than the data, I would recommend this:
class BusinessCoordinates: ObservableObject {
public static let shared = BusinessCoordinates()
...
}
This will give you a Singleton you can use at will. Then you use it like this:
struct BusinessMapView: View {
#State private var region: MKCoordinateRegion
init() {
let mapCoordinates = CLLocationCoordinate2D(latitude: BusinessCoordinates.shared.latitude, longitude: BusinessCoordinates.shared.longitude)
var mapZoomLevel = MKCoordinateSpan(latitudeDelta: 5.00, longitudeDelta: 5.00)
_region = State(initialValue: MKCoordinateRegion(center: mapCoordinates, span: mapZoomLevel))
}
var body: some View {
Map(coordinateRegion: $region)
}
}
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.
I am using Xcode 12.
I am able to show a map with a region, but with hard-coded values.
Instead, I want to set the region of the map based on the user's current location.
I have a LocationManager class, which gets the user's location and publish it.
I have a ShowMapView SwiftUI View that observes an object based on the LocationManager class to get the user's location.
But, I don't know how to use the data from the locationManager object to set the region used by the map.
Here is the LocationManager class, which gets the user's location and publishes it.
import Foundation
import MapKit
final class LocationManager: NSObject, ObservableObject {
#Published var location: CLLocation?
private let locationManager = CLLocationManager()
override init() {
super.init()
self.locationManager.delegate = self
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
self.locationManager.distanceFilter = kCLDistanceFilterNone
self.locationManager.requestWhenInUseAuthorization()
self.locationManager.startUpdatingLocation()
}
}
extension LocationManager: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.last {
self.location = location
}
}
}
Here is the ShowMapView SwiftUI View, which needs to get the user's location that's published and set the region used by the map. As you can see, the values are hard-coded for now.
import Combine
import MapKit
import SwiftUI
struct AnnotationItem: Identifiable {
let id = UUID()
let name: String
let coordinate: CLLocationCoordinate2D
}
struct ShowMapView: View {
#ObservedObject private var locationManager = LocationManager()
#State private var region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 38.898150, longitude: -77.034340),
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
)
var body: some View {
Map(coordinateRegion: $region, annotationItems: [AnnotationItem(name: "Home", coordinate: CLLocationCoordinate2D(latitude: self.locationManager.location!.coordinate.latitude, longitude: self.locationManager.location!.coordinate.longitude))]) {
MapPin(coordinate: $0.coordinate)
}
.frame(height: 300)
}
}
Here's one possible solution to this:
final class LocationManager: NSObject, ObservableObject {
#Published var location: CLLocation?
#Published var region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 38.898150, longitude: -77.034340),
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
)
private var hasSetRegion = false
private let locationManager = CLLocationManager()
override init() {
super.init()
self.locationManager.delegate = self
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
self.locationManager.distanceFilter = kCLDistanceFilterNone
self.locationManager.requestWhenInUseAuthorization()
self.locationManager.startUpdatingLocation()
}
}
extension LocationManager: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.last {
self.location = location
if !hasSetRegion {
self.region = MKCoordinateRegion(center: location.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
hasSetRegion = true
}
}
}
}
struct ShowMapView: View {
#ObservedObject private var locationManager = LocationManager()
var homeLocation : [AnnotationItem] {
guard let location = locationManager.location?.coordinate else {
return []
}
return [.init(name: "Home", coordinate: location)]
}
var body: some View {
Map(coordinateRegion: $locationManager.region, annotationItems: homeLocation) {
MapPin(coordinate: $0.coordinate)
}
.frame(height: 300)
}
}
In this solution, the region is published by the location manager. As soon as a location is received, the region is centered on that spot (in didUpdateLocations). Then, a boolean flag is set saying the region has been centered initially. After that boolean is set, it no longer updates the region. This will let the user still drag/zoom, etc.
I also changed your code for putting down the pin a little bit. You were force-unwrapping location, which is nil until the first location is set by the location manager, causing a crash. In my edit, it just returns an empty array of annotation items if there isn't a location yet.
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.