Passing Data between Delegate and ViewModel ObservableObjects - mvvm

I have an app built using SwiftUI that works with user location. Using online tutorials, I have come up with a class LocationManager that handles the request using the delegate method and has an attribute #Published that sets the location as soon as it receives it.
I also have a ViewModel that has a function getData(location: CLLocation) that will properly update my view after an async call to a different API.
My question is, what is the best way to connect the LocationManager with the ViewModel, so that as soon as the LocationManager gets the location using the delegate it automatically calls the getData() function with that value?
I've tried to come up with a few solutions on my own, such as passing a closure to the LocationManager to call viewModel.getData() when the delegate is updated, but I got an issue with the "closure capturing a mutating self parameter". Any help would be greatly appreciated!!
Here is the code in question:
final class LocationManager: NSObject, ObservableObject {
#Published var location: CLLocation?
private let locationManager = CLLocationManager()
override init() {
super.init()
locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
locationManager.delegate = self
}
}
extension LocationManager: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else {
return
}
DispatchQueue.main.async {
self.location = location
}
}
}
class ViewModel: ObservableObject {
#Published dataArray = [Model]()
func getData(location: CLLocation) {
// async api call
// update dataArray for view in completion handler
}
}
struct ShowData: View {
// initialize LocationManager
#StateObject var locationManager = LocationManager()
// initialize ViewModel
#StateObject var viewModel = ViewModel()
var body: some View {
// show dataArray
}
}

You can own the LocationManager in your view model:
class ViewModel: ObservableObject {
#Published dataArray = [Model]()
var lm = LocationManager()
}
Then, you could architect the LocationManager to take a separate delegate (which could be the view model), or, you could use Combine to listen for changes on the #Published property on the LocationManager:
cancellable = lm.$location.sink { ... }

Related

Do something in another class when the locationManagerDidChangeAuthorization method called

In the following code, I have a LocationManager class which provides the city name of the current location via the #Published property wrapper lastSearchedCity.
Then I have a SearchManagerViewModel class that should be in charge of presenting the city name on SwiftUI views based on some conditions (not currently shown in the code below) via the #Published property wrapper cityName. It properly shows the city name when I call the searchAndSetCity() method from ContentView.swift inside an onAppear modifier.
My issue is that if the user turned Location Services off and turns it back On while he/she is in the ContentView.swift the Text view doesn't update, which is understandable since the searchAndSetCity() method would need to be called again.
How can I call the searchAndSetCity() method located inside the SearchManagerViewModel class every time the locationManagerDidChangeAuthorization(_ manager: CLLocationManager) method is called? I believed this method is called every time the authorization status changes.
LocationManager Class
final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
private let locationManager = CLLocationManager()
#Published var lastSearchedCity = ""
var hasFoundOnePlacemark:Bool = false
func checkIfLocationServicesIsEnabled(){
DispatchQueue.global().async {
if CLLocationManager.locationServicesEnabled(){
self.locationManager.delegate = self
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest/// kCLLocationAccuracyBest is the default
self.checkLocationAuthorization()
}else{
// show message: Services desabled!
}
}
}
private func checkLocationAuthorization(){
switch locationManager.authorizationStatus{
case .notDetermined:
locationManager.requestWhenInUseAuthorization()
case .restricted:
// show message
case .denied:
// show message
case .authorizedWhenInUse, .authorizedAlways:
/// app is authorized
locationManager.startUpdatingLocation()
default:
break
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
checkLocationAuthorization()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
hasFoundOnePlacemark = false
CLGeocoder().reverseGeocodeLocation(manager.location!, completionHandler: {(placemarks, error)-> Void in
if error != nil {
self.locationManager.stopUpdatingLocation()
// show error message
}
if placemarks!.count > 0 {
if !self.hasFoundOnePlacemark{
self.hasFoundOnePlacemark = true
let placemark = placemarks![0]
self.lastSearchedCity = placemark.locality ?? ""
}
self.locationManager.stopUpdatingLocation()
}else{
// no places found
}
})
}
}
SearchManagerViewModel Class
class SearchManagerViewModel: ObservableObject{
#Published var cityName = "" // use this directly in SwifUI views
#ObservedObject private var locationManager = LocationManager()
// Call this directly fron onAppear in SwiftUI views
// This method is more complex than what is shown here. It handles other things like HTTP requests etc.
func searchAndSetCity(){
locationManager.checkIfLocationServicesIsEnabled()
self.cityName = locationManager.lastSearchedCity
}
}
ContentView.swift
struct ContentView: View {
#StateObject private var searchManager = SearchManagerViewModel()
var body: some View {
VStack {
Text(searchManager.cityName)
.font(.callout)
}
.onAppear{
searchManager.searchAndSetCity()
}
}
}

