Skip to content

Commit 3495af0

Browse files
[fix] View environment customizations on hosting VCs now propagate to SwiftUI environment (#297)
* ModeledHostingController implementations now conform to ViewEnvironmentObserving * Make StateAccessor init public
1 parent b027b1d commit 3495af0

File tree

5 files changed

+177
-12
lines changed

5 files changed

+177
-12
lines changed

WorkflowSwiftUI/Sources/ObservableScreen.swift

+17-6
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public extension ObservableScreen {
5656
},
5757
update: { hostingController in
5858
hostingController.setModel(model)
59-
hostingController.setViewEnvironment(environment)
59+
// ViewEnvironment updates are handled by the ModeledHostingController internally
6060
}
6161
)
6262
}
@@ -89,9 +89,10 @@ private final class ViewEnvironmentHolder: ObservableObject {
8989
}
9090
}
9191

92-
private final class ModeledHostingController<Model, Content: View>: UIHostingController<ModifiedContent<Content, ViewEnvironmentModifier>> {
92+
private final class ModeledHostingController<Model, Content: View>: UIHostingController<ModifiedContent<Content, ViewEnvironmentModifier>>, ViewEnvironmentObserving {
9393
let setModel: (Model) -> Void
94-
let setViewEnvironment: (ViewEnvironment) -> Void
94+
95+
private let viewEnvironmentHolder: ViewEnvironmentHolder
9596

9697
var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions {
9798
didSet {
@@ -111,10 +112,8 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
111112
rootView: Content,
112113
sizingOptions swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions
113114
) {
114-
let viewEnvironmentHolder = ViewEnvironmentHolder(viewEnvironment: viewEnvironment)
115-
116115
self.setModel = setModel
117-
self.setViewEnvironment = { viewEnvironmentHolder.viewEnvironment = $0 }
116+
self.viewEnvironmentHolder = ViewEnvironmentHolder(viewEnvironment: viewEnvironment)
118117
self.swiftUIScreenSizingOptions = swiftUIScreenSizingOptions
119118

120119
super.init(
@@ -169,6 +168,12 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
169168
}
170169
}
171170

171+
override func viewWillLayoutSubviews() {
172+
super.viewWillLayoutSubviews()
173+
174+
applyEnvironmentIfNeeded()
175+
}
176+
172177
private func updateSizingOptionsIfNeeded() {
173178
if #available(iOS 16.0, *) {
174179
self.sizingOptions = swiftUIScreenSizingOptions.uiHostingControllerSizingOptions
@@ -190,6 +195,12 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
190195
view.setNeedsLayout()
191196
}
192197
}
198+
199+
// MARK: ViewEnvironmentObserving
200+
201+
func apply(environment: ViewEnvironment) {
202+
viewEnvironmentHolder.viewEnvironment = environment
203+
}
193204
}
194205

195206
fileprivate extension SwiftUIScreenSizingOptions {

WorkflowSwiftUI/Sources/StateAccessor.swift

+8
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
public struct StateAccessor<State: ObservableState> {
1313
let state: State
1414
let sendValue: (@escaping (inout State) -> Void) -> Void
15+
16+
public init(
17+
state: State,
18+
sendValue: @escaping (@escaping (inout State) -> Void) -> Void
19+
) {
20+
self.state = state
21+
self.sendValue = sendValue
22+
}
1523
}
1624

1725
extension StateAccessor: ObservableModel {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#if canImport(UIKit)
2+
3+
import SwiftUI
4+
import ViewEnvironment
5+
@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI
6+
import WorkflowSwiftUI
7+
import XCTest
8+
9+
final class ObservableScreenTests: XCTestCase {
10+
func test_viewEnvironmentObservation() {
11+
// Ensure that environment customizations made on the view controller
12+
// are propagated to the SwiftUI view environment.
13+
14+
var state = MyState()
15+
16+
let viewController = TestKeyEmittingScreen(
17+
model: MyModel(
18+
accessor: StateAccessor(
19+
state: state,
20+
sendValue: { $0(&state) }
21+
)
22+
)
23+
)
24+
.buildViewController(in: .empty)
25+
26+
let lifetime = viewController.addEnvironmentCustomization { environment in
27+
environment[TestKey.self] = 1
28+
}
29+
30+
viewController.view.layoutIfNeeded()
31+
32+
XCTAssertEqual(state.emittedValue, 1)
33+
34+
withExtendedLifetime(lifetime) {}
35+
}
36+
}
37+
38+
private struct TestKey: ViewEnvironmentKey {
39+
static var defaultValue: Int = 0
40+
}
41+
42+
@ObservableState
43+
private struct MyState {
44+
var emittedValue: TestKey.Value?
45+
}
46+
47+
private struct MyModel: ObservableModel {
48+
typealias State = MyState
49+
50+
let accessor: StateAccessor<State>
51+
}
52+
53+
private struct TestKeyEmittingScreen: ObservableScreen {
54+
typealias Model = MyModel
55+
56+
var model: Model
57+
58+
let sizingOptions: WorkflowSwiftUI.SwiftUIScreenSizingOptions = [.preferredContentSize]
59+
60+
static func makeView(store: Store<Model>) -> some View {
61+
ContentView(store: store)
62+
}
63+
64+
struct ContentView: View {
65+
@Environment(\.viewEnvironment)
66+
var viewEnvironment: ViewEnvironment
67+
68+
var store: Store<Model>
69+
70+
var body: some View {
71+
WithPerceptionTracking {
72+
let _ = { store.emittedValue = viewEnvironment[TestKey.self] }()
73+
Color.clear
74+
.frame(width: 1, height: 1)
75+
}
76+
}
77+
}
78+
}
79+
80+
#endif

WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift

+21-6
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ public extension SwiftUIScreen {
6565
},
6666
update: {
6767
$0.modelSink.send(self)
68-
$0.viewEnvironmentSink.send(environment)
6968
$0.swiftUIScreenSizingOptions = sizingOptions
69+
// ViewEnvironment updates are handled by the ModeledHostingController internally
7070
}
7171
)
7272
}
@@ -92,7 +92,7 @@ private struct EnvironmentInjectingView<Content: View>: View {
9292
}
9393
}
9494

