hmlongco / Resolver

Swift Ultralight Dependency Injection / Service Locator framework
MIT License
2.14k stars 187 forks source link

@InjectedObject breaks Bindings #135

Closed Faltenreich closed 2 years ago

Faltenreich commented 2 years ago

First of all, thank you for this awesome framework. Resolver really helps establishing a clean architecture and is a pure joy to use so far. Currently we are only having one problem:

We have one SwiftUI View with a TextField whose content should be bound to a property. This property is being held by a ViewModel which itself is an ObservableObject. The @Published annotation allows us to bind the property's content to the TextField.

View

import SwiftUI
import Resolver

struct SampleView: View {

    @InjectedObject var viewModel: SampleViewModel

    var body: some View {
        TextField("Hint", $viewModel.text) // Error: Cannot find $viewModel in scope
    }
}

ViewModel

import Combine

class SampleViewModel: ObservableObject {

    @Published var text: String = ""
}

If we change the annotation for viewModel from @InjectedObject to @ObservedObject and instantiate the dependency ourselves, the code compiles again and everything works fine. But then we lose the support for Resolver which we would rather use.

Is there something we are doing wrong when using Resolver for ObservedObjects?

hmlongco commented 2 years ago

I plugged this into one of my tinkertoy projects and it works just fine (after fixing a syntax error).

struct SampleView: View {
    @InjectedObject var viewModel: SampleViewModel
    var body: some View {
        TextField("Hint", text: $viewModel.text) // Works after adding text:
    }
}

class SampleViewModel: ObservableObject {
    @Published var text: String = ""
}

So does this example...

class ContentViewModel: ObservableObject {
    @Injected var data: DataProviding
    @Published var greeting: String = ""
    var text: String {
        if greeting.isEmpty {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                self.greeting = "Good Evening, "
            }
            return "Hello, \(data.text) world!"
        }
        return "\(greeting)\(data.text) world!"
    }
}

struct ContentView: View {
    @InjectedObject var viewModel: ContentViewModel
    var body: some View {
        VStack(spacing: 20) {
            TextField("Hint", text: $viewModel.greeting)
            Text(viewModel.text)
        }
        .padding()
    }
}

Everything builds, runs, and updates as expected. (Xcode 12.5, iOS 14.1 minimum version)

Faltenreich commented 2 years ago

My bad! I left out a property wrapper that serves as a wrapper class for the @InjectedObject annotation in order to simplify the code example. This property wrapper encapsulates third-party dependencies and, as it turned out, was bugged:

import SwiftUI
import Resolver

@propertyWrapper
struct ViewDependency<Component>: DynamicProperty where Component: ObservableObject {

    @InjectedObject private var value: Component

   var wrappedValue: Component { value }

    // This override was missing which lead to returning a Component instead of ObservedObject<Component>
   var projectedValue: ObservedObject<Component>.Wrapper { $value }
}

After adding the missing delegate for projectedValue, the code compiles again and everything works as expected.

Thank you for your time and please excuse this misleading issue!

TL;DR: Resolver works fine, this was a self-made problem.

hmlongco commented 2 years ago

Funny. @InjectedObject is a simple property wrapper around the @ObservedObject property wrapper... that you're wrapping.

Things are getting deep here. ;)