SwiftUI mapkit set region to user's current location - swiftui

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.

Related

Can You Get Latitude and Longitude From A Database for MapKit in SwiftUI

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

Problem of resizing Map using SwiftUI Mapkit

I would like to zoom in my map on the application, I tried both using latitudinalMeters: 300, longitudinalMeters: 300 or spin with latitudeDelta: 0.001. Both of them did not work at all.
I also chose (0, 0) as my center, but every time I run on the simulator, I have (37.326010,-122.026056) as my center. Apparently, none of the default settings of center and region that I had set in my location manager works in ContentView.
Here is my code of LocationManager.swift:
import Foundation
import CoreLocation
import MapKit
class LocationManager: NSObject, ObservableObject{
let locationManager = CLLocationManager()
#Published var location: CLLocation?
#Published var region: MKCoordinateRegion
override init(){
self.region = MKCoordinateRegion(center: CLLocationCoordinate2D.init(latitude: 0,longitude: 0),latitudinalMeters: 300, longitudinalMeters: 300)
super.init()
self.locationManager.delegate = self
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
self.locationManager.requestWhenInUseAuthorization()
self.locationManager.startUpdatingLocation()
}
}
extension LocationManager : CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]){
guard let location = locations.last else { return }
self.region = MKCoordinateRegion(center: location.coordinate, latitudinalMeters: 300, longitudinalMeters: 300)
self.location = location
}
}
Here is my ContentView:
struct ContentView: View {
var body: some View {
MapView2()
}
}
struct MapView2: View {
#ObservedObject var locationManager = LocationManager()
var body: some View {
let coord = locationManager.location?.coordinate
let lat = coord?.latitude ?? 0
let lon = coord?.longitude ?? 0
return VStack {
Map(coordinateRegion: $locationManager.region,
interactionModes: .all,
showsUserLocation: true, userTrackingMode: .constant(.follow))
}
}
}
As for SwiftUI using MapKit, I would not use the CoreLocation framework. You can use the .onChange modifier to perform zoom changes to your View. You can use the #State var zoom with a SwiftUI gesture to perform them if you want, or anything that can do those changes live. I added two buttons within a slider to zoom in or out for the example.
import SwiftUI
import MapKit
struct ContentView: View {
var body: some View {
MapsView()
}
}
struct MapsView: View {
#State var zoom: CGFloat = 15
#State var mapCoordinate = MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: 38.989202809314854,
longitude: -76.93626224283602),
span: MKCoordinateSpan(
latitudeDelta: .zero,
longitudeDelta: .zero))
var body: some View {
VStack(spacing: 16) {
Map(coordinateRegion: $mapCoordinate)
.ignoresSafeArea(edges: .all)
// You can see the changes being operating by the .onChange modifier.
Slider(value: $zoom,
in: 0.01...50,
minimumValueLabel: Image(systemName: "plus.circle"),
maximumValueLabel: Image(systemName: "minus.circle"), label: {})
.padding(.horizontal)
.onChange(of: zoom) { value in
mapCoordinate.span.latitudeDelta = CLLocationDegrees(value)
mapCoordinate.span.longitudeDelta = CLLocationDegrees(value)
}
}
.font(.title)
}
}

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

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.

Passing Lat/Long to function in SwiftUI

I am building a SwiftUI app that shows data based on user lat/long. I have based my code off of this sample provided by the framework dev.
With SwiftUI I have my LocationManager set as:
class LocationViewModel: NSObject, ObservableObject, CLLocationManagerDelegate{
#Published var userLatitude: Double = 0.0
#Published var userLongitude: Double = 0.0
private let locationManager = CLLocationManager()
override init() {
super.init()
self.locationManager.delegate = self
self.locationManager.distanceFilter = 100.0
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
self.locationManager.requestWhenInUseAuthorization()
self.locationManager.startUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
userLatitude = location.coordinate.latitude
userLongitude = location.coordinate.longitude
print("Hello I'm here! \(location)")
}
}
Whenever I go back to my ContentView and try to read the Lat/Long it just shows up as 0.0. but if I output them within the body the values show up correctly.
struct ContentView: View {
#State private var times = prayerTimes()
#ObservedObject var locationViewModel = LocationViewModel()
var body: some View {
NavigationView {
PrayerTimeView(times: $times)
.navigationBarTitle(Text("Prayer Times"))
}
}
static func prayerTimes() -> PrayerTimes? {
let cal = Calendar(identifier: Calendar.Identifier.gregorian)
let date = cal.dateComponents([.year, .month, .day], from: Date())
let coordinates = Coordinates(latitude: locationViewMode.userLatitude, longitude: locationViewMode.userLongitude)
var params = CalculationMethod.moonsightingCommittee.params
params.madhab = .hanafi
return PrayerTimes(coordinates: coordinates, date: date, calculationParameters: params)
}
}
prayerTimes() only call once when you init the view. Why don't you make times as a #Published of your ViewModel. When location changes, just update that value.
PrayerTimeView(times: $viewmodel.times)
.navigationBarTitle(Text("Prayer Times"))