Cannot get @EnvironmentObject in Controller

Viewed 624

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.

1 Answers

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.

Related