diff --git a/Sources/TokamakCore/Environment/Environment.swift b/Sources/TokamakCore/Environment/Environment.swift index a57f5256e..2e8511a7b 100644 --- a/Sources/TokamakCore/Environment/Environment.swift +++ b/Sources/TokamakCore/Environment/Environment.swift @@ -12,6 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// A protocol that allows the conforming type to access values from the `EnvironmentValues`. +/// (e.g. `Environment` and `EnvironmentObject`) +/// +/// `EnvironmentValues` are injected in 2 places: +/// 1. `View.makeMountedView` +/// 2. `MountedHostView.update` when reconciling +/// protocol EnvironmentReader { mutating func setContent(from values: EnvironmentValues) } @@ -22,7 +29,7 @@ protocol EnvironmentReader { case value(Value) } - var content: Environment.Content + var content: Content let keyPath: KeyPath public init(_ keyPath: KeyPath) { content = .keyPath(keyPath) diff --git a/Sources/TokamakCore/Environment/EnvironmentObject.swift b/Sources/TokamakCore/Environment/EnvironmentObject.swift new file mode 100644 index 000000000..46edc7961 --- /dev/null +++ b/Sources/TokamakCore/Environment/EnvironmentObject.swift @@ -0,0 +1,76 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 7/7/20. +// + +import OpenCombine + +@propertyWrapper public struct EnvironmentObject: ObservedProperty, + EnvironmentReader + where ObjectType: ObservableObject { + @dynamicMemberLookup public struct Wrapper { + internal let root: ObjectType + public subscript( + dynamicMember keyPath: ReferenceWritableKeyPath + ) -> Binding { + .init( + get: { + self.root[keyPath: keyPath] + }, set: { + self.root[keyPath: keyPath] = $0 + } + ) + } + } + + var _store: ObjectType? + var _seed: Int = 0 + + mutating func setContent(from values: EnvironmentValues) { + _store = values[ObjectIdentifier(ObjectType.self)] + } + + public var wrappedValue: ObjectType { + guard let store = _store else { error() } + return store + } + + public var projectedValue: Wrapper { + guard let store = _store else { error() } + return Wrapper(root: store) + } + + var objectWillChange: AnyPublisher<(), Never> { + wrappedValue.objectWillChange.map { _ in }.eraseToAnyPublisher() + } + + func error() -> Never { + fatalError("No ObservableObject found for type \(ObjectType.self)") + } + + public init() {} +} + +extension ObservableObject { + static var environmentStore: WritableKeyPath { + \.[ObjectIdentifier(self)] + } +} + +extension View { + public func environmentObject(_ bindable: B) -> some View where B: ObservableObject { + environment(B.environmentStore, bindable) + } +} diff --git a/Sources/TokamakCore/Environment/EnvironmentValues.swift b/Sources/TokamakCore/Environment/EnvironmentValues.swift index bc8dbcbb1..4f0f011e4 100644 --- a/Sources/TokamakCore/Environment/EnvironmentValues.swift +++ b/Sources/TokamakCore/Environment/EnvironmentValues.swift @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import OpenCombine + public struct EnvironmentValues: CustomStringConvertible { public var description: String { String(describing: values) @@ -32,4 +34,13 @@ public struct EnvironmentValues: CustomStringConvertible { values[ObjectIdentifier(key)] = newValue } } + + subscript(bindable: ObjectIdentifier) -> B? where B: ObservableObject { + get { + values[bindable] as? B + } + set { + values[bindable] = newValue + } + } } diff --git a/Sources/TokamakCore/MountedViews/MountedHostView.swift b/Sources/TokamakCore/MountedViews/MountedHostView.swift index 1eb6bca21..0ad4ece40 100644 --- a/Sources/TokamakCore/MountedViews/MountedHostView.swift +++ b/Sources/TokamakCore/MountedViews/MountedHostView.swift @@ -15,6 +15,8 @@ // Created by Max Desiatov on 03/12/2018. // +import Runtime + /* A representation of a `View`, which has a `body` of type `Never`, stored in the tree of mounted views by `StackReconciler`. */ @@ -103,6 +105,17 @@ public final class MountedHostView: MountedView { let newChild: MountedView if firstChild.typeConstructorName == mountedChildren[0].view.typeConstructorName { child.view = firstChild + // Inject Environment + // swiftlint:disable force_try + let viewInfo = try! typeInfo(of: child.view.type) + for prop in viewInfo.properties.filter({ $0.type is EnvironmentReader.Type }) { + // swiftlint:disable force_cast + var wrapper = try! prop.get(from: child.view.view) as! EnvironmentReader + wrapper.setContent(from: environmentValues) + try! prop.set(value: wrapper, on: &child.view.view) + // swiftlint:enable force_cast + } + // swiftlint:enable force_try child.update(with: reconciler) newChild = child } else { diff --git a/Sources/TokamakCore/MountedViews/MountedView.swift b/Sources/TokamakCore/MountedViews/MountedView.swift index 1f9baf8a0..96d4bc775 100644 --- a/Sources/TokamakCore/MountedViews/MountedView.swift +++ b/Sources/TokamakCore/MountedViews/MountedView.swift @@ -44,36 +44,37 @@ extension View { // Find Environment changes var modifiedEnv = environmentValues var injectableView = self - if let any = injectableView as? AnyView { - // swiftlint:disable force_try - // Extract the view from the AnyView for modification - var extractedView = any.view - let viewInfo = try! typeInfo(of: any.type) - if viewInfo - .genericTypes - .filter({ $0 is EnvironmentModifier.Type }).count > 0 { - // Apply Environment changes: - if let modifier = try? viewInfo - .property(named: "modifier") - .get(from: any.view) as? EnvironmentModifier { - modifier.modifyEnvironment(&modifiedEnv) - } + let any = (injectableView as? AnyView) ?? AnyView(injectableView) + // swiftlint:disable force_try + // Extract the view from the AnyView for modification + var extractedView = any.view + let viewInfo = try! typeInfo(of: any.type) + if viewInfo + .genericTypes + .filter({ $0 is EnvironmentModifier.Type }).count > 0 { + // Apply Environment changes: + if let modifier = try? viewInfo + .property(named: "modifier") + .get(from: any.view) as? EnvironmentModifier { + modifier.modifyEnvironment(&modifiedEnv) } - // Inject @Environment values - // In the future we can also inject @EnvironmentObject values - for prop in viewInfo.properties.filter({ $0.type is EnvironmentReader.Type }) { - // swiftlint:disable force_cast - var wrapper = try! prop.get(from: any.view) as! EnvironmentReader - wrapper.setContent(from: modifiedEnv) - try! prop.set(value: wrapper, on: &extractedView) - // swiftlint:enable force_cast - } - // Set the extractedView back on the AnyView after modification - let anyViewInfo = try! typeInfo(of: AnyView.self) - try! anyViewInfo.property(named: "view").set(value: extractedView, on: &injectableView) - // swiftlint:enable force_try } + // Inject @Environment values + for prop in viewInfo.properties.filter({ $0.type is EnvironmentReader.Type }) { + // swiftlint:disable force_cast + var wrapper = try! prop.get(from: any.view) as! EnvironmentReader + wrapper.setContent(from: modifiedEnv) + try! prop.set(value: wrapper, on: &extractedView) + // swiftlint:enable force_cast + } + + // Set the extractedView back on the AnyView after modification + let anyViewInfo = try! typeInfo(of: AnyView.self) + try! anyViewInfo.property(named: "view").set(value: extractedView, on: &injectableView) + // swiftlint:enable force_try + + // Make MountedView let anyView = injectableView as? AnyView ?? AnyView(injectableView) if anyView.type == EmptyView.self { return MountedNull(anyView) diff --git a/Sources/TokamakDOM/Environment/Environment.swift b/Sources/TokamakDOM/Environment/Environment.swift index 96ae1a4a6..a37429b84 100644 --- a/Sources/TokamakDOM/Environment/Environment.swift +++ b/Sources/TokamakDOM/Environment/Environment.swift @@ -18,3 +18,4 @@ import TokamakCore public typealias Environment = TokamakCore.Environment +public typealias EnvironmentObject = TokamakCore.EnvironmentObject diff --git a/Sources/TokamakDemo/EnvironmentDemo.swift b/Sources/TokamakDemo/EnvironmentDemo.swift index e0574dfc5..6fa0fdf74 100644 --- a/Sources/TokamakDemo/EnvironmentDemo.swift +++ b/Sources/TokamakDemo/EnvironmentDemo.swift @@ -18,17 +18,34 @@ #if canImport(SwiftUI) import SwiftUI #else +import OpenCombine import TokamakDOM #endif +class TestEnvironment: ObservableObject { + @Published var envTest = "Hello, world!" + init() {} +} + +struct EnvironmentObjectDemo: View { + @EnvironmentObject var testEnv: TestEnvironment + + var body: some View { + Button(testEnv.envTest) { + testEnv.envTest = "EnvironmentObject modified." + } + } +} + struct EnvironmentDemo: View { @Environment(\.font) var font: Font? + @EnvironmentObject var testEnv: TestEnvironment var body: some View { - if let font = font { - return Text("\(String(describing: font))") - } else { - return Text("`font` environment not set.") + VStack { + Text(font == nil ? "`font` environment not set." : "\(String(describing: font!))") + Text(testEnv.envTest) + EnvironmentObjectDemo() } } } diff --git a/Sources/TokamakDemo/TokamakDemo.swift b/Sources/TokamakDemo/TokamakDemo.swift index 0b034fa8b..04bd718d9 100644 --- a/Sources/TokamakDemo/TokamakDemo.swift +++ b/Sources/TokamakDemo/TokamakDemo.swift @@ -67,5 +67,6 @@ struct TokamakDemoView: View { } } } + .environmentObject(TestEnvironment()) } }