#Published requires willSet to fire

I have a class:
final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
let objectWillChange = PassthroughSubject<Void, Never>()
private let locationManager = CLLocationManager()
#Published var status: String? {
willSet { objectWillChange.send() }
}
#Published var location: CLLocation? {
willSet { objectWillChange.send() }
}
// ...other code
}
And then I have a view that observes this class:
struct MapView: UIViewRepresentable {
#ObservedObject var lm = LocationManager()
// ...other view code
}
Everything works fine and the view updates when the published property changes. However, if I remove the willSet { objectWillChange.send() } then the view that observes an instance of LocationManager does not update when the published location changes. Which brings me to my question: I thought that by putting #Published next to a var that any #ObservedObject will invalidate the current view when the published property changes, essentially a default objectWillChange.send() implementation but this doesn't seem to be happening. Instead I have to manually call the update. Why is that?
You're doing too much work. It looks like you're trying to write ObservableObject. You don't need to; it already exists. The whole point is that ObservableObject is already observable, automatically. Here's a non-SwiftUI example:
final class Thing: NSObject, ObservableObject {
#Published var status: String?
}
class ViewController: UIViewController {
var storage = Set<AnyCancellable>()
let thing = Thing()
override func viewDidLoad() {
self.thing.objectWillChange
.sink {_ in print("will change")}.store(in: &self.storage)
self.thing.$status
.sink { print($0) }.store(in: &self.storage)
}
#IBAction func doButton (_ sender:Any) {
self.thing.status = (self.thing.status ?? "") + "x"
}
}
The thing to notice is that, although the observable object contains no code at all, it is emitting a signal every time its status property is set, before the property changes. Then the status property itself emits a signal, namely its new value.
The same thing happens in SwiftUI.
Apple documentation states:
By default an ObservableObject synthesizes an objectWillChange
publisher that emits the changed value before any of its #Published
properties changes.
This means you don't need to declare your own objectWillChange. You can just remove objectWillChange from your code including the following line:
let objectWillChange = PassthroughSubject<Void, Never>()

Handle SwiftUI and CoreLocation with MVVM-Pattern

