Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 242 additions & 25 deletions WorkflowSwiftUI/Sources/ObservableScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,38 +24,164 @@ 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 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<UIPress>, 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.
@ViewBuilder
static func makeView(store: Store<Model>) -> 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<UIPress>, with event: UIPressesEvent?) -> Bool {
false
}

public func accessibilityPerformEscape() -> Bool {
false
}
}

extension ObservableScreen {
public func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
ViewControllerDescription(
type: ModeledHostingController<Model, Content>.self,
performInitialUpdate: false,
type: ObservableScreenViewController<Self, Content>.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
}
)
Expand Down Expand Up @@ -89,33 +215,35 @@ private final class ViewEnvironmentHolder: ObservableObject {
}
}

private final class ModeledHostingController<Model, Content: View>: UIHostingController<ModifiedContent<Content, ViewEnvironmentModifier>>, ViewEnvironmentObserving {
let setModel: (Model) -> Void
private final class ObservableScreenViewController<ScreenType: ObservableScreen, Content: View>:
UIHostingController<ModifiedContent<Content, ViewEnvironmentModifier>>,
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
Expand All @@ -130,6 +258,12 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
fatalError("not implemented")
}

func update(screen: ScreenType) {
self.screen = screen
setModel(screen.model)
updateViewControllerContainmentForwarding()
}

override func viewDidLoad() {
super.viewDidLoad()

Expand All @@ -146,7 +280,7 @@ private final class ModeledHostingController<Model, Content: View>: 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)
Expand Down Expand Up @@ -175,6 +309,8 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
if preferredContentSize != size {
preferredContentSize = size
}
} else if preferredContentSize != .zero {
preferredContentSize = .zero
}
}

Expand All @@ -184,16 +320,97 @@ private final class ModeledHostingController<Model, Content: View>: 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<UIPress>, 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().
Expand Down
Loading
Loading