swiftswiftuiswiftui-environment

Using protocols with the newest @Environment in SwiftUI


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.


Solution

  • 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