i am trying to implement SwiftUI and CoreLocation with the MVVM-Pattern. My LocationManager as Helper works fine. But how I can change the properties of my LocationViewModel? I am implemented my #ObservedObject of the LocationManager in LocationViewModel. Here is my problem.
I don't have a idea to implement properties they change on the fly. Nothing is changed in my LocationView. By pressing a Button anything works fine one time. But the LocationViewModel must change there properties on every change of the LocationManager.
In summary I would like to display the current user position.
// Location Manager as Helper
import Foundation
import CoreLocation
class LocationManager: NSObject, ObservableObject {
let locationManager = CLLocationManager()
let geoCoder = CLGeocoder()
#Published var location: CLLocation?
#Published var placemark: CLPlacemark?
override init() {
super.init()
self.locationManager.delegate = self
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
self.locationManager.requestWhenInUseAuthorization()
self.locationManager.startUpdatingLocation()
}
func geoCode(with location: CLLocation) {
geoCoder.reverseGeocodeLocation(location) { (placemark, error) in
if error != nil {
print(error!.localizedDescription)
} else {
self.placemark = placemark?.first
}
}
}
}
extension LocationManager: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return }
DispatchQueue.main.async {
self.location = location
self.geoCode(with: location)
}
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
// TODO
}
}
// Location Model
import Foundation
import CoreLocation
struct Location {
var location: CLLocation = CLLocation()
var placemark: CLPlacemark = CLPlacemark()
}
// Location View Model
import SwiftUI
import CoreLocation
class LocationViewModel: ObservableObject {
#ObservedObject var locationManager: LocationManager = LocationManager()
#Published var location: Location
init() {
self.location = Location()
}
}
// Location View
import SwiftUI
struct LocationView: View {
#ObservedObject var locationViewModel: LocationViewModel = LocationViewModel()
var body: some View {
VStack(alignment: .leading) {
Text("Latitude: \(self.locationViewModel.location.location.coordinate.latitude.description)")
Text("Longitude: \(self.locationViewModel.location.location.coordinate.longitude.description)")
}
}
}
struct LocationView_Previews: PreviewProvider {
static var previews: some View {
LocationView()
}
}
Update
Now, I have set up my MapView.
But how I can receive the data of my LocationManager? The didUpdateLocations method is working in LocationManager.
All what I am trying to do goes wrong. I would like to set the region on my MapView based on the current user location. In UIKit it was very simple, but in SwiftUI it is freaky.
// Map View
import SwiftUI
import MapKit
struct MapView: UIViewRepresentable {
#ObservedObject var locationManager: LocationManager = LocationManager()
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ control: MapView) {
self.parent = control
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView(frame: .zero)
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
mapView.showsUserLocation = true
}
}
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView()
}
}
SwiftUI 2
Use instead StateObject in this case
struct LocationView: View {
#StateObject var locationManager: LocationManager = LocationManager()
...
SwiftUI 1
Actually LocationViewModel is redundant here. As your LocationManager is a ObservableObject you can use it directly in your view, as below:
struct LocationView: View {
#ObservedObject var locationManager: LocationManager = LocationManager()
var body: some View {
VStack(alignment: .leading) {
Text("Latitude: \(locationManager.location.coordinate.latitude.description)")
Text("Longitude: \(locationManager.location.coordinate.longitude.description)")
}
}
}

I don't understand Property 'self.heading' not initialized at super.init call error in my code

I created a class to get the current heading with CLLocationManagerDelegate for my SwiftUI app, but I have the Property 'self.heading' not initialized at super.init call error. I don't know why. I do not think I need to specify a parameter because my class does not take any parameters during its call ?
My LocationManager class:
class LocationManager: NSObject, CLLocationManagerDelegate, ObservableObject {
var locationManager: CLLocationManager?
var objectWillChange = PassthroughSubject<Void, Never>()
#Published var heading: Double {
willSet {
objectWillChange.send()
}
}
override init() {
super.init()
locationManager = CLLocationManager()
locationManager!.delegate = self
locationManager!.desiredAccuracy = kCLLocationAccuracyBest
locationManager!.requestWhenInUseAuthorization()
}
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
self.heading = newHeading.trueHeading.degreesToRadians
}
}
You MUST initial all instance variables of the current class before initializing the super.
So add some initial value to heading before calling super.init() like:
override init() {
heading = 0 // Or anything you need
super.init()
locationManager = CLLocationManager()
locationManager!.delegate = self
locationManager!.desiredAccuracy = kCLLocationAccuracyBest
locationManager!.requestWhenInUseAuthorization()
}

How to get Current Location with SwiftUI?

Trying to get current location with using swiftUI. Below code, couldn't initialize with didUpdateLocations delegate.
class GetLocation : BindableObject {
var didChange = PassthroughSubject<GetLocation,Never>()
var location : CLLocation {
didSet {
didChange.send(self)
}
}
init() {}
}
This code below works (Not production ready). Implementing the CLLocationManagerDelegate works fine and the lastKnownLocation is updated accordingly.
Don't forget to set the NSLocationWhenInUseUsageDescription in your Info.plist
class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
var lastKnownLocation: CLLocation?
func startUpdating() {
manager.delegate = self
manager.requestWhenInUseAuthorization()
manager.startUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
print(locations)
lastKnownLocation = locations.last
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
if status == .authorizedWhenInUse {
manager.startUpdatingLocation()
}
}
}
I have written a one-file swift package with usage instructions on https://github.com/himbeles/LocationProvider. It provides a ObservableObject-type wrapper class for CLLocationManager and its delegate.
There is a #Published property location which can directly be used in SwiftUI, as well as a PassthroughSubject<CLLocation, Never> called locationWillChange that you can subscribe to via Combine. Both update on every didUpdateLocations event of the CLLocationManager.
It also handles the case where location access has previously been denied: The default behavior is to present the user with a request to enable access in the app settings and a link to go there.
In SwiftUI (> iOS 14, > macOS 11), use as
import SwiftUI
import LocationProvider
struct ContentView: View {
#StateObject var locationProvider = LocationProvider()
var body: some View {
VStack{
Text("latitude \(locationProvider.location?.coordinate.latitude ?? 0)")
Text("longitude \(locationProvider.location?.coordinate.longitude ?? 0)")
}
.onAppear {
do {try locationProvider.start()}
catch {
print("No location access.")
locationProvider.requestAuthorization()
}
}
}
}
As of Xcode 11 beta 4, you will need to change didChange to willChange:
var willChange = PassthroughSubject<LocationManager, Never>()
var lastKnownLocation: CLLocation? {
willSet {
willChange.send(self)
}
}