2

I'm trying to use @EnvironmentObject to control some aspects of my app. The issue I'm having is that one of my controllers can't access the environment object. I get the fatal error "No @ObservableObject of type Environment found".

I've searched other questions, and every solution I could find consisted of sending .environmentObject(myEnvironment) to the view in question. The problem is this is not a view, and I don't seem to have that option.

Also, in my SceneDelegate I send the environmentObject to the first view, so that is not the problem.

Here is my code.

First, I created a model to declare all my environment variables

Environment

struct Environment {
        var showMenu: Bool
        var searchText: String
        var location : Location

        init() {
            self.showMenu = false
            self.searchText = ""
            self.location = Location()
        }
    }

Next I have a controller which purpose is to handle any actions related to the environment, right now it has none

EnvironmentController

import Foundation

class EnvironmentController : ObservableObject {
    @Published var environment = Environment()
}

Now, in the SceneDelegate I call the NextDeparturesView, which in turn calls, the MapView.

MapView

import SwiftUI
import MapKit

//MARK: Map View
struct MapView : UIViewRepresentable {

    @EnvironmentObject var environmentController: EnvironmentController
    var locationController = LocationController()

    func makeUIView(context: Context) -> MKMapView {
        MKMapView(frame: .zero)
    }

    func updateUIView(_ uiView: MKMapView, context: Context) {
        let coordinate = CLLocationCoordinate2D(
            latitude: environmentController.environment.location.latitude,
            longitude: environmentController.environment.location.longitude)
        let span = MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
        let region = MKCoordinateRegion(center: coordinate, span: span)
        uiView.showsUserLocation = true
        uiView.setRegion(region, animated: true)
    }
}

You'll notice that in the MapView I call the LocationController, which is where the fatal error occurs

LocationController

import SwiftUI
import MapKit
import CoreLocation

final class LocationController: NSObject, CLLocationManagerDelegate, ObservableObject {
    //MARK: Vars
    @EnvironmentObject var environmentController: EnvironmentController
    @ObservedObject var userSettingsController = UserSettingsController()

    //var declaration - Irrelevant code to the question

    //MARK: Location Manager
    var locationManager = CLLocationManager()

    //MARK: Init
    override init() {
        //more irrelevant code

        super.init()

        //Ask for location access
        self.updateLocation()
    }

    //MARK: Functions
    func updateLocation() {
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        if locationManager.responds(to: #selector(CLLocationManager.requestAlwaysAuthorization)){
            locationManager.requestAlwaysAuthorization()
        }
        else {
            locationManager.startUpdatingLocation()
        }
    }

    //MARK: CLLocationManagerDelegate methods
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Error updating location :%@", error)
    }

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        switch status {
        case .notDetermined:
            self.setDefaultLocation()
            break
        case .restricted:
            self.setDefaultLocation()
            break
        case .denied:
            self.setDefaultLocation()
            break
        default:
            locationManager.startUpdatingLocation()
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {

        let currentLocation = manager.location?.coordinate
        self.environmentController.environment.location.latitude = Double(currentLocation!.latitude)
        self.environmentController.environment.location.longitude = Double(currentLocation!.longitude)

        manager.stopUpdatingLocation()
    }

    //MARK: Other Functions

    func recenter() {
        locationManager.startUpdatingLocation()
    }

    func setDefaultLocation() {
        if self.$userSettingsController.userCity.wrappedValue == "" {
            self.environmentController.environment.location.latitude = 0.0
            self.environmentController.environment.location.longitude = 0.0          
        } else {
            self.environmentController.environment.location.latitude = self.citiesDictionary[self.userSettingsController.userCity]!.latitude
            self.environmentController.environment.location.longitude = self.citiesDictionary[self.userSettingsController.userCity]!.longitude       
        }
    }
}

So, this is where the fatal error occurs. For instance, my app usually calls setDefaultLocation() first, and the app is crashing there. Any idea what I am doing wrong, or how to solve it?

Thank you in advance.

EDIT

After much help from @pawello2222 I've solved my problem, however with some changes to the overall structure of my application.

I will accept his answer as the correct one, but I'll provide a list of things that I did, so anyone seeing this in the future might get nudged in the right direction.

  1. I was wrongly assuming that View and UIViewRepresentable could both access the @EnvironmentObject. Only View can.
  2. In my Environment struct, instead of a Location var, I now have a LocationController, so the same instance is used throughout the application. In my LocationController I now have a @Published var location: Location, so every View has access to the same location.
  3. In structs of the type View I create the @EnvironmentObject var environmentController: EnvironmentController and use the LocationController associated with it. In other class types, I simply have an init method which receives a LocationController, which is sent through the environmentController, for instance, when I call MapView I do: MapView(locController: environmentController.environment.locationController) thus insuring that it is the same controller used throughout the application and the same Location that is being changed. It is important that to use @ObservedObject var locationController: LocationController in classes such as MapView, otherwise changes won't be detected.

Hope this helps.

kyrers
  • 489
  • 1
  • 6
  • 22
  • [How to pass EnvironmentObject into View Model?](https://stackoverflow.com/questions/59491675/swiftui-how-to-pass-environmentobject-into-view-model) Short answer: you should use `@EnvironmentObject` in views only. – pawello2222 Jun 04 '20 at 23:13

1 Answers1

2

Don't use @EnvironmentObject in your Controller/ViewModel (in fact anywhere outside a View). If you want to observe changes to Environment in your Controller you can do this:

class Environment: ObservableObject {
    @Published var showMenu: Bool = false
    @Published var searchText: String = ""
    @Published var location : Location = Location()
}
class Controller: ObservableObject {
    @Published var showMenu: Bool

    private var environment: Environment
    private var cancellables = Set<AnyCancellable>()

    init(environment: Environment) {
        _showMenu = .init(initialValue: environment.showMenu)
        environment.$showMenu
            .receive(on: DispatchQueue.main)
            .sink(receiveValue: { [weak self] value in
                self?.showMenu = value
            })
            .store(in: &cancellables)
    }
}

You can also use other forms of Dependency Injection to inject the Environment (or even use a singleton).


Generally there are different ways to show your Environment variables (eg. showMenu) in the View (and refresh it):

1) The Environment is injected into your View (NOT to ViewModel) as an @EnvironmentObject - for cases when you need to access the Environment from the View only.

2) The ViewModel subscribes to the Environment (as presented above) and publishes its own variables to the View. No need to use an @EnvironmentObject in your View then.

3) The Environment is injected into your View as an @EnvironmentObject and then is passed to the ViewModel.

pawello2222
  • 46,897
  • 22
  • 145
  • 209
  • I still can't get it to work. Now it says that the view(MapView) that calls the LocationController, doesn't have the object. Are views not supposed to have access to the environment object if I have it on the parent view? For instance, my entry point view has the object, can't I simply make the call ```MapView()```? Do I have to call it as ```MapView().environmentObject(envObject)```? – kyrers Jun 05 '20 at 12:50
  • Thank you. I'm trying to execute the option number 3. I edited my question to show you what is happening. Hopefully, you can help me. – kyrers Jun 05 '20 at 13:12
  • `UIViewRepresentable` is not the same as the `View`. Try passing the `Environment` to your `MapView` in init. Something like: `MapView(environmentController: environmentController)`. – pawello2222 Jun 05 '20 at 13:18
  • I've tried exactly that, but I still can't assign the parameter to the environmentController nor locationController. Must I remove the ```@EnvironmentObject``` notation. I've updated the code so you can see what I mean. – kyrers Jun 05 '20 at 14:45
  • You *cannot* use `@EnvironmentObject` property wrapper outside the `View`. Remove `@EnvironmentObject` from the declaration of your `environmentController` and it should be fine. – pawello2222 Jun 05 '20 at 14:50
  • 1
    Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/215379/discussion-between-diogo-melo-and-pawello2222). – kyrers Jun 05 '20 at 15:02