From cd606f635ab95923ba1257d1553553fff79d0af6 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Mon, 12 May 2025 18:23:18 -0700 Subject: [PATCH] feat: remove deprecated SwiftUI types --- .github/CODEOWNERS | 2 +- Package.swift | 17 - Samples/Project.swift | 7 - Samples/Tuist/Package.swift | 1 - Samples/Workspace.swift | 1 - WorkflowSwiftUI/Sources/WorkflowView.swift | 295 ------------------ WorkflowSwiftUIExperimental/README.md | 3 - .../Sources/ObservableValue+Binding.swift | 48 --- .../Sources/ObservableValue.swift | 107 ------- .../Sources/SwiftUIScreen.swift | 211 ------------- .../Sources/WithModel.swift | 34 -- .../Tests/SwiftUIScreenTests.swift | 127 -------- 12 files changed, 1 insertion(+), 852 deletions(-) delete mode 100644 WorkflowSwiftUI/Sources/WorkflowView.swift delete mode 100644 WorkflowSwiftUIExperimental/README.md delete mode 100644 WorkflowSwiftUIExperimental/Sources/ObservableValue+Binding.swift delete mode 100644 WorkflowSwiftUIExperimental/Sources/ObservableValue.swift delete mode 100644 WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift delete mode 100644 WorkflowSwiftUIExperimental/Sources/WithModel.swift delete mode 100644 WorkflowSwiftUIExperimental/Tests/SwiftUIScreenTests.swift diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 983bfb7d1..97bc5b917 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,7 +2,7 @@ * @square/foundation-ios -# WorkflowUI, WorkflowSwiftUI, WorkflowSwiftUIExperimental +# WorkflowUI, WorkflowSwiftUI /Workflow*UI*/ @square/foundation-ios @square/ui-systems-ios # ViewEnvironment, ViewEnvironmentUI diff --git a/Package.swift b/Package.swift index 67d698b22..54479c1c4 100644 --- a/Package.swift +++ b/Package.swift @@ -51,10 +51,6 @@ let package = Package( // MARK: ViewEnvironmentUI .singleTargetLibrary("ViewEnvironmentUI"), - - // MARK: WorkflowSwiftUIExperimental - - .singleTargetLibrary("WorkflowSwiftUIExperimental"), ], dependencies: [ .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift.git", from: "7.1.1"), @@ -253,19 +249,6 @@ let package = Package( dependencies: ["ViewEnvironment"], path: "ViewEnvironmentUI/Sources" ), - - // MARK: WorkflowSwiftUIExperimental - - .target( - name: "WorkflowSwiftUIExperimental", - dependencies: ["Workflow", "WorkflowUI"], - path: "WorkflowSwiftUIExperimental/Sources" - ), - .testTarget( - name: "WorkflowSwiftUIExperimentalTests", - dependencies: ["WorkflowSwiftUIExperimental", "Workflow", "WorkflowUI"], - path: "WorkflowSwiftUIExperimental/Tests" - ), ], swiftLanguageVersions: [.v5] ) diff --git a/Samples/Project.swift b/Samples/Project.swift index 84848b268..f7fdf9fb4 100644 --- a/Samples/Project.swift +++ b/Samples/Project.swift @@ -230,12 +230,6 @@ let project = Project( dependencies: [.external(name: "WorkflowSwiftUI")] ), - .unitTest( - for: "WorkflowSwiftUIExperimental", - sources: "../WorkflowSwiftUIExperimental/Tests/**", - dependencies: [.external(name: "WorkflowSwiftUIExperimental")] - ), - // It's not currently possible to create a Tuist target that depends on a macro target. See // https://github.com/tuist/tuist/issues/5827, https://github.com/tuist/tuist/issues/6651, // and similar issues. @@ -274,7 +268,6 @@ let project = Project( "WorkflowRxSwift-Tests", "WorkflowRxSwiftTesting-Tests", "WorkflowSwiftUI-Tests", - "WorkflowSwiftUIExperimental-Tests", "WorkflowTesting-Tests", "WorkflowUI-Tests", ] diff --git a/Samples/Tuist/Package.swift b/Samples/Tuist/Package.swift index 3f7675fd4..145d8b137 100644 --- a/Samples/Tuist/Package.swift +++ b/Samples/Tuist/Package.swift @@ -33,7 +33,6 @@ let packageSettings = PackageSettings( "WorkflowReactiveSwiftTesting": unsuppressedWarningsSettings, "WorkflowRxSwift": unsuppressedWarningsSettings, "WorkflowRxSwiftTesting": unsuppressedWarningsSettings, - "WorkflowSwiftUIExperimental": unsuppressedWarningsSettings, "WorkflowTesting": unsuppressedWarningsSettings, "WorkflowUI": unsuppressedWarningsSettings, ] diff --git a/Samples/Workspace.swift b/Samples/Workspace.swift index 1d3fc183a..cce1d7566 100644 --- a/Samples/Workspace.swift +++ b/Samples/Workspace.swift @@ -21,7 +21,6 @@ let workspace = Workspace( .workflow("WorkflowConcurrencyTesting"), .workflow("ViewEnvironment"), .workflow("ViewEnvironmentUI"), - .workflow("WorkflowSwiftUIExperimental"), .scheme( name: "Documentation", buildAction: .buildAction( diff --git a/WorkflowSwiftUI/Sources/WorkflowView.swift b/WorkflowSwiftUI/Sources/WorkflowView.swift deleted file mode 100644 index 897844b33..000000000 --- a/WorkflowSwiftUI/Sources/WorkflowView.swift +++ /dev/null @@ -1,295 +0,0 @@ -/* - * Copyright 2020 Square Inc. - * - * 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. - */ - -#if canImport(SwiftUI) && canImport(Combine) - -import Combine -import ReactiveSwift -import SwiftUI -import Workflow - -/// Hosts a Workflow-powered view hierarchy. -/// -/// Example: -/// -/// ``` -/// var body: some View { -/// WorkflowView(workflow: MyWorkflow(), onOutput: { self.handleOutput($0) }) { rendering in -/// VStack { -/// -/// Text("The value is \(rendering.value)") -/// -/// Button(action: rendering.onIncrement) { -/// Text("+") -/// } -/// -/// Button(action: rendering.onDecrement) { -/// Text("-") -/// } -/// -/// } -/// } -/// } -/// ``` -@available(*, deprecated, message: "Use ObservableScreen to render SwiftUI content") -public struct WorkflowView: View { - /// The workflow implementation to use - public var workflow: T - - /// A handler for any output events emitted by the workflow - public var onOutput: (T.Output) -> Void - - /// A closure that maps the workflow's rendering type into a view of type `Content`. - public var content: (T.Rendering) -> Content - - public init(workflow: T, onOutput: @escaping (T.Output) -> Void, content: @escaping (T.Rendering) -> Content) { - self.onOutput = onOutput - self.content = content - self.workflow = workflow - } - - public var body: some View { - IntermediateView( - workflow: workflow, - onOutput: onOutput, - content: content - ) - } -} - -@available(*, deprecated, message: "Use ObservableScreen to render SwiftUI content") -extension WorkflowView where T.Output == Never { - /// Convenience initializer for workflows with no output. - public init(workflow: T, content: @escaping (T.Rendering) -> Content) { - self.init(workflow: workflow, onOutput: { _ in }, content: content) - } -} - -@available(*, deprecated, message: "Use ObservableScreen to render SwiftUI content") -extension WorkflowView where T.Rendering == Content { - /// Convenience initializer for workflows whose rendering type conforms to `View`. - public init(workflow: T, onOutput: @escaping (T.Output) -> Void) { - self.init(workflow: workflow, onOutput: onOutput, content: { $0 }) - } -} - -@available(*, deprecated, message: "Use ObservableScreen to render SwiftUI content") -extension WorkflowView where T.Output == Never, T.Rendering == Content { - /// Convenience initializer for workflows with no output whose rendering type conforms to `View`. - public init(workflow: T) { - self.init(workflow: workflow, onOutput: { _ in }, content: { $0 }) - } -} - -// We use a `UIViewController/UIViewControllerRepresentable` here to drop back to UIKit because it gives us a predictable -// update mechanism via `updateUIViewController(_:context:)`. If we were to manage a `WorkflowHost` instance directly -// within a SwiftUI view we would need to update the host with the updated workflow from our implementation of `body`. -// Performing work within the body accessor is strongly discouraged, so we jump back into UIKit for a second here. -fileprivate struct IntermediateView { - var workflow: T - var onOutput: (T.Output) -> Void - var content: (T.Rendering) -> Content -} - -#if canImport(UIKit) - -import UIKit - -extension IntermediateView: UIViewControllerRepresentable { - func makeUIViewController(context: UIViewControllerRepresentableContext>) -> WorkflowHostingViewController { - WorkflowHostingViewController(workflow: workflow, content: content) - } - - func updateUIViewController(_ uiViewController: WorkflowHostingViewController, context: UIViewControllerRepresentableContext>) { - uiViewController.content = content - uiViewController.onOutput = onOutput - uiViewController.update(to: workflow) - } -} - -fileprivate final class WorkflowHostingViewController: UIViewController { - private let workflowHost: WorkflowHost - private let hostingController: UIHostingController> - private let rootViewProvider: RootViewProvider - - var content: (T.Rendering) -> Content - var onOutput: (T.Output) -> Void - - private let (lifetime, token) = Lifetime.make() - - init(workflow: T, content: @escaping (T.Rendering) -> Content) { - self.content = content - self.onOutput = { _ in } - - self.workflowHost = WorkflowHost(workflow: workflow) - self.rootViewProvider = RootViewProvider(view: content(workflowHost.rendering.value)) - self.hostingController = UIHostingController(rootView: RootView(provider: rootViewProvider)) - - super.init(nibName: nil, bundle: nil) - - addChild(hostingController) - view.addSubview(hostingController.view) - hostingController.didMove(toParent: self) - - workflowHost - .rendering - .signal - .take(during: lifetime) - .observeValues { [weak self] rendering in - self?.didEmit(rendering: rendering) - } - - workflowHost - .output - .take(during: lifetime) - .observeValues { [weak self] output in - self?.didEmit(output: output) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - hostingController.view.frame = view.bounds - } - - private func didEmit(rendering: T.Rendering) { - rootViewProvider.view = content(rendering) - } - - private func didEmit(output: T.Output) { - onOutput(output) - } - - func update(to workflow: T) { - workflowHost.update(workflow: workflow) - } -} - -#elseif canImport(AppKit) - -import AppKit - -@available(OSX 10.15, *) -extension IntermediateView: NSViewControllerRepresentable { - func makeNSViewController(context: NSViewControllerRepresentableContext>) -> WorkflowHostingViewController { - WorkflowHostingViewController(workflow: workflow, content: content) - } - - func updateNSViewController(_ nsViewController: WorkflowHostingViewController, context: NSViewControllerRepresentableContext>) { - nsViewController.content = content - nsViewController.onOutput = onOutput - nsViewController.update(to: workflow) - } -} - -@available(macOS 10.15, *) -fileprivate final class WorkflowHostingViewController: NSViewController { - private let workflowHost: WorkflowHost - private let hostingController: NSHostingController> - private let rootViewProvider: RootViewProvider - - var content: (T.Rendering) -> Content - var onOutput: (T.Output) -> Void - - private let (lifetime, token) = Lifetime.make() - - init(workflow: T, content: @escaping (T.Rendering) -> Content) { - self.content = content - self.onOutput = { _ in } - - self.workflowHost = WorkflowHost(workflow: workflow) - self.rootViewProvider = RootViewProvider(view: content(workflowHost.rendering.value)) - self.hostingController = NSHostingController(rootView: RootView(provider: rootViewProvider)) - - super.init(nibName: nil, bundle: nil) - - addChild(hostingController) - - workflowHost - .rendering - .signal - .take(during: lifetime) - .observeValues { [weak self] rendering in - self?.didEmit(rendering: rendering) - } - - workflowHost - .output - .take(during: lifetime) - .observeValues { [weak self] output in - self?.didEmit(output: output) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func loadView() { - view = NSView() - } - - override func viewDidLoad() { - super.viewDidLoad() - view.addSubview(hostingController.view) - } - - override func viewDidLayout() { - super.viewDidLayout() - hostingController.view.frame = view.bounds - } - - private func didEmit(rendering: T.Rendering) { - rootViewProvider.view = content(rendering) - } - - private func didEmit(output: T.Output) { - onOutput(output) - } - - func update(to workflow: T) { - workflowHost.update(workflow: workflow) - } -} - -#endif - -// Assigning `rootView` on a `UIHostingController` causes unwanted animated transitions. -// To avoid this, we never change the root view, but we pass down an `ObservableObject` -// so that we can still update the hierarchy as the workflow emits new renderings. -fileprivate final class RootViewProvider: ObservableObject { - @Published var view: T - - init(view: T) { - self.view = view - } -} - -fileprivate struct RootView: View { - @ObservedObject var provider: RootViewProvider - - var body: some View { - provider.view - } -} - -#endif diff --git a/WorkflowSwiftUIExperimental/README.md b/WorkflowSwiftUIExperimental/README.md deleted file mode 100644 index 30864c0db..000000000 --- a/WorkflowSwiftUIExperimental/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# WorkflowSwiftUIExperimental - -Experimental extensions to Workflow for writing Screens in SwiftUI. diff --git a/WorkflowSwiftUIExperimental/Sources/ObservableValue+Binding.swift b/WorkflowSwiftUIExperimental/Sources/ObservableValue+Binding.swift deleted file mode 100644 index d1d035b56..000000000 --- a/WorkflowSwiftUIExperimental/Sources/ObservableValue+Binding.swift +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2023 Square Inc. - * - * 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. - */ - -#if canImport(UIKit) - -import SwiftUI - -extension ObservableValue { - public func binding( - get: @escaping (Value) -> T, - set: @escaping (Value) -> (T) -> Void - ) -> Binding { - // This convoluted way of creating a `Binding`, relative to `Binding.init(get:set:)`, is - // a workaround borrowed from TCA for a SwiftUI issue: - // https://github.com/pointfreeco/swift-composable-architecture/pull/770 - ObservedObject(wrappedValue: self) - .projectedValue[get: .init(rawValue: get), set: .init(rawValue: set)] - } - - private subscript( - get get: HashableWrapper<(Value) -> T>, - set set: HashableWrapper<(Value) -> (T) -> Void> - ) -> T { - get { get.rawValue(value) } - set { set.rawValue(value)(newValue) } - } - - private struct HashableWrapper: Hashable { - let rawValue: WrappedValue - static func == (lhs: Self, rhs: Self) -> Bool { false } - func hash(into hasher: inout Hasher) {} - } -} - -#endif diff --git a/WorkflowSwiftUIExperimental/Sources/ObservableValue.swift b/WorkflowSwiftUIExperimental/Sources/ObservableValue.swift deleted file mode 100644 index 02274d9d8..000000000 --- a/WorkflowSwiftUIExperimental/Sources/ObservableValue.swift +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2023 Square Inc. - * - * 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. - */ - -import Combine -import Workflow - -@dynamicMemberLookup -public final class ObservableValue: ObservableObject { - private var internalValue: Value - private let subject = PassthroughSubject() - private var cancellable: AnyCancellable? - private let isEquivalent: ((Value, Value) -> Bool)? - - public private(set) var value: Value { - get { - internalValue - } - set { - subject.send(newValue) - } - } - - public private(set) lazy var objectWillChange = ObservableObjectPublisher() - private var parentCancellable: AnyCancellable? - - public static func makeObservableValue( - _ value: Value, - isEquivalent: ((Value, Value) -> Bool)? = nil - ) -> (ObservableValue, Sink) { - let observableValue = ObservableValue(value: value, isEquivalent: isEquivalent) - let sink = Sink { newValue in - observableValue.value = newValue - } - - return (observableValue, sink) - } - - private init(value: Value, isEquivalent: ((Value, Value) -> Bool)?) { - self.internalValue = value - self.isEquivalent = isEquivalent - self.cancellable = valuePublisher() - .dropFirst() - .sink { [weak self] newValue in - guard let self else { return } - self.objectWillChange.send() - self.internalValue = newValue - } - // Allows removeDuplicates operator to have the initial value. - subject.send(value) - } - - //// Scopes the ObservableValue to a subset of Value to LocalValue given the supplied closure while allowing to optionally remove duplicates. - /// - Parameters: - /// - toLocalValue: A closure that takes a Value and returns a LocalValue. - /// - isEquivalent: An optional closure that checks to see if a LocalValue is equivalent. - /// - Returns: a scoped ObservableValue of LocalValue. - public func scope(_ toLocalValue: @escaping (Value) -> LocalValue, isEquivalent: ((LocalValue, LocalValue) -> Bool)? = nil) -> ObservableValue { - scopeToLocalValue(toLocalValue, isEquivalent: isEquivalent) - } - - /// Scopes the ObservableValue to a subset of Value to LocalValue given the supplied closure and removes duplicate values using Equatable. - /// - Parameter toLocalValue: A closure that takes a Value and returns a LocalValue. - /// - Returns: a scoped ObservableValue of LocalValue. - public func scope(_ toLocalValue: @escaping (Value) -> LocalValue) -> ObservableValue where LocalValue: Equatable { - scopeToLocalValue(toLocalValue, isEquivalent: { $0 == $1 }) - } - - /// Returns the value at the given keypath of ``Value``. - /// - /// In combination with `@dynamicMemberLookup`, this allows us to write `model.myProperty` instead of - /// `model.value.myProperty` where `model` has type `ObservableValue`. - public subscript(dynamicMember keyPath: KeyPath) -> T { - internalValue[keyPath: keyPath] - } - - private func scopeToLocalValue(_ toLocalValue: @escaping (Value) -> LocalValue, isEquivalent: ((LocalValue, LocalValue) -> Bool)? = nil) -> ObservableValue { - let localObservableValue = ObservableValue( - value: toLocalValue(internalValue), - isEquivalent: isEquivalent - ) - localObservableValue.parentCancellable = valuePublisher().sink(receiveValue: { newValue in - localObservableValue.value = toLocalValue(newValue) - }) - return localObservableValue - } - - private func valuePublisher() -> AnyPublisher { - guard let isEquivalent else { - return subject.eraseToAnyPublisher() - } - - return subject.removeDuplicates(by: isEquivalent).eraseToAnyPublisher() - } -} diff --git a/WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift b/WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift deleted file mode 100644 index 27bf6f4fb..000000000 --- a/WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright 2023 Square Inc. - * - * 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. - */ - -#if canImport(UIKit) - -import SwiftUI -import Workflow -import WorkflowUI - -@available(*, deprecated, message: "Use the ObservableScreen protocol from WorkflowSwiftUI") -public protocol SwiftUIScreen: Screen { - associatedtype Content: View - - var sizingOptions: SwiftUIScreenSizingOptions { get } - - @ViewBuilder - static func makeView(model: ObservableValue) -> Content - - static var isEquivalent: ((Self, Self) -> Bool)? { get } -} - -extension SwiftUIScreen { - public var sizingOptions: SwiftUIScreenSizingOptions { [] } -} - -extension SwiftUIScreen { - public static var isEquivalent: ((Self, Self) -> Bool)? { nil } -} - -extension SwiftUIScreen where Self: Equatable { - public static var isEquivalent: ((Self, Self) -> Bool)? { { $0 == $1 } } -} - -extension SwiftUIScreen { - public func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { - ViewControllerDescription( - type: ModeledHostingController>>.self, - environment: environment, - build: { - let (model, modelSink) = ObservableValue.makeObservableValue(self, isEquivalent: Self.isEquivalent) - let (viewEnvironment, envSink) = ObservableValue.makeObservableValue(environment) - return ModeledHostingController( - modelSink: modelSink, - viewEnvironmentSink: envSink, - rootView: WithModel(model, content: { model in - EnvironmentInjectingView( - viewEnvironment: viewEnvironment, - content: Self.makeView(model: model) - ) - }), - swiftUIScreenSizingOptions: sizingOptions - ) - }, - update: { - $0.modelSink.send(self) - $0.swiftUIScreenSizingOptions = sizingOptions - // ViewEnvironment updates are handled by the ModeledHostingController internally - } - ) - } -} - -public struct SwiftUIScreenSizingOptions: OptionSet { - public let rawValue: Int - - public init(rawValue: Int) { - self.rawValue = rawValue - } - - public static let preferredContentSize: SwiftUIScreenSizingOptions = .init(rawValue: 1 << 0) -} - -private struct EnvironmentInjectingView: View { - @ObservedObject var viewEnvironment: ObservableValue - let content: Content - - var body: some View { - content - .environment(\.viewEnvironment, viewEnvironment.value) - } -} - -private final class ModeledHostingController: UIHostingController, ViewEnvironmentObserving { - let modelSink: Sink - let viewEnvironmentSink: Sink - var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions { - didSet { - updateSizingOptionsIfNeeded() - if isViewLoaded { - setNeedsLayoutBeforeFirstLayoutIfNeeded() - } - } - } - - private var hasLaidOutOnce = false - private var maxFrameWidth: CGFloat = 0 - private var maxFrameHeight: CGFloat = 0 - - init( - modelSink: Sink, - viewEnvironmentSink: Sink, - rootView: Content, - swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions - ) { - self.modelSink = modelSink - self.viewEnvironmentSink = viewEnvironmentSink - self.swiftUIScreenSizingOptions = swiftUIScreenSizingOptions - - super.init(rootView: rootView) - - updateSizingOptionsIfNeeded() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("not implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - // `UIHostingController` provides a system background color by default. We set the - // background to clear to support contexts where it is composed within another view - // controller. - view.backgroundColor = .clear - - setNeedsLayoutBeforeFirstLayoutIfNeeded() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - defer { hasLaidOutOnce = true } - - if swiftUIScreenSizingOptions.contains(.preferredContentSize) { - // Use the largest frame ever laid out in as a constraint for preferredContentSize - // measurements. - let width = max(view.frame.width, maxFrameWidth) - let height = max(view.frame.height, maxFrameHeight) - - maxFrameWidth = width - maxFrameHeight = height - - let fixedSize = CGSize(width: width, height: height) - - // Measure a few different ways to account for ScrollView behavior. ScrollViews will - // always greedily fill the space available, but will report the natural content size - // when given an infinite size. By combining the results of these measurements we can - // deduce the natural size of content that scrolls in either direction, or both, or - // neither. - - let fixedResult = view.sizeThatFits(fixedSize) - let unboundedHorizontalResult = view.sizeThatFits(CGSize(width: .infinity, height: fixedSize.height)) - let unboundedVerticalResult = view.sizeThatFits(CGSize(width: fixedSize.width, height: .infinity)) - - let size = CGSize( - width: min(fixedResult.width, unboundedHorizontalResult.width), - height: min(fixedResult.height, unboundedVerticalResult.height) - ) - - if preferredContentSize != size { - preferredContentSize = size - } - } - } - - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - - applyEnvironmentIfNeeded() - } - - private func updateSizingOptionsIfNeeded() { - if !swiftUIScreenSizingOptions.contains(.preferredContentSize), - preferredContentSize != .zero - { - preferredContentSize = .zero - } - } - - private func setNeedsLayoutBeforeFirstLayoutIfNeeded() { - if swiftUIScreenSizingOptions.contains(.preferredContentSize), !hasLaidOutOnce { - // Without manually calling setNeedsLayout here it was observed that a call to - // layoutIfNeeded() immediately after loading the view would not perform a layout, and - // therefore would not update the preferredContentSize in viewDidLayoutSubviews(). - // UI-5797 - view.setNeedsLayout() - } - } - - // MARK: ViewEnvironmentObserving - - func apply(environment: ViewEnvironment) { - viewEnvironmentSink.send(environment) - } -} - -#endif diff --git a/WorkflowSwiftUIExperimental/Sources/WithModel.swift b/WorkflowSwiftUIExperimental/Sources/WithModel.swift deleted file mode 100644 index a380e2544..000000000 --- a/WorkflowSwiftUIExperimental/Sources/WithModel.swift +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2023 Square Inc. - * - * 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. - */ - -import SwiftUI - -struct WithModel: View { - @ObservedObject private var model: ObservableValue - private let content: (ObservableValue) -> Content - - init( - _ model: ObservableValue, - @ViewBuilder content: @escaping (ObservableValue) -> Content - ) { - self.model = model - self.content = content - } - - var body: Content { - content(model) - } -} diff --git a/WorkflowSwiftUIExperimental/Tests/SwiftUIScreenTests.swift b/WorkflowSwiftUIExperimental/Tests/SwiftUIScreenTests.swift deleted file mode 100644 index 5538f10d4..000000000 --- a/WorkflowSwiftUIExperimental/Tests/SwiftUIScreenTests.swift +++ /dev/null @@ -1,127 +0,0 @@ -#if canImport(UIKit) - -import SwiftUI -import UIKit -import ViewEnvironment -@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI -import WorkflowSwiftUIExperimental -import XCTest - -final class SwiftUIScreenTests: XCTestCase { - func test_noSizingOptions() { - let viewController = ContentScreen(sizingOptions: []) - .buildViewController(in: .empty) - - viewController.view.layoutIfNeeded() - - XCTAssertEqual(viewController.preferredContentSize, .zero) - } - - func test_preferredContentSize() { - let viewController = ContentScreen(sizingOptions: .preferredContentSize) - .buildViewController(in: .empty) - - viewController.view.layoutIfNeeded() - - XCTAssertEqual( - viewController.preferredContentSize, - .init(width: 42, height: 42) - ) - } - - func test_preferredContentSize_sizingOptionsChanges() { - let viewController = ContentScreen(sizingOptions: []) - .buildViewController(in: .empty) - - viewController.view.layoutIfNeeded() - - XCTAssertEqual(viewController.preferredContentSize, .zero) - - ContentScreen(sizingOptions: .preferredContentSize) - .viewControllerDescription(environment: .empty) - .update(viewController: viewController) - - viewController.view.layoutIfNeeded() - - XCTAssertEqual( - viewController.preferredContentSize, - .init(width: 42, height: 42) - ) - - ContentScreen(sizingOptions: []) - .viewControllerDescription(environment: .empty) - .update(viewController: viewController) - - viewController.view.layoutIfNeeded() - - 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 -} - -extension ViewEnvironment { - fileprivate var testKey: Int { - get { self[TestKey.self] } - set { self[TestKey.self] = newValue } - } -} - -private struct ContentScreen: SwiftUIScreen { - let sizingOptions: SwiftUIScreenSizingOptions - - static func makeView(model: ObservableValue) -> some View { - Color.clear - .frame(width: 42, height: 42) - } -} - -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.testKey) - var testValue: Int - - var onTestKeyEmission: (TestKey.Value) -> Void - - var body: some View { - let _ = onTestKeyEmission(testValue) - - Color.clear - .frame(width: 1, height: 1) - } - } -} - -#endif