Skip to content

Commit

Permalink
[fix] View environment customizations on hosting VCs now propagate to…
Browse files Browse the repository at this point in the history
… SwiftUI environment (#297)

* ModeledHostingController implementations now conform to ViewEnvironmentObserving

* Make StateAccessor init public
  • Loading branch information
robmaceachern authored Aug 22, 2024
1 parent b027b1d commit 3495af0
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 12 deletions.
23 changes: 17 additions & 6 deletions WorkflowSwiftUI/Sources/ObservableScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public extension ObservableScreen {
},
update: { hostingController in
hostingController.setModel(model)
hostingController.setViewEnvironment(environment)
// ViewEnvironment updates are handled by the ModeledHostingController internally
}
)
}
Expand Down Expand Up @@ -89,9 +89,10 @@ private final class ViewEnvironmentHolder: ObservableObject {
}
}

private final class ModeledHostingController<Model, Content: View>: UIHostingController<ModifiedContent<Content, ViewEnvironmentModifier>> {
private final class ModeledHostingController<Model, Content: View>: UIHostingController<ModifiedContent<Content, ViewEnvironmentModifier>>, ViewEnvironmentObserving {
let setModel: (Model) -> Void
let setViewEnvironment: (ViewEnvironment) -> Void

private let viewEnvironmentHolder: ViewEnvironmentHolder

var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions {
didSet {
Expand All @@ -111,10 +112,8 @@ private final class ModeledHostingController<Model, Content: View>: 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(
Expand Down Expand Up @@ -169,6 +168,12 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
}
}

override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()

applyEnvironmentIfNeeded()
}

private func updateSizingOptionsIfNeeded() {
if #available(iOS 16.0, *) {
self.sizingOptions = swiftUIScreenSizingOptions.uiHostingControllerSizingOptions
Expand All @@ -190,6 +195,12 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
view.setNeedsLayout()
}
}

// MARK: ViewEnvironmentObserving

func apply(environment: ViewEnvironment) {
viewEnvironmentHolder.viewEnvironment = environment
}
}

fileprivate extension SwiftUIScreenSizingOptions {
Expand Down
8 changes: 8 additions & 0 deletions WorkflowSwiftUI/Sources/StateAccessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
public struct StateAccessor<State: ObservableState> {
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 {
Expand Down
80 changes: 80 additions & 0 deletions WorkflowSwiftUI/Tests/ObservableScreenTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#if canImport(UIKit)

import SwiftUI
import ViewEnvironment
@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI
import WorkflowSwiftUI
import XCTest

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<State>
}

private struct TestKeyEmittingScreen: ObservableScreen {
typealias Model = MyModel

var model: Model

let sizingOptions: WorkflowSwiftUI.SwiftUIScreenSizingOptions = [.preferredContentSize]

static func makeView(store: Store<Model>) -> some View {
ContentView(store: store)
}

struct ContentView: View {
@Environment(\.viewEnvironment)
var viewEnvironment: ViewEnvironment

var store: Store<Model>

var body: some View {
WithPerceptionTracking {
let _ = { store.emittedValue = viewEnvironment[TestKey.self] }()
Color.clear
.frame(width: 1, height: 1)
}
}
}
}

#endif
27 changes: 21 additions & 6 deletions WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
)
}
Expand All @@ -92,7 +92,7 @@ private struct EnvironmentInjectingView<Content: View>: View {
}
}

private final class ModeledHostingController<Model, Content: View>: UIHostingController<Content> {
private final class ModeledHostingController<Model, Content: View>: UIHostingController<Content>, ViewEnvironmentObserving {
let modelSink: Sink<Model>
let viewEnvironmentSink: Sink<ViewEnvironment>
var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions {
Expand Down Expand Up @@ -122,6 +122,7 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
updateSizingOptionsIfNeeded()
}

@available(*, unavailable)
required init?(coder aDecoder: NSCoder) {
fatalError("not implemented")
}
Expand All @@ -148,7 +149,8 @@ private final class ModeledHostingController<Model, Content: View>: 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 {
Expand All @@ -164,13 +166,20 @@ private final class ModeledHostingController<Model, Content: View>: 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
}
}
Expand All @@ -184,11 +193,17 @@ private final class ModeledHostingController<Model, Content: View>: 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) {
Expand Down
51 changes: 51 additions & 0 deletions WorkflowSwiftUIExperimental/Tests/SwiftUIScreenTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import SwiftUI
import UIKit
import ViewEnvironment
@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI
import WorkflowSwiftUIExperimental
import XCTest

Expand Down Expand Up @@ -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 {
Expand All @@ -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<Self>) -> 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

0 comments on commit 3495af0

Please sign in to comment.