From 38e632027f5836b94b3eda20a817e6d8dcf776c0 Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Wed, 21 Aug 2024 20:18:20 -0500 Subject: [PATCH 1/2] ModeledHostingController implementations now conform to ViewEnvironmentObserving --- .../Sources/ObservableScreen.swift | 23 ++++-- .../Tests/ObservableScreenTests.swift | 80 +++++++++++++++++++ .../Sources/SwiftUIScreen.swift | 27 +++++-- .../Tests/SwiftUIScreenTests.swift | 51 ++++++++++++ 4 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 WorkflowSwiftUI/Tests/ObservableScreenTests.swift diff --git a/WorkflowSwiftUI/Sources/ObservableScreen.swift b/WorkflowSwiftUI/Sources/ObservableScreen.swift index 27819ef34..d5365b964 100644 --- a/WorkflowSwiftUI/Sources/ObservableScreen.swift +++ b/WorkflowSwiftUI/Sources/ObservableScreen.swift @@ -56,7 +56,7 @@ public extension ObservableScreen { }, update: { hostingController in hostingController.setModel(model) - hostingController.setViewEnvironment(environment) + // ViewEnvironment updates are handled by the ModeledHostingController internally } ) } @@ -89,9 +89,10 @@ private final class ViewEnvironmentHolder: ObservableObject { } } -private final class ModeledHostingController: UIHostingController> { +private final class ModeledHostingController: UIHostingController>, ViewEnvironmentObserving { let setModel: (Model) -> Void - let setViewEnvironment: (ViewEnvironment) -> Void + + private let viewEnvironmentHolder: ViewEnvironmentHolder var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions { didSet { @@ -111,10 +112,8 @@ private final class ModeledHostingController: UIHostingCon rootView: Content, sizingOptions swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions ) { - let viewEnvironmentHolder = ViewEnvironmentHolder(viewEnvironment: viewEnvironment) - self.setModel = setModel - self.setViewEnvironment = { viewEnvironmentHolder.viewEnvironment = $0 } + self.viewEnvironmentHolder = ViewEnvironmentHolder(viewEnvironment: viewEnvironment) self.swiftUIScreenSizingOptions = swiftUIScreenSizingOptions super.init( @@ -169,6 +168,12 @@ private final class ModeledHostingController: UIHostingCon } } + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + applyEnvironmentIfNeeded() + } + private func updateSizingOptionsIfNeeded() { if #available(iOS 16.0, *) { self.sizingOptions = swiftUIScreenSizingOptions.uiHostingControllerSizingOptions @@ -190,6 +195,12 @@ private final class ModeledHostingController: UIHostingCon view.setNeedsLayout() } } + + // MARK: ViewEnvironmentObserving + + func apply(environment: ViewEnvironment) { + viewEnvironmentHolder.viewEnvironment = environment + } } fileprivate extension SwiftUIScreenSizingOptions { diff --git a/WorkflowSwiftUI/Tests/ObservableScreenTests.swift b/WorkflowSwiftUI/Tests/ObservableScreenTests.swift new file mode 100644 index 000000000..fcbd2e85d --- /dev/null +++ b/WorkflowSwiftUI/Tests/ObservableScreenTests.swift @@ -0,0 +1,80 @@ +#if canImport(UIKit) + +import SwiftUI +import ViewEnvironment +@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI +import XCTest +@testable import WorkflowSwiftUI + +final class ObservableScreenTests: XCTestCase { + func test_viewEnvironmentObservation() { + // Ensure that environment customizations made on the view controller + // are propagated to the SwiftUI view environment. + + var state = MyState() + + let viewController = TestKeyEmittingScreen( + model: MyModel( + accessor: StateAccessor( + state: state, + sendValue: { $0(&state) } + ) + ) + ) + .buildViewController(in: .empty) + + let lifetime = viewController.addEnvironmentCustomization { environment in + environment[TestKey.self] = 1 + } + + viewController.view.layoutIfNeeded() + + XCTAssertEqual(state.emittedValue, 1) + + withExtendedLifetime(lifetime) {} + } +} + +private struct TestKey: ViewEnvironmentKey { + static var defaultValue: Int = 0 +} + +@ObservableState +private struct MyState { + var emittedValue: TestKey.Value? +} + +private struct MyModel: ObservableModel { + typealias State = MyState + + let accessor: StateAccessor +} + +private struct TestKeyEmittingScreen: ObservableScreen { + typealias Model = MyModel + + var model: Model + + let sizingOptions: WorkflowSwiftUI.SwiftUIScreenSizingOptions = [.preferredContentSize] + + static func makeView(store: Store) -> some View { + ContentView(store: store) + } + + struct ContentView: View { + @Environment(\.viewEnvironment) + var viewEnvironment: ViewEnvironment + + var store: Store + + var body: some View { + WithPerceptionTracking { + let _ = { store.emittedValue = viewEnvironment[TestKey.self] }() + Color.clear + .frame(width: 1, height: 1) + } + } + } +} + +#endif diff --git a/WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift b/WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift index 525aa8ca8..2b915b1e7 100644 --- a/WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift +++ b/WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift @@ -65,8 +65,8 @@ public extension SwiftUIScreen { }, update: { $0.modelSink.send(self) - $0.viewEnvironmentSink.send(environment) $0.swiftUIScreenSizingOptions = sizingOptions + // ViewEnvironment updates are handled by the ModeledHostingController internally } ) } @@ -92,7 +92,7 @@ private struct EnvironmentInjectingView: View { } } -private final class ModeledHostingController: UIHostingController { +private final class ModeledHostingController: UIHostingController, ViewEnvironmentObserving { let modelSink: Sink let viewEnvironmentSink: Sink var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions { @@ -122,6 +122,7 @@ private final class ModeledHostingController: UIHostingCon updateSizingOptionsIfNeeded() } + @available(*, unavailable) required init?(coder aDecoder: NSCoder) { fatalError("not implemented") } @@ -148,7 +149,8 @@ private final class ModeledHostingController: UIHostingCon // not updated appropriately after the first layout. // UI-5797 if !hasLaidOutOnce, - swiftUIScreenSizingOptions.contains(.preferredContentSize) { + swiftUIScreenSizingOptions.contains(.preferredContentSize) + { let size = view.sizeThatFits(view.frame.size) if preferredContentSize != size { @@ -164,13 +166,20 @@ private final class ModeledHostingController: UIHostingCon } } + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + applyEnvironmentIfNeeded() + } + private func updateSizingOptionsIfNeeded() { if #available(iOS 16.0, *) { self.sizingOptions = swiftUIScreenSizingOptions.uiHostingControllerSizingOptions } if !swiftUIScreenSizingOptions.contains(.preferredContentSize), - preferredContentSize != .zero { + preferredContentSize != .zero + { preferredContentSize = .zero } } @@ -184,11 +193,17 @@ private final class ModeledHostingController: UIHostingCon view.setNeedsLayout() } } + + // MARK: ViewEnvironmentObserving + + func apply(environment: ViewEnvironment) { + viewEnvironmentSink.send(environment) + } } -extension SwiftUIScreenSizingOptions { +fileprivate extension SwiftUIScreenSizingOptions { @available(iOS 16.0, *) - fileprivate var uiHostingControllerSizingOptions: UIHostingControllerSizingOptions { + var uiHostingControllerSizingOptions: UIHostingControllerSizingOptions { var options = UIHostingControllerSizingOptions() if contains(.preferredContentSize) { diff --git a/WorkflowSwiftUIExperimental/Tests/SwiftUIScreenTests.swift b/WorkflowSwiftUIExperimental/Tests/SwiftUIScreenTests.swift index e72d00bd6..a9a5ec024 100644 --- a/WorkflowSwiftUIExperimental/Tests/SwiftUIScreenTests.swift +++ b/WorkflowSwiftUIExperimental/Tests/SwiftUIScreenTests.swift @@ -2,6 +2,8 @@ import SwiftUI import UIKit +import ViewEnvironment +@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI import WorkflowSwiftUIExperimental import XCTest @@ -54,6 +56,32 @@ final class SwiftUIScreenTests: XCTestCase { XCTAssertEqual(viewController.preferredContentSize, .zero) } + + func test_viewEnvironmentObservation() { + // Ensure that environment customizations made on the view controller + // are propagated to the SwiftUI view environment. + + var emittedValue: Int? + + let viewController = TestKeyEmittingScreen(onTestKeyEmission: { value in + emittedValue = value + }) + .buildViewController(in: .empty) + + let lifetime = viewController.addEnvironmentCustomization { environment in + environment[TestKey.self] = 1 + } + + viewController.view.layoutIfNeeded() + + XCTAssertEqual(emittedValue, 1) + + withExtendedLifetime(lifetime) {} + } +} + +private struct TestKey: ViewEnvironmentKey { + static var defaultValue: Int = 0 } private struct ContentScreen: SwiftUIScreen { @@ -65,4 +93,27 @@ private struct ContentScreen: SwiftUIScreen { } } +private struct TestKeyEmittingScreen: SwiftUIScreen { + var onTestKeyEmission: (TestKey.Value) -> Void + + let sizingOptions: SwiftUIScreenSizingOptions = [.preferredContentSize] + + static func makeView(model: ObservableValue) -> some View { + ContentView(onTestKeyEmission: model.onTestKeyEmission) + } + + struct ContentView: View { + @Environment(\.viewEnvironment) + var viewEnvironment: ViewEnvironment + + var onTestKeyEmission: (TestKey.Value) -> Void + + var body: some View { + let _ = onTestKeyEmission(viewEnvironment[TestKey.self]) + Color.clear + .frame(width: 1, height: 1) + } + } +} + #endif From 46961b0924ae027cd40aa1a8197fc86abab9fe56 Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Thu, 22 Aug 2024 17:11:43 -0500 Subject: [PATCH 2/2] Make StateAccessor init public --- WorkflowSwiftUI/Sources/StateAccessor.swift | 8 ++++++++ WorkflowSwiftUI/Tests/ObservableScreenTests.swift | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/WorkflowSwiftUI/Sources/StateAccessor.swift b/WorkflowSwiftUI/Sources/StateAccessor.swift index 69bdb0de0..24278483c 100644 --- a/WorkflowSwiftUI/Sources/StateAccessor.swift +++ b/WorkflowSwiftUI/Sources/StateAccessor.swift @@ -12,6 +12,14 @@ public struct StateAccessor { let state: State let sendValue: (@escaping (inout State) -> Void) -> Void + + public init( + state: State, + sendValue: @escaping (@escaping (inout State) -> Void) -> Void + ) { + self.state = state + self.sendValue = sendValue + } } extension StateAccessor: ObservableModel { diff --git a/WorkflowSwiftUI/Tests/ObservableScreenTests.swift b/WorkflowSwiftUI/Tests/ObservableScreenTests.swift index fcbd2e85d..4bbe5970f 100644 --- a/WorkflowSwiftUI/Tests/ObservableScreenTests.swift +++ b/WorkflowSwiftUI/Tests/ObservableScreenTests.swift @@ -3,8 +3,8 @@ import SwiftUI import ViewEnvironment @_spi(ViewEnvironmentWiring) import ViewEnvironmentUI +import WorkflowSwiftUI import XCTest -@testable import WorkflowSwiftUI final class ObservableScreenTests: XCTestCase { func test_viewEnvironmentObservation() {