From 898e3f0a1d8b8ce198fa9c9fd12756aef7d28417 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Fri, 2 May 2025 19:03:08 -0700 Subject: [PATCH 1/2] feat: ObservableScreen view controller preferences --- .../Sources/ObservableScreen.swift | 267 ++++++++++++++++-- .../UIViewController+Orientation.swift | 109 +++++++ .../Tests/ObservableScreenTests.swift | 192 ++++++++++--- 3 files changed, 508 insertions(+), 60 deletions(-) create mode 100644 WorkflowSwiftUI/Sources/UIViewController+Orientation.swift diff --git a/WorkflowSwiftUI/Sources/ObservableScreen.swift b/WorkflowSwiftUI/Sources/ObservableScreen.swift index fc000d792..e7fbddb2c 100644 --- a/WorkflowSwiftUI/Sources/ObservableScreen.swift +++ b/WorkflowSwiftUI/Sources/ObservableScreen.swift @@ -24,11 +24,70 @@ public protocol ObservableScreen: Screen { /// The type of the model that this screen observes. associatedtype Model: ObservableModel - /// The sizing options for the screen. - var sizingOptions: SwiftUIScreenSizingOptions { get } /// 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 is + /// 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. @@ -36,26 +95,93 @@ 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 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 + } } extension ObservableScreen { public func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { ViewControllerDescription( - type: ModeledHostingController.self, + performInitialUpdate: false, + type: ObservableScreenViewController.self, environment: environment, build: { let (store, setModel) = Store.make(model: model) - return ModeledHostingController( + return ObservableScreenViewController( setModel: setModel, viewEnvironment: environment, rootView: Self.makeView(store: store), - sizingOptions: sizingOptions + screen: self ) }, update: { hostingController in - hostingController.setModel(model) + hostingController.update(screen: self) // ViewEnvironment updates are handled by the ModeledHostingController internally } ) @@ -89,33 +215,35 @@ private final class ViewEnvironmentHolder: ObservableObject { } } -private final class ModeledHostingController: UIHostingController>, ViewEnvironmentObserving { - let setModel: (Model) -> Void +private final class ObservableScreenViewController: + UIHostingController>, + ViewEnvironmentObserving +{ + typealias Model = ScreenType.Model + private let setModel: (Model) -> Void private let viewEnvironmentHolder: ViewEnvironmentHolder - var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions { - didSet { - updateSizingOptionsIfNeeded() - if isViewLoaded { - setNeedsLayoutBeforeFirstLayoutIfNeeded() - } - } - } - + 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, viewEnvironment: ViewEnvironment, rootView: Content, - sizingOptions swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions + screen: ScreenType ) { self.setModel = setModel self.viewEnvironmentHolder = ViewEnvironmentHolder(viewEnvironment: viewEnvironment) - self.swiftUIScreenSizingOptions = swiftUIScreenSizingOptions + self.screen = screen super.init( rootView: rootView @@ -130,6 +258,12 @@ private final class ModeledHostingController: UIHostingCon fatalError("not implemented") } + func update(screen: ScreenType) { + self.screen = screen + setModel(screen.model) + updateViewControllerContainmentForwarding() + } + override func viewDidLoad() { super.viewDidLoad() @@ -146,7 +280,7 @@ private final class ModeledHostingController: UIHostingCon defer { hasLaidOutOnce = true } - if swiftUIScreenSizingOptions.contains(.preferredContentSize) { + 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) @@ -175,6 +309,8 @@ private final class ModeledHostingController: UIHostingCon if preferredContentSize != size { preferredContentSize = size } + } else if preferredContentSize != .zero { + preferredContentSize = .zero } } @@ -184,16 +320,97 @@ private final class ModeledHostingController: UIHostingCon 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() -> ObservableScreenContext { + ObservableScreenContext( + environment: environment, + safeAreaInsets: viewIfLoaded?.safeAreaInsets ?? .zero, + windowSize: view.window?.bounds.size + ) + } + private func updateSizingOptionsIfNeeded() { - if !swiftUIScreenSizingOptions.contains(.preferredContentSize), - preferredContentSize != .zero - { + 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 swiftUIScreenSizingOptions.contains(.preferredContentSize), !hasLaidOutOnce { + 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(). diff --git a/WorkflowSwiftUI/Sources/UIViewController+Orientation.swift b/WorkflowSwiftUI/Sources/UIViewController+Orientation.swift new file mode 100644 index 000000000..6f2246019 --- /dev/null +++ b/WorkflowSwiftUI/Sources/UIViewController+Orientation.swift @@ -0,0 +1,109 @@ +#if canImport(UIKit) + +import UIKit + +extension UIViewController { + /// Flags the view controller for needing a supported interface orientations update + /// (`setNeedsUpdateOfSupportedInterfaceOrientations()`), and rotates to a supported interface orientation if the + /// associated `view.window?.rootViewController?.supportedInterfaceOrientations` mask does not contain match the + /// current `windowScene` orientation. + public func setNeedsUpdateOfSupportedInterfaceOrientationsAndRotateIfNeeded() { + // This approach is inspired by the solution found in the Flutter repository: + // https://github.com/flutter/engine/blob/67440ccd58561a2b2f0336a3af695a07a6f9eff5/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm#L1697-L1744 + + // We need to indicate that this VC's supported orientations changed, even in cases where the current + // orientation is supported, so that the system can utilize these new values to determine if a device + // rotation should trigger a VC rotation. + // If you do not call this function, the supported orientations are never re-queried. + setNeedsUpdateOfSupportedInterfaceOrientations() + + guard + let view = viewIfLoaded, + let supportedInterfaceOrientations = view.window?.rootViewController?.supportedInterfaceOrientations, + let scene = view.window?.windowScene, + let sceneOrientationMask = UIInterfaceOrientationMask(scene.interfaceOrientation), + sceneOrientationMask.isDisjoint(with: supportedInterfaceOrientations) + else { + return + } + + let deviceOrientation = UIInterfaceOrientation(UIDevice.current.orientation) + + let orientations: [UIInterfaceOrientation] = [ + .portrait, + .landscapeRight, + .landscapeLeft, + .portraitUpsideDown, + ].sorted { lhs, _ in + /// The current orientation should always be the first fallback. + lhs == deviceOrientation + } + + let newOrientation = orientations.first { orientation in + UIInterfaceOrientationMask(orientation)?.isSubset(of: supportedInterfaceOrientations) == true + } + + if newOrientation != nil { + scene.requestGeometryUpdate(.iOS(interfaceOrientations: supportedInterfaceOrientations)) { error in + print("Failed to request geometry update: \(error)") + } + } + } +} + +extension UIInterfaceOrientationMask { + fileprivate init?(_ orientation: UIInterfaceOrientation) { + switch orientation { + case .portrait: + self = .portrait + + case .portraitUpsideDown: + self = .portraitUpsideDown + + case .landscapeLeft: + self = .landscapeLeft + + case .landscapeRight: + self = .landscapeRight + + case .unknown: + return nil + + @unknown default: + return nil + } + } +} + +extension UIInterfaceOrientation { + fileprivate init?(_ orientation: UIDeviceOrientation) { + switch orientation { + case .portrait: + self = .portrait + + case .portraitUpsideDown: + self = .portraitUpsideDown + + // The reason Left is mapped to Right and vice versa according to Apple's documentation on + // `UIInterfaceOrientation`: + // > Notice that UIDeviceOrientation.landscapeRight is assigned to UIInterfaceOrientation.landscapeLeft and + // > UIDeviceOrientation.landscapeLeft is assigned to UIInterfaceOrientation.landscapeRight. The reason for this + // > is that rotating the device requires rotating the content in the opposite direction. + case .landscapeLeft: + self = .landscapeRight + + case .landscapeRight: + self = .landscapeLeft + + case .unknown, + .faceUp, + .faceDown: + return nil + + @unknown default: + return nil + } + } +} + +#endif diff --git a/WorkflowSwiftUI/Tests/ObservableScreenTests.swift b/WorkflowSwiftUI/Tests/ObservableScreenTests.swift index 14b286eec..a02fd92eb 100644 --- a/WorkflowSwiftUI/Tests/ObservableScreenTests.swift +++ b/WorkflowSwiftUI/Tests/ObservableScreenTests.swift @@ -11,10 +11,43 @@ final class ObservableScreenTests: XCTestCase { // Ensure that environment customizations made on the view controller // are propagated to the SwiftUI view environment. - var state = MyState() + struct KeyCapturingModel: ObservableModel { + typealias State = KeyCapturingState + + let accessor: StateAccessor + } + + struct TestKeyEmittingScreen: ObservableScreen { + typealias Model = KeyCapturingModel + + var model: Model + + let sizingOptions: WorkflowSwiftUI.SwiftUIScreenSizingOptions = [.preferredContentSize] + + static func makeView(store: Store) -> some View { + ContentView(store: store) + } + + struct ContentView: View { + @Environment(\.viewEnvironment.testKey) + var testValue: Int + + var store: Store + + var body: some View { + WithPerceptionTracking { + let _ = { store.emittedValue = testValue }() + Color.clear + .frame(width: 1, height: 1) + } + } + } + } + + var state = KeyCapturingState() let viewController = TestKeyEmittingScreen( - model: MyModel( + model: KeyCapturingModel( accessor: StateAccessor( state: state, sendValue: { $0(&state) } @@ -33,6 +66,125 @@ final class ObservableScreenTests: XCTestCase { withExtendedLifetime(lifetime) {} } + + func test_viewControllerPreferences() { + typealias Model = StateAccessor + + 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: ObservableScreen { + 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 + + let model: Model + static func makeView(store: Store) -> 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, + model: Model.constant(state: DummyState()) + ) + + 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 { @@ -47,41 +199,11 @@ extension ViewEnvironment { } @ObservableState -private struct MyState { +private struct KeyCapturingState { 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.testKey) - var testValue: Int - - var store: Store - - var body: some View { - WithPerceptionTracking { - let _ = { store.emittedValue = testValue }() - Color.clear - .frame(width: 1, height: 1) - } - } - } -} +@ObservableState +private struct DummyState {} #endif From b05111fc2eb49d96a029f912f4ff8a2917bdd073 Mon Sep 17 00:00:00 2001 From: Andrew Watt <100192+watt@users.noreply.github.com> Date: Thu, 22 May 2025 14:47:31 -0700 Subject: [PATCH 2/2] docs: fix typo Co-authored-by: johnnewman-square --- WorkflowSwiftUI/Sources/ObservableScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WorkflowSwiftUI/Sources/ObservableScreen.swift b/WorkflowSwiftUI/Sources/ObservableScreen.swift index e7fbddb2c..cbfa4a251 100644 --- a/WorkflowSwiftUI/Sources/ObservableScreen.swift +++ b/WorkflowSwiftUI/Sources/ObservableScreen.swift @@ -43,7 +43,7 @@ public protocol ObservableScreen: Screen { /// Defaults to `false` func prefersStatusBarHidden(in context: ObservableScreenContext) -> Bool - /// The preferred animation style when the status bar appearance changes when this screen is is + /// The preferred animation style when the status bar appearance changes when this screen is in /// control of the status bar appearance. /// /// Defaults to `.fade`