95-
private final class ModeledHostingController<Model, Content: View>: UIHostingController<Content> {
95+
private final class ModeledHostingController<Model, Content: View>: UIHostingController<Content>, ViewEnvironmentObserving {
9696
let modelSink: Sink<Model>
9797
let viewEnvironmentSink: Sink<ViewEnvironment>
9898
var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions {
@@ -122,6 +122,7 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
122122
updateSizingOptionsIfNeeded()
123123
}
124124

125+
@available(*, unavailable)
125126
required init?(coder aDecoder: NSCoder) {
126127
fatalError("not implemented")
127128
}
@@ -148,7 +149,8 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
148149
// not updated appropriately after the first layout.
149150
// UI-5797
150151
if !hasLaidOutOnce,
151-
swiftUIScreenSizingOptions.contains(.preferredContentSize) {
152+
swiftUIScreenSizingOptions.contains(.preferredContentSize)
153+
{
152154
let size = view.sizeThatFits(view.frame.size)
153155

154156
if preferredContentSize != size {
@@ -164,13 +166,20 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
164166
}
165167
}
166168

169+
override func viewWillLayoutSubviews() {
170+
super.viewWillLayoutSubviews()
171+
172+
applyEnvironmentIfNeeded()
173+
}
174+
167175
private func updateSizingOptionsIfNeeded() {
168176
if #available(iOS 16.0, *) {
169177
self.sizingOptions = swiftUIScreenSizingOptions.uiHostingControllerSizingOptions
170178
}
171179

172180
if !swiftUIScreenSizingOptions.contains(.preferredContentSize),
173-
preferredContentSize != .zero {
181+
preferredContentSize != .zero
182+
{
174183
preferredContentSize = .zero
175184
}
176185
}
@@ -184,11 +193,17 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
184193
view.setNeedsLayout()
185194
}
186195
}
196+
197+
// MARK: ViewEnvironmentObserving
198+
199+
func apply(environment: ViewEnvironment) {
200+
viewEnvironmentSink.send(environment)
201+
}
187202
}
188203

189-
extension SwiftUIScreenSizingOptions {
204+
fileprivate extension SwiftUIScreenSizingOptions {
190205
@available(iOS 16.0, *)
191-
fileprivate var uiHostingControllerSizingOptions: UIHostingControllerSizingOptions {
206+
var uiHostingControllerSizingOptions: UIHostingControllerSizingOptions {
192207
var options = UIHostingControllerSizingOptions()
193208

194209
if contains(.preferredContentSize) {

WorkflowSwiftUIExperimental/Tests/SwiftUIScreenTests.swift

+51
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import SwiftUI
44
import UIKit
5+
import ViewEnvironment
6+
@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI
57
import WorkflowSwiftUIExperimental
68
import XCTest
79

@@ -54,6 +56,32 @@ final class SwiftUIScreenTests: XCTestCase {
5456

5557
XCTAssertEqual(viewController.preferredContentSize, .zero)
5658
}
59+
60+
func test_viewEnvironmentObservation() {
61+
// Ensure that environment customizations made on the view controller
62+
// are propagated to the SwiftUI view environment.
63+
64+
var emittedValue: Int?
65+
66+
let viewController = TestKeyEmittingScreen(onTestKeyEmission: { value in
67+
emittedValue = value
68+
})
69+
.buildViewController(in: .empty)
70+
71+
let lifetime = viewController.addEnvironmentCustomization { environment in
72+
environment[TestKey.self] = 1
73+
}
74+
75+
viewController.view.layoutIfNeeded()
76+
77+
XCTAssertEqual(emittedValue, 1)
78+
79+
withExtendedLifetime(lifetime) {}
80+
}
81+
}
82+
83+
private struct TestKey: ViewEnvironmentKey {
84+
static var defaultValue: Int = 0
5785
}
5886

5987
private struct ContentScreen: SwiftUIScreen {
@@ -65,4 +93,27 @@ private struct ContentScreen: SwiftUIScreen {
6593
}
6694
}
6795

96+
private struct TestKeyEmittingScreen: SwiftUIScreen {
97+
var onTestKeyEmission: (TestKey.Value) -> Void
98+
99+
let sizingOptions: SwiftUIScreenSizingOptions = [.preferredContentSize]
100+
101+
static func makeView(model: ObservableValue<Self>) -> some View {
102+
ContentView(onTestKeyEmission: model.onTestKeyEmission)
103+
}
104+
105+
struct ContentView: View {
106+
@Environment(\.viewEnvironment)
107+
var viewEnvironment: ViewEnvironment
108+
109+
var onTestKeyEmission: (TestKey.Value) -> Void
110+
111+
var body: some View {
112+
let _ = onTestKeyEmission(viewEnvironment[TestKey.self])
113+
Color.clear
114+
.frame(width: 1, height: 1)
115+
}
116+
}
117+
}
118+
68119
#endif

0 commit comments

Comments
 (0)