diff --git a/Sources/TokamakCore/MountedViews/MountedCompositeElement.swift b/Sources/TokamakCore/MountedViews/MountedCompositeElement.swift index 00b515611..907258e63 100644 --- a/Sources/TokamakCore/MountedViews/MountedCompositeElement.swift +++ b/Sources/TokamakCore/MountedViews/MountedCompositeElement.swift @@ -20,10 +20,10 @@ import CombineShim class MountedCompositeElement: MountedElement { let parentTarget: R.TargetType - /** An array that stores type-erased values captured with the `@State` property wrappers used - in declarations of this element. + /** An array that stores type-erased values captured with the `@State` and `@StateObject` property + wrappers used in declarations of this element. */ - var state = [Any]() + var storage = [Any]() /** An array that stores subscriptions to updates on `@ObservableObject` property wrappers used in declarations of this element. These subscriptions are transient and may be cleaned up on diff --git a/Sources/TokamakCore/StackReconciler.swift b/Sources/TokamakCore/StackReconciler.swift index 65c0751d9..690892402 100644 --- a/Sources/TokamakCore/StackReconciler.swift +++ b/Sources/TokamakCore/StackReconciler.swift @@ -99,12 +99,12 @@ public final class StackReconciler { } } - private func queueStateUpdate( + private func queueStorageUpdate( for mountedElement: MountedCompositeElement, id: Int, updater: (inout Any) -> () ) { - updater(&mountedElement.state[id]) + updater(&mountedElement.storage[id]) queueUpdate(for: mountedElement) } @@ -125,7 +125,7 @@ public final class StackReconciler { queuedRerenders.removeAll() } - private func setupState( + private func setupStorage( id: Int, for property: PropertyInfo, of compositeElement: MountedCompositeElement, @@ -134,23 +134,28 @@ public final class StackReconciler { // swiftlint:disable force_try // `ValueStorage` property already filtered out, so safe to assume the value's type // swiftlint:disable:next force_cast - var state = try! property.get(from: compositeElement[keyPath: bodyKeypath]) as! ValueStorage + var storage = try! property.get(from: compositeElement[keyPath: bodyKeypath]) as! ValueStorage - if compositeElement.state.count == id { - compositeElement.state.append(state.anyInitialValue) + if compositeElement.storage.count == id { + compositeElement.storage.append(storage.anyInitialValue) } - if state.getter == nil || state.setter == nil { - state.getter = { compositeElement.state[id] } + if storage.getter == nil { + storage.getter = { compositeElement.storage[id] } + + guard var writableStorage = storage as? WritableValueStorage else { + return try! property.set(value: storage, on: &compositeElement[keyPath: bodyKeypath]) + } // Avoiding an indirect reference cycle here: this closure can be owned by callbacks // owned by view's target, which is strongly referenced by the reconciler. - state.setter = { [weak self, weak compositeElement] newValue in + writableStorage.setter = { [weak self, weak compositeElement] newValue in guard let element = compositeElement else { return } - self?.queueStateUpdate(for: element, id: id) { $0 = newValue } + self?.queueStorageUpdate(for: element, id: id) { $0 = newValue } } + + try! property.set(value: writableStorage, on: &compositeElement[keyPath: bodyKeypath]) } - try! property.set(value: state, on: &compositeElement[keyPath: bodyKeypath]) } private func setupTransientSubscription( @@ -207,9 +212,10 @@ public final class StackReconciler { for property in dynamicProps { // Setup state/subscriptions if property.type is ValueStorage.Type { - setupState(id: stateIdx, for: property, of: compositeElement, body: bodyKeypath) + setupStorage(id: stateIdx, for: property, of: compositeElement, body: bodyKeypath) stateIdx += 1 - } else if property.type is ObservedProperty.Type { + } + if property.type is ObservedProperty.Type { setupTransientSubscription(for: property, of: compositeElement, body: bodyKeypath) } } diff --git a/Sources/TokamakCore/State/ObservedObject.swift b/Sources/TokamakCore/State/ObservedObject.swift index 4cdb2e3dc..c1a56f2cb 100644 --- a/Sources/TokamakCore/State/ObservedObject.swift +++ b/Sources/TokamakCore/State/ObservedObject.swift @@ -47,10 +47,10 @@ public struct ObservedObject: DynamicProperty where ObjectType: Obse } public let projectedValue: Wrapper +} +extension ObservedObject: ObservedProperty { var objectWillChange: AnyPublisher<(), Never> { wrappedValue.objectWillChange.map { _ in }.eraseToAnyPublisher() } } - -extension ObservedObject: ObservedProperty {} diff --git a/Sources/TokamakCore/State/State.swift b/Sources/TokamakCore/State/State.swift index 5e7bc2c35..edb5d9320 100644 --- a/Sources/TokamakCore/State/State.swift +++ b/Sources/TokamakCore/State/State.swift @@ -16,10 +16,13 @@ // protocol ValueStorage { var getter: (() -> Any)? { get set } - var setter: ((Any) -> ())? { get set } var anyInitialValue: Any { get } } +protocol WritableValueStorage: ValueStorage { + var setter: ((Any) -> ())? { get set } +} + @propertyWrapper public struct State: DynamicProperty { private let initialValue: Value @@ -46,7 +49,7 @@ protocol ValueStorage { } } -extension State: ValueStorage {} +extension State: WritableValueStorage {} extension State where Value: ExpressibleByNilLiteral { @inlinable diff --git a/Sources/TokamakCore/State/StateObject.swift b/Sources/TokamakCore/State/StateObject.swift index 07245557f..51aef0a46 100644 --- a/Sources/TokamakCore/State/StateObject.swift +++ b/Sources/TokamakCore/State/StateObject.swift @@ -12,4 +12,32 @@ // See the License for the specific language governing permissions and // limitations under the License. -public typealias StateObject = ObservedObject +import CombineShim + +@propertyWrapper +public struct StateObject: DynamicProperty { + public var wrappedValue: ObjectType { (getter?() as? ObservedObject.Wrapper)?.root ?? initial() } + + let initial: () -> ObjectType + var getter: (() -> Any)? + + public init(wrappedValue initial: @autoclosure @escaping () -> ObjectType) { + self.initial = initial + } + + public var projectedValue: ObservedObject.Wrapper { + getter?() as? ObservedObject.Wrapper ?? ObservedObject.Wrapper(root: initial()) + } +} + +extension StateObject: ObservedProperty { + var objectWillChange: AnyPublisher<(), Never> { + wrappedValue.objectWillChange.map { _ in }.eraseToAnyPublisher() + } +} + +extension StateObject: ValueStorage { + var anyInitialValue: Any { + ObservedObject.Wrapper(root: initial()) + } +} diff --git a/Sources/TokamakCore/Views/Navigation/NavigationView.swift b/Sources/TokamakCore/Views/Navigation/NavigationView.swift index dd96d4cae..43a2188be 100644 --- a/Sources/TokamakCore/Views/Navigation/NavigationView.swift +++ b/Sources/TokamakCore/Views/Navigation/NavigationView.swift @@ -22,7 +22,7 @@ final class NavigationContext: ObservableObject { public struct NavigationView: View where Content: View { let content: Content - @ObservedObject var context = NavigationContext() + @StateObject var context = NavigationContext() public init(@ViewBuilder content: () -> Content) { self.content = content() diff --git a/Sources/TokamakDOM/Core.swift b/Sources/TokamakDOM/Core.swift index 733eb1323..53a54781d 100644 --- a/Sources/TokamakDOM/Core.swift +++ b/Sources/TokamakDOM/Core.swift @@ -27,6 +27,7 @@ public typealias ObservableObject = TokamakCore.ObservableObject public typealias ObservedObject = TokamakCore.ObservedObject public typealias Published = TokamakCore.Published public typealias State = TokamakCore.State +public typealias StateObject = TokamakCore.StateObject // MARK: Modifiers & Styles