12

I'm using the latest Observation framework with SwiftUI introduced at WWDC 2023. It seems that the new @Environment property wrapper does not allow to use protocols. Here is what my service implementations looks like:

protocol UserServiceRepresentation {
    var user: User { get }
}

// Service implementation
@Observable
final class UserService: UserServiceRepresentable {
    private(set) var user: User

    func fetchUser() async { ... }
}

// Service mock
@Observable
final class UserServiceMock: UserServiceRepresentable {
    let user: User = .mock
}

Previously, it was possible using @EnvironmentObject to declare a protocol so you can pass your mocked service as your environment object to the view.

struct UserView: View {
    // This is possible using EnvironmentObject.
    // But it require UserService to conforms to ObservableObject.
    @EnvironmentObject private var userService: UserService

    var body: some View { ... }
}

#Preview {
    UserView()
        .environmentObject(UserServiceMock()) // this allows your previews not to rely on production data.
}

Now, with @Environment, I can't do something like this:

struct UserView: View {
    // This is not possible
    // Throw compiler error: No exact matches in call to initializer
    @Environment(UserService.self) private var userService

    var body: some View { ... }
}

I would have been nice to inject my mocked user service like so:

#Preview {
    UserView()
        .environment(UserServiceMock())
}

Am I missing something or doing it the wrong way? It seems that the new @Environment property wrapper was not built for this kind of usage. I can't find anything about it on the web. Thanks in advance for your help.

8
  • 1
    The new @Environment should take an Observable-conforming type, and no, protocols cannot conform to other protocols, so it can't be used this way. What's wrong with how you previously did this anyway? Commented Oct 27, 2023 at 9:08
  • Nothing wrong with the previous way. The @EnvironmentObject way allowed me not to make singleton for shared objects in the app to better deal with dependencies injection. The new @Environment now forces me to create singletons. It works that way, but I feel like a regression. Commented Oct 27, 2023 at 9:17
  • Nothing is forcing you to do anything. You can keep using @EnvironmentObject and ObservableObject. They are not deprecated or anything. Do you mean you don't want to use ObservableObject anymore for some other reason, but still wants a convenient dependency injection? Commented Oct 27, 2023 at 9:22
  • You can make UserServiceMock a subclass of UserService, wrap it inside #if DEBUG ... #endif if you want to be sure it isn't used anywhere in the app code. Commented Oct 27, 2023 at 9:23
  • 1
    @Sweeper I came across the same issue. The reason I prefer Observable over ObservableObject is that the latter will re-render all views depending on one of the Published properties, whereas changes on an Observable's properties only cause a re-render if that particular property is used by the view. That's a major performance gain. Commented Feb 2, 2024 at 17:47

1 Answer 1

14

No, the @Environment initialisers take a concrete type that is Observable, and there is nothing wrong with keep using EnvironmentObject and ObservableObject. If it ain't broke, don't fix it.

If you really like @Observable for some reason, you can create a custom environment key that stores the user service. The value for the environment key can be an existential protocol type.

struct UserServiceKey: EnvironmentKey {
    // you can also set the real user service as the default value
    static let defaultValue: any UserServiceRepresentation = UserServiceMock()
}

extension EnvironmentValues {
    var userService: any UserServiceRepresentation {
        get { self[UserServiceKey.self] }
        set { self[UserServiceKey.self] = newValue }
    }
}

Then you can call the Environment initialiser that takes a keypath.

@Environment(\.userService) var service
.environment(\.userService, someService)

Note that if the instance of UserServiceRepresentation is not Observable, this will not work. It might be better to require an Observable conformance.

protocol UserServiceRepresentation: Observable
Sign up to request clarification or add additional context in comments.

5 Comments

Thank you for your answer! This is a working solution. The Observation framework provide several features such as performance improvements and Apple strongly encourage developers to migrate. That being said, you’re right saying that still using @EnvironmentObject to this day would not cause any issue.
I believe this won't send change notifications though because existential types aren't equatable
@aehlke What can change? Setting user from within UserService does cause the view to update correctly, and changing the environment value to something else works too. If something is not working for you, you should post a new question and include a minimal reproducible example.
Thanks, you're right - nice trick for getting view updates from an existential type
Just a quick "thanks": Using custom keys also helped me injecting classes that do not conform to ObservableObject nor implements Observable.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.