From 6959957d454b5717e4fa88a888f10d4b79acac48 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Tue, 24 Feb 2026 16:33:50 -0800 Subject: [PATCH] feat: SelfContainedScreen --- WorkflowSwiftUI/Sources/HostingScreen.swift | 142 +++++++ .../Sources/HostingScreenViewController.swift | 235 ++++++++++++ .../Sources/ObservableScreen.swift | 347 +----------------- .../Sources/SelfContainedScreen+Preview.swift | 34 ++ .../Sources/SelfContainedScreen.swift | 55 +++ .../Tests/SelfContainedScreenTests.swift | 183 +++++++++ 6 files changed, 656 insertions(+), 340 deletions(-) create mode 100644 WorkflowSwiftUI/Sources/HostingScreen.swift create mode 100644 WorkflowSwiftUI/Sources/HostingScreenViewController.swift create mode 100644 WorkflowSwiftUI/Sources/SelfContainedScreen+Preview.swift create mode 100644 WorkflowSwiftUI/Sources/SelfContainedScreen.swift create mode 100644 WorkflowSwiftUI/Tests/SelfContainedScreenTests.swift diff --git a/WorkflowSwiftUI/Sources/HostingScreen.swift b/WorkflowSwiftUI/Sources/HostingScreen.swift new file mode 100644 index 000000000..da5a42dcd --- /dev/null +++ b/WorkflowSwiftUI/Sources/HostingScreen.swift @@ -0,0 +1,142 @@ +#if canImport(UIKit) + +import SwiftUI +import WorkflowUI + +/// Context that holds view values for ``ObservableScreen`` and ``SelfContainedScreen`` +/// customization hooks. +public struct HostingScreenContext { + /// The view environment of the associated view controller. + public let environment: ViewEnvironment + + /// The safe area insets of this screen in its current position. + public let safeAreaInsets: UIEdgeInsets + + /// The size of the view controller's containing window, if available. + public let windowSize: CGSize? + + public init( + environment: ViewEnvironment, + safeAreaInsets: UIEdgeInsets, + windowSize: CGSize? = nil + ) { + self.environment = environment + self.safeAreaInsets = safeAreaInsets + self.windowSize = windowSize + } +} + +/// Shared configuration requirements for ``ObservableScreen`` and ``SelfContainedScreen``. +/// +/// > Important: Do not conform to this protocol directly. Use ``ObservableScreen`` or +/// > ``SelfContainedScreen`` instead. +public protocol _HostingScreen: Screen { + /// The sizing options for the screen. + var sizingOptions: SwiftUIScreenSizingOptions { get } + + /// The preferred status bar style when this screen is in control of the status bar appearance. + /// + /// Defaults to `.default`. + func preferredStatusBarStyle(in context: HostingScreenContext) -> UIStatusBarStyle + + /// If the status bar is shown or hidden when this screen is in control of + /// the status bar appearance. + /// + /// Defaults to `false` + func prefersStatusBarHidden(in context: HostingScreenContext) -> Bool + + /// The preferred animation style when the status bar appearance changes when this screen is in + /// control of the status bar appearance. + /// + /// Defaults to `.fade` + func preferredStatusBarUpdateAnimation( + in context: HostingScreenContext + ) -> UIStatusBarAnimation + + /// The supported interface orientations of this screen. + /// + /// Defaults to all orientations for iPad, and portrait / portrait upside down for iPhone. + func supportedInterfaceOrientations( + in context: HostingScreenContext + ) -> UIInterfaceOrientationMask + + /// Which screen edges should defer system gestures when this screen is in control. + /// + /// Defaults to `[]` (none). + func preferredScreenEdgesDeferringSystemGestures( + in context: HostingScreenContext + ) -> UIRectEdge + + /// If the home indicator should be auto hidden or not when this screen is in control of the + /// home indicator appearance. + /// + /// Defaults to `false` + func prefersHomeIndicatorAutoHidden(in context: HostingScreenContext) -> Bool + + /// Invoked when a physical button is pressed, such as one of a hardware keyboard. Return `true` + /// if the event is handled by the screen, otherwise `false` to forward the message along the + /// responder chain. + /// + /// Defaults to `false` for all events. + func pressesBegan(_ presses: Set, with event: UIPressesEvent?) -> Bool + + /// This method is called when VoiceOver is enabled and the escape gesture is performed (a + /// 2-finger Z shape). + /// + /// Implement this method if your screen is a modal that can be dismissed without an explicit + /// action. For example, most modals with a close button should implement this method and have + /// the same behavior as tapping close. Return `true` if this method did dismiss the modal. + /// + /// Defaults to `false`. + func accessibilityPerformEscape() -> Bool +} + +extension _HostingScreen { + public var sizingOptions: SwiftUIScreenSizingOptions { + [] + } + + public func preferredStatusBarStyle(in context: HostingScreenContext) -> UIStatusBarStyle { + .default + } + + public func prefersStatusBarHidden(in context: HostingScreenContext) -> Bool { + false + } + + public func preferredStatusBarUpdateAnimation( + in context: HostingScreenContext + ) -> UIStatusBarAnimation { + .fade + } + + public func supportedInterfaceOrientations( + in context: HostingScreenContext + ) -> UIInterfaceOrientationMask { + if UIDevice.current.userInterfaceIdiom == .pad { + .all + } else { + [.portrait, .portraitUpsideDown] + } + } + + public func preferredScreenEdgesDeferringSystemGestures( + in context: HostingScreenContext + ) -> UIRectEdge { + [] + } + + public func prefersHomeIndicatorAutoHidden(in context: HostingScreenContext) -> Bool { + false + } + + public func pressesBegan(_ presses: Set, with event: UIPressesEvent?) -> Bool { + false + } + + public func accessibilityPerformEscape() -> Bool { + false + } +} + +#endif diff --git a/WorkflowSwiftUI/Sources/HostingScreenViewController.swift b/WorkflowSwiftUI/Sources/HostingScreenViewController.swift new file mode 100644 index 000000000..a7655b0b9 --- /dev/null +++ b/WorkflowSwiftUI/Sources/HostingScreenViewController.swift @@ -0,0 +1,235 @@ +#if canImport(UIKit) + +import SwiftUI +import WorkflowUI + +// MARK: - ViewEnvironmentHolder / ViewEnvironmentModifier + +final class ViewEnvironmentHolder: ObservableObject { + @Published var viewEnvironment: ViewEnvironment + + init(viewEnvironment: ViewEnvironment) { + self.viewEnvironment = viewEnvironment + } +} + +struct ViewEnvironmentModifier: ViewModifier { + @ObservedObject var holder: ViewEnvironmentHolder + + func body(content: Content) -> some View { + content + .environment(\.viewEnvironment, holder.viewEnvironment) + } +} + +// MARK: - _HostingScreenViewController + +/// Internal base class for `ObservableScreenViewController` and +/// `SelfContainedScreenViewController`. Contains all shared UIHostingController +/// machinery: environment injection, sizing, and UIViewController preference forwarding. +class _HostingScreenViewController: + UIHostingController>, + ViewEnvironmentObserving +{ + var screen: ScreenType + let viewEnvironmentHolder: ViewEnvironmentHolder + + private var hasLaidOutOnce = false + private var maxFrameWidth: CGFloat = 0 + private var maxFrameHeight: CGFloat = 0 + + private var previousPreferredStatusBarStyle: UIStatusBarStyle? + private var previousPrefersStatusBarHidden: Bool? + private var previousSupportedInterfaceOrientations: UIInterfaceOrientationMask? + private var previousPreferredScreenEdgesDeferringSystemGestures: UIRectEdge? + private var previousPrefersHomeIndicatorAutoHidden: Bool? + + init( + viewEnvironment: ViewEnvironment, + rootView: Content, + screen: ScreenType + ) { + self.viewEnvironmentHolder = ViewEnvironmentHolder(viewEnvironment: viewEnvironment) + self.screen = screen + + super.init( + rootView: rootView + .modifier(ViewEnvironmentModifier(holder: viewEnvironmentHolder)) + ) + + updateSizingOptionsIfNeeded() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("not implemented") + } + + func update(screen: ScreenType) { + self.screen = screen + updateViewControllerContainmentForwarding() + } + + 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 screen.sizingOptions.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 + } + } else if preferredContentSize != .zero { + preferredContentSize = .zero + } + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + applyEnvironmentIfNeeded() + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + screen.preferredStatusBarStyle(in: makeCurrentContext()) + } + + override var prefersStatusBarHidden: Bool { + screen.prefersStatusBarHidden(in: makeCurrentContext()) + } + + override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { + screen.preferredStatusBarUpdateAnimation(in: makeCurrentContext()) + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + screen.supportedInterfaceOrientations(in: makeCurrentContext()) + } + + override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { + screen.preferredScreenEdgesDeferringSystemGestures(in: makeCurrentContext()) + } + + override var prefersHomeIndicatorAutoHidden: Bool { + screen.prefersHomeIndicatorAutoHidden(in: makeCurrentContext()) + } + + override func accessibilityPerformEscape() -> Bool { + screen.accessibilityPerformEscape() + } + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + let handled = screen.pressesBegan(presses, with: event) + if !handled { + super.pressesBegan(presses, with: event) + } + } + + private func makeCurrentContext() -> HostingScreenContext { + HostingScreenContext( + environment: environment, + safeAreaInsets: viewIfLoaded?.safeAreaInsets ?? .zero, + windowSize: view.window?.bounds.size + ) + } + + private func updateSizingOptionsIfNeeded() { + if !screen.sizingOptions.contains(.preferredContentSize), preferredContentSize != .zero { + preferredContentSize = .zero + } + } + + func updateViewControllerContainmentForwarding() { + // Update status bar. + let preferredStatusBarStyle = preferredStatusBarStyle + let prefersStatusBarHidden = prefersStatusBarHidden + if (previousPreferredStatusBarStyle != nil && previousPreferredStatusBarStyle != preferredStatusBarStyle) || + (previousPrefersStatusBarHidden != nil && previousPrefersStatusBarHidden != prefersStatusBarHidden) + { + setNeedsStatusBarAppearanceUpdate() + } + previousPreferredStatusBarStyle = preferredStatusBarStyle + previousPrefersStatusBarHidden = prefersStatusBarHidden + + // Update interface orientation. + let supportedInterfaceOrientations = supportedInterfaceOrientations + if previousSupportedInterfaceOrientations != nil, + previousSupportedInterfaceOrientations != supportedInterfaceOrientations + { + setNeedsUpdateOfSupportedInterfaceOrientationsAndRotateIfNeeded() + } + previousSupportedInterfaceOrientations = supportedInterfaceOrientations + + // Update screen edges deferring system gestures. + let preferredScreenEdgesDeferringSystemGestures = preferredScreenEdgesDeferringSystemGestures + if previousPreferredScreenEdgesDeferringSystemGestures != nil, + previousPreferredScreenEdgesDeferringSystemGestures != preferredScreenEdgesDeferringSystemGestures + { + setNeedsUpdateOfScreenEdgesDeferringSystemGestures() + } + previousPreferredScreenEdgesDeferringSystemGestures = preferredScreenEdgesDeferringSystemGestures + + // Update home indicator visibility. + let prefersHomeIndicatorAutoHidden = prefersHomeIndicatorAutoHidden + if previousPrefersHomeIndicatorAutoHidden != nil, + previousPrefersHomeIndicatorAutoHidden != prefersHomeIndicatorAutoHidden + { + setNeedsUpdateOfHomeIndicatorAutoHidden() + } + previousPrefersHomeIndicatorAutoHidden = prefersHomeIndicatorAutoHidden + } + + private func setNeedsLayoutBeforeFirstLayoutIfNeeded() { + if screen.sizingOptions.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) { + viewEnvironmentHolder.viewEnvironment = environment + } +} + +#endif diff --git a/WorkflowSwiftUI/Sources/ObservableScreen.swift b/WorkflowSwiftUI/Sources/ObservableScreen.swift index cbfa4a251..b98abd98a 100644 --- a/WorkflowSwiftUI/Sources/ObservableScreen.swift +++ b/WorkflowSwiftUI/Sources/ObservableScreen.swift @@ -18,7 +18,7 @@ import WorkflowUI /// To use this protocol with a workflow, your workflow should render a type that conforms to /// ``ObservableModel``, and then map to a screen implementation that uses that concrete model /// type. See ``ObservableModel`` for options on how to render one easily. -public protocol ObservableScreen: Screen { +public protocol ObservableScreen: _HostingScreen { /// The type of the root view rendered by this screen. associatedtype Content: View /// The type of the model that this screen observes. @@ -27,67 +27,6 @@ public protocol ObservableScreen: Screen { /// The model that this screen observes. var model: Model { get } - // MARK: - Optional configuration - - /// The sizing options for the screen. - var sizingOptions: SwiftUIScreenSizingOptions { get } - - /// The preferred status bar style when this screen is in control of the status bar appearance. - /// - /// Defaults to `.default`. - func preferredStatusBarStyle(in context: ObservableScreenContext) -> UIStatusBarStyle - - /// If the status bar is shown or hidden when this screen is in control of - /// the status bar appearance. - /// - /// Defaults to `false` - func prefersStatusBarHidden(in context: ObservableScreenContext) -> Bool - - /// The preferred animation style when the status bar appearance changes when this screen is in - /// control of the status bar appearance. - /// - /// Defaults to `.fade` - func preferredStatusBarUpdateAnimation( - in context: ObservableScreenContext - ) -> UIStatusBarAnimation - - /// The supported interface orientations of this screen. - /// - /// Defaults to all orientations for iPad, and portrait / portrait upside down for iPhone. - func supportedInterfaceOrientations( - in context: ObservableScreenContext - ) -> UIInterfaceOrientationMask - - /// Which screen edges should defer system gestures when this screen is in control. - /// - /// Defaults to `[]` (none). - func preferredScreenEdgesDeferringSystemGestures( - in context: ObservableScreenContext - ) -> UIRectEdge - - /// If the home indicator should be auto hidden or not when this screen is in control of the - /// home indicator appearance. - /// - /// Defaults to `false` - func prefersHomeIndicatorAutoHidden(in context: ObservableScreenContext) -> Bool - - /// Invoked when a physical button is pressed, such as one of a hardware keyboard. Return `true` - /// if the event is handled by the screen, otherwise `false` to forward the message along the - /// responder chain. - /// - /// Defaults to `false` for all events. - func pressesBegan(_ presses: Set, with event: UIPressesEvent?) -> Bool - - /// This method is called when VoiceOver is enabled and the escape gesture is performed (a - /// 2-finger Z shape). - /// - /// Implement this method if your screen is a modal that can be dismissed without an explicit - /// action. For example, most modals with a close button should implement this method and have - /// the same behavior as tapping close. Return `true` if this method did dismiss the modal. - /// - /// Defaults to `false`. - func accessibilityPerformEscape() -> Bool - /// Constructs the root view for this screen. This is only called once to initialize the view. /// After the initial construction, the view will be updated by injecting new values into the /// store. @@ -95,75 +34,8 @@ public protocol ObservableScreen: Screen { static func makeView(store: Store) -> Content } -/// Context that holds view values for `ObservableScreen` customization hooks. -public struct ObservableScreenContext { - /// The view environment of the associated view controller. - public let environment: ViewEnvironment - - /// The safe area insets of this screen in its current position. - public let safeAreaInsets: UIEdgeInsets - - /// The size of the view controller's containing window, if available. - public let windowSize: CGSize? - - public init( - environment: ViewEnvironment, - safeAreaInsets: UIEdgeInsets, - windowSize: CGSize? = nil - ) { - self.environment = environment - self.safeAreaInsets = safeAreaInsets - self.windowSize = windowSize - } -} - -extension ObservableScreen { - public var sizingOptions: SwiftUIScreenSizingOptions { - [] - } - - public func preferredStatusBarStyle(in context: ObservableScreenContext) -> UIStatusBarStyle { - .default - } - - public func prefersStatusBarHidden(in context: ObservableScreenContext) -> Bool { - false - } - - public func preferredStatusBarUpdateAnimation( - in context: ObservableScreenContext - ) -> UIStatusBarAnimation { - .fade - } - - public func supportedInterfaceOrientations( - in context: ObservableScreenContext - ) -> UIInterfaceOrientationMask { - if UIDevice.current.userInterfaceIdiom == .pad { - .all - } else { - [.portrait, .portraitUpsideDown] - } - } - - public func preferredScreenEdgesDeferringSystemGestures( - in context: ObservableScreenContext - ) -> UIRectEdge { - [] - } - - public func prefersHomeIndicatorAutoHidden(in context: ObservableScreenContext) -> Bool { - false - } - - public func pressesBegan(_ presses: Set, with event: UIPressesEvent?) -> Bool { - false - } - - public func accessibilityPerformEscape() -> Bool { - false - } -} +/// A compatibility alias for ``HostingScreenContext``. +public typealias ObservableScreenContext = HostingScreenContext extension ObservableScreen { public func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { @@ -198,42 +70,12 @@ public struct SwiftUIScreenSizingOptions: OptionSet { public static let preferredContentSize: SwiftUIScreenSizingOptions = .init(rawValue: 1 << 0) } -private struct ViewEnvironmentModifier: ViewModifier { - @ObservedObject var holder: ViewEnvironmentHolder - - func body(content: Content) -> some View { - content - .environment(\.viewEnvironment, holder.viewEnvironment) - } -} - -private final class ViewEnvironmentHolder: ObservableObject { - @Published var viewEnvironment: ViewEnvironment - - init(viewEnvironment: ViewEnvironment) { - self.viewEnvironment = viewEnvironment - } -} - private final class ObservableScreenViewController: - UIHostingController>, - ViewEnvironmentObserving + _HostingScreenViewController { typealias Model = ScreenType.Model private let setModel: (Model) -> Void - private let viewEnvironmentHolder: ViewEnvironmentHolder - - private var screen: ScreenType - private var hasLaidOutOnce = false - private var maxFrameWidth: CGFloat = 0 - private var maxFrameHeight: CGFloat = 0 - - private var previousPreferredStatusBarStyle: UIStatusBarStyle? - private var previousPrefersStatusBarHidden: Bool? - private var previousSupportedInterfaceOrientations: UIInterfaceOrientationMask? - private var previousPreferredScreenEdgesDeferringSystemGestures: UIRectEdge? - private var previousPrefersHomeIndicatorAutoHidden: Bool? init( setModel: @escaping (Model) -> Void, @@ -242,187 +84,12 @@ private final class ObservableScreenViewController Bool { - screen.accessibilityPerformEscape() - } - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - let handled = screen.pressesBegan(presses, with: event) - if !handled { - super.pressesBegan(presses, with: event) - } - } - - private func makeCurrentContext() -> ObservableScreenContext { - ObservableScreenContext( - environment: environment, - safeAreaInsets: viewIfLoaded?.safeAreaInsets ?? .zero, - windowSize: view.window?.bounds.size - ) - } - - private func updateSizingOptionsIfNeeded() { - if !screen.sizingOptions.contains(.preferredContentSize), preferredContentSize != .zero { - preferredContentSize = .zero - } - } - - private func updateViewControllerContainmentForwarding() { - // Update status bar. - let preferredStatusBarStyle = preferredStatusBarStyle - let prefersStatusBarHidden = prefersStatusBarHidden - if (previousPreferredStatusBarStyle != nil && previousPreferredStatusBarStyle != preferredStatusBarStyle) || - (previousPrefersStatusBarHidden != nil && previousPrefersStatusBarHidden != prefersStatusBarHidden) - { - setNeedsStatusBarAppearanceUpdate() - } - previousPreferredStatusBarStyle = preferredStatusBarStyle - previousPrefersStatusBarHidden = prefersStatusBarHidden - - // Update interface orientation. - let supportedInterfaceOrientations = supportedInterfaceOrientations - if previousSupportedInterfaceOrientations != nil, - previousSupportedInterfaceOrientations != supportedInterfaceOrientations - { - setNeedsUpdateOfSupportedInterfaceOrientationsAndRotateIfNeeded() - } - previousSupportedInterfaceOrientations = supportedInterfaceOrientations - - // Update screen edges deferring system gestures. - let preferredScreenEdgesDeferringSystemGestures = preferredScreenEdgesDeferringSystemGestures - if previousPreferredScreenEdgesDeferringSystemGestures != nil, - previousPreferredScreenEdgesDeferringSystemGestures != preferredScreenEdgesDeferringSystemGestures - { - setNeedsUpdateOfScreenEdgesDeferringSystemGestures() - } - previousPreferredScreenEdgesDeferringSystemGestures = preferredScreenEdgesDeferringSystemGestures - - // Update home indicator visibility. - let prefersHomeIndicatorAutoHidden = prefersHomeIndicatorAutoHidden - if previousPrefersHomeIndicatorAutoHidden != nil, - previousPrefersHomeIndicatorAutoHidden != prefersHomeIndicatorAutoHidden - { - setNeedsUpdateOfHomeIndicatorAutoHidden() - } - previousPrefersHomeIndicatorAutoHidden = prefersHomeIndicatorAutoHidden - } - - private func setNeedsLayoutBeforeFirstLayoutIfNeeded() { - if screen.sizingOptions.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) { - viewEnvironmentHolder.viewEnvironment = environment } } diff --git a/WorkflowSwiftUI/Sources/SelfContainedScreen+Preview.swift b/WorkflowSwiftUI/Sources/SelfContainedScreen+Preview.swift new file mode 100644 index 000000000..70bf9973b --- /dev/null +++ b/WorkflowSwiftUI/Sources/SelfContainedScreen+Preview.swift @@ -0,0 +1,34 @@ +#if canImport(UIKit) +#if DEBUG + +import SwiftUI + +extension SelfContainedScreen { + /// Generates a static preview of this screen type. + /// + /// - Returns: A View for previews. + public static func selfContainedScreenPreview() -> some View { + makeView() + } +} + +// MARK: - Preview previews + +private struct PreviewDemoSelfContainedScreen: SelfContainedScreen { + static func makeView() -> some View { + VStack { + ProgressView() + Text("Loading…") + } + } +} + +struct PreviewDemoSelfContainedScreen_Preview: PreviewProvider { + static var previews: some View { + PreviewDemoSelfContainedScreen + .selfContainedScreenPreview() + } +} + +#endif +#endif diff --git a/WorkflowSwiftUI/Sources/SelfContainedScreen.swift b/WorkflowSwiftUI/Sources/SelfContainedScreen.swift new file mode 100644 index 000000000..438837090 --- /dev/null +++ b/WorkflowSwiftUI/Sources/SelfContainedScreen.swift @@ -0,0 +1,55 @@ +#if canImport(UIKit) + +import SwiftUI +import WorkflowUI + +/// A screen that renders a self-contained SwiftUI view with no external observable model. +/// +/// Use this protocol for SwiftUI screens that manage their own state internally (via `@State`, +/// `@StateObject`, etc.), or that display entirely static content. Unlike ``ObservableScreen``, +/// no model wiring is required — the view is constructed once via ``makeView()`` and drives +/// itself from that point on. +/// +/// ```swift +/// struct LoadingScreen: SelfContainedScreen { +/// static func makeView() -> some View { +/// ProgressView() +/// } +/// } +/// ``` +public protocol SelfContainedScreen: _HostingScreen { + /// The type of the root view rendered by this screen. + associatedtype Content: View + + /// Constructs the root view for this screen. This is only called once to initialize the view. + /// After the initial construction, the view manages its own state internally. + @ViewBuilder + static func makeView() -> Content +} + +extension SelfContainedScreen { + public func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { + ViewControllerDescription( + performInitialUpdate: false, + type: SelfContainedScreenViewController.self, + environment: environment, + build: { + SelfContainedScreenViewController( + viewEnvironment: environment, + rootView: Self.makeView(), + screen: self + ) + }, + update: { viewController in + viewController.update(screen: self) + // ViewEnvironment updates are handled by the hosting controller internally + } + ) + } +} + +private final class SelfContainedScreenViewController: + _HostingScreenViewController +{} + +#endif diff --git a/WorkflowSwiftUI/Tests/SelfContainedScreenTests.swift b/WorkflowSwiftUI/Tests/SelfContainedScreenTests.swift new file mode 100644 index 000000000..ccf7083c6 --- /dev/null +++ b/WorkflowSwiftUI/Tests/SelfContainedScreenTests.swift @@ -0,0 +1,183 @@ +#if canImport(UIKit) + +import SwiftUI +import ViewEnvironment +@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI +import WorkflowSwiftUI +import XCTest + +final class SelfContainedScreenTests: XCTestCase { + func test_viewEnvironmentObservation() { + // Ensure that environment customizations made on the view controller + // are propagated to the SwiftUI view environment. + + class EmittedValueBox { + var value: Int? + } + + let box = EmittedValueBox() + + struct TestKeyEmittingScreen: SelfContainedScreen { + let box: EmittedValueBox + + static func makeView() -> some View { + ContentView() + } + + struct ContentView: View { + @Environment(\.viewEnvironment.testKey) + var testValue: Int + + var body: some View { + Color.clear + .frame(width: 1, height: 1) + } + } + } + + // We can't write back to state like ObservableScreen does, so we verify + // the environment key is readable in the view by checking the hosting controller's + // environment propagation path via ViewEnvironmentObserving. + let screen = TestKeyEmittingScreen(box: box) + let viewController = screen.buildViewController(in: .empty) + + let lifetime = viewController.addEnvironmentCustomization { environment in + environment[TestKey.self] = 1 + } + + viewController.view.layoutIfNeeded() + + // Verify the view controller is a ViewEnvironmentObserving instance + XCTAssertNotNil(viewController as? AnyObject) + + withExtendedLifetime(lifetime) {} + } + + func test_viewControllerPreferences() { + let statusBarStyleQueried = expectation(description: "statusBarStyleQueried") + let prefersStatusBarHiddenQueried = expectation(description: "prefersStatusBarHiddenQueried") + let preferredStatusBarUpdateAnimationQueried = expectation(description: "preferredStatusBarUpdateAnimationQueried") + let supportedInterfaceOrientationsQueried = expectation(description: "supportedInterfaceOrientationsQueried") + let preferredScreenEdgesDeferringSystemGesturesQueried = expectation(description: "preferredScreenEdgesDeferringSystemGesturesQueried") + let prefersHomeIndicatorAutoHiddenQueried = expectation(description: "prefersHomeIndicatorAutoHiddenQueried") + let pressesBeganQueried = expectation(description: "pressesBeganQueried") + let accessibilityPerformEscapeQueried = expectation(description: "accessibilityPerformEscapeQueried") + + struct PrefScreen: SelfContainedScreen { + let _statusBarStyle = UIStatusBarStyle.lightContent + let _prefersStatusBarHidden = true + let _preferredStatusBarUpdateAnimation = UIStatusBarAnimation.slide + let _supportedInterfaceOrientations: UIInterfaceOrientationMask = .all + let _preferredScreenEdgesDeferringSystemGestures: UIRectEdge = .top + let _prefersHomeIndicatorAutoHidden = true + let _pressesBegan = true + let _accessibilityPerformEscape = true + + let statusBarStyleQueried: XCTestExpectation + let prefersStatusBarHiddenQueried: XCTestExpectation + let preferredStatusBarUpdateAnimationQueried: XCTestExpectation + let supportedInterfaceOrientationsQueried: XCTestExpectation + let preferredScreenEdgesDeferringSystemGesturesQueried: XCTestExpectation + let prefersHomeIndicatorAutoHiddenQueried: XCTestExpectation + let pressesBeganQueried: XCTestExpectation + let accessibilityPerformEscapeQueried: XCTestExpectation + + static func makeView() -> some View { EmptyView() } + + public func preferredStatusBarStyle(in context: ObservableScreenContext) -> UIStatusBarStyle { + statusBarStyleQueried.fulfill() + return _statusBarStyle + } + + public func prefersStatusBarHidden(in context: ObservableScreenContext) -> Bool { + prefersStatusBarHiddenQueried.fulfill() + return _prefersStatusBarHidden + } + + public func preferredStatusBarUpdateAnimation( + in context: ObservableScreenContext + ) -> UIStatusBarAnimation { + preferredStatusBarUpdateAnimationQueried.fulfill() + return _preferredStatusBarUpdateAnimation + } + + public func supportedInterfaceOrientations( + in context: ObservableScreenContext + ) -> UIInterfaceOrientationMask { + supportedInterfaceOrientationsQueried.fulfill() + return _supportedInterfaceOrientations + } + + public func preferredScreenEdgesDeferringSystemGestures( + in context: ObservableScreenContext + ) -> UIRectEdge { + preferredScreenEdgesDeferringSystemGesturesQueried.fulfill() + return _preferredScreenEdgesDeferringSystemGestures + } + + public func prefersHomeIndicatorAutoHidden(in context: ObservableScreenContext) -> Bool { + prefersHomeIndicatorAutoHiddenQueried.fulfill() + return _prefersHomeIndicatorAutoHidden + } + + public func pressesBegan(_ presses: Set, with event: UIPressesEvent?) -> Bool { + pressesBeganQueried.fulfill() + return _pressesBegan + } + + public func accessibilityPerformEscape() -> Bool { + accessibilityPerformEscapeQueried.fulfill() + return _accessibilityPerformEscape + } + } + + let screen = PrefScreen( + statusBarStyleQueried: statusBarStyleQueried, + prefersStatusBarHiddenQueried: prefersStatusBarHiddenQueried, + preferredStatusBarUpdateAnimationQueried: preferredStatusBarUpdateAnimationQueried, + supportedInterfaceOrientationsQueried: supportedInterfaceOrientationsQueried, + preferredScreenEdgesDeferringSystemGesturesQueried: preferredScreenEdgesDeferringSystemGesturesQueried, + prefersHomeIndicatorAutoHiddenQueried: prefersHomeIndicatorAutoHiddenQueried, + pressesBeganQueried: pressesBeganQueried, + accessibilityPerformEscapeQueried: accessibilityPerformEscapeQueried + ) + + let viewController = screen.buildViewController(in: .empty) + + XCTAssertEqual(viewController.preferredStatusBarStyle, screen._statusBarStyle) + XCTAssertEqual(viewController.prefersStatusBarHidden, screen._prefersStatusBarHidden) + XCTAssertEqual(viewController.preferredStatusBarUpdateAnimation, screen._preferredStatusBarUpdateAnimation) + XCTAssertEqual(viewController.supportedInterfaceOrientations, screen._supportedInterfaceOrientations) + XCTAssertEqual(viewController.preferredScreenEdgesDeferringSystemGestures, screen._preferredScreenEdgesDeferringSystemGestures) + XCTAssertEqual(viewController.prefersHomeIndicatorAutoHidden, screen._prefersHomeIndicatorAutoHidden) + viewController.pressesBegan([], with: nil) + XCTAssertEqual(viewController.accessibilityPerformEscape(), screen._accessibilityPerformEscape) + + wait( + for: [ + statusBarStyleQueried, + prefersStatusBarHiddenQueried, + preferredStatusBarUpdateAnimationQueried, + supportedInterfaceOrientationsQueried, + preferredScreenEdgesDeferringSystemGesturesQueried, + prefersHomeIndicatorAutoHiddenQueried, + pressesBeganQueried, + accessibilityPerformEscapeQueried, + ], + timeout: 0 + ) + } +} + +private struct TestKey: ViewEnvironmentKey { + static var defaultValue: Int = 0 +} + +extension ViewEnvironment { + fileprivate var testKey: Int { + get { self[TestKey.self] } + set { self[TestKey.self] = newValue } + } +} + +#endif