Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Closes #265] Injection of viewmodel when @Observable not working properly #266

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

pilmen
Copy link

@pilmen pilmen commented Mar 17, 2025

This pull request introduces a new property wrapper InjectedObservable to manage observable dependencies in SwiftUI views and includes tests to verify its functionality. The most important changes include the addition of the InjectedObservable property wrapper, updates to the test suite, and the creation of a new observable view model.

New Property Wrapper for Dependency Injection:

  • Sources/Factory/Factory/Injections.swift: Introduced the InjectedObservable property wrapper, which resolves and injects observable dependencies from a shared container into SwiftUI views. This wrapper supports iOS 17.0, macOS 14.0, tvOS 17.0, and watchOS 10.0.

Test Suite Enhancements:

  • Tests/FactoryTests/FactoryInjectionTests.swift: Added a new test case testInjectedObservable to verify the functionality of the InjectedObservable property wrapper, including initialization from both default and custom containers, as well as direct parameter passing.

New Observable View Model:

@hmlongco
Copy link
Owner

hmlongco commented Mar 17, 2025

Nice, but doesn't go far enough in that it suffers from the state initialization issue with observation. Every time the wrapper is reinstantiated it will make a new request to Factory.

See: https://fatbobman.com/en/posts/lazy-initialization-state-in-swiftui/

@hmlongco
Copy link
Owner

hmlongco commented Mar 17, 2025

/// A property wrapper that injects an Observable dependency into a SwiftUI view.
///
/// `InjectedObservable` is designed to automatically resolve and inject Observable dependencies
/// from a shared container, allowing for easy management of Observable objects within
/// SwiftUI views. This property wrapper ensures that the dependency is resolved at
/// initialization and provides both direct access and binding capabilities.
///
/// And unlike using State, the injected dependency is only resolved once, on first use.
///
/// - Note: This property wrapper is available on iOS 17.0, macOS 14.0, tvOS 17.0, and watchOS 10.0.
/// - Requires: The wrapped type `T` must conform to the `Observable` protocol.
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
@MainActor @propertyWrapper public struct InjectedObservable<T>: DynamicProperty where T: Observation.Observable {
    /// The observable dependency managed by this property wrapper.
    @State fileprivate var dependency: ThunkedValue<T>
     /// Initializes the `InjectedObservable` property wrapper, resolving the dependency from the default container.
     ///
     /// - Parameter keyPath: A key path to a `Factory` on the default `Container` that resolves the dependency.
     ///
     /// **Example Usage:**
     /// ```swift
     /// @InjectedObservable(\.contentViewModel) var viewModel: ContentViewModel
     /// ```
    public init(_ keyPath: KeyPath<Container, Factory<T>>) {
        self._dependency = .init(wrappedValue: ThunkedValue(thunkedValue: { Container.shared[keyPath: keyPath]() }))
    }
    /// Initializes the property wrapper. The dependency is resolved on initialization.
    /// - Parameter keyPath: KeyPath to a Factory on the specified Container.
    public init<C: SharedContainer>(_ keyPath: KeyPath<C, Factory<T>>) {
        self._dependency = .init(wrappedValue: ThunkedValue(thunkedValue: { C.shared[keyPath: keyPath]() }))
    }
    /// Provides direct access to the wrapped observable dependency.
    public var wrappedValue: T {
        get { dependency.thunkedValue }
    }
    /// Provides a binding to the wrapped observable dependency, allowing for dynamic updates.
    public var projectedValue: Binding<T> {
        Binding(get: { dependency.thunkedValue }, set: { _ in })
    }
}

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
extension InjectedObservable {
    /// Simple initializer with passed parameter bypassing injection.
    ///
    /// Still has issue with attempting to pass dependency into existing view when existing InjectedObject has keyPath.
    /// https://forums.swift.org/t/allow-property-wrappers-with-multiple-arguments-to-defer-initialization-when-wrappedvalue-is-not-specified
    public init(_ wrappedValue: @autoclosure @escaping () -> T) {
        self._dependency = .init(wrappedValue: ThunkedValue(thunkedValue: wrappedValue))
    }
}

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
private final class ThunkedValue<T: Observation.Observable> {
    private var object: T!
    private var thunk: (() -> T)?
    init(thunkedValue thunk: @escaping () -> T) {
        self.thunk = thunk
    }
    var thunkedValue: T {
        if let thunk {
            object = thunk()
            self.thunk = nil
        }
        return object
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants