Skip to content

Commit e3a157c

Browse files
feat: ObservableScreen view controller preferences (#345)
Adds a bunch of optional methods to the `ObservableScreen` protocol to customize view controller behavior: - preferredStatusBarStyle - prefersStatusBarHidden - preferredStatusBarUpdateAnimation - supportedInterfaceOrientations - preferredScreenEdgesDeferringSystemGestures - prefersHomeIndicatorAutoHidden - pressesBegan - accessibilityPerformEscape Co-authored-by: johnnewman-square <[email protected]>
1 parent baaf536 commit e3a157c

File tree

3 files changed

+508
-60
lines changed

3 files changed

+508
-60
lines changed

WorkflowSwiftUI/Sources/ObservableScreen.swift

Lines changed: 242 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,38 +24,164 @@ public protocol ObservableScreen: Screen {
2424
/// The type of the model that this screen observes.
2525
associatedtype Model: ObservableModel
2626

27-
/// The sizing options for the screen.
28-
var sizingOptions: SwiftUIScreenSizingOptions { get }
2927
/// The model that this screen observes.
3028
var model: Model { get }
3129

30+
// MARK: - Optional configuration
31+
32+
/// The sizing options for the screen.
33+
var sizingOptions: SwiftUIScreenSizingOptions { get }
34+
35+
/// The preferred status bar style when this screen is in control of the status bar appearance.
36+
///
37+
/// Defaults to `.default`.
38+
func preferredStatusBarStyle(in context: ObservableScreenContext) -> UIStatusBarStyle
39+
40+
/// If the status bar is shown or hidden when this screen is in control of
41+
/// the status bar appearance.
42+
///
43+
/// Defaults to `false`
44+
func prefersStatusBarHidden(in context: ObservableScreenContext) -> Bool
45+
46+
/// The preferred animation style when the status bar appearance changes when this screen is in
47+
/// control of the status bar appearance.
48+
///
49+
/// Defaults to `.fade`
50+
func preferredStatusBarUpdateAnimation(
51+
in context: ObservableScreenContext
52+
) -> UIStatusBarAnimation
53+
54+
/// The supported interface orientations of this screen.
55+
///
56+
/// Defaults to all orientations for iPad, and portrait / portrait upside down for iPhone.
57+
func supportedInterfaceOrientations(
58+
in context: ObservableScreenContext
59+
) -> UIInterfaceOrientationMask
60+
61+
/// Which screen edges should defer system gestures when this screen is in control.
62+
///
63+
/// Defaults to `[]` (none).
64+
func preferredScreenEdgesDeferringSystemGestures(
65+
in context: ObservableScreenContext
66+
) -> UIRectEdge
67+
68+
/// If the home indicator should be auto hidden or not when this screen is in control of the
69+
/// home indicator appearance.
70+
///
71+
/// Defaults to `false`
72+
func prefersHomeIndicatorAutoHidden(in context: ObservableScreenContext) -> Bool
73+
74+
/// Invoked when a physical button is pressed, such as one of a hardware keyboard. Return `true`
75+
/// if the event is handled by the screen, otherwise `false` to forward the message along the
76+
/// responder chain.
77+
///
78+
/// Defaults to `false` for all events.
79+
func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) -> Bool
80+
81+
/// This method is called when VoiceOver is enabled and the escape gesture is performed (a
82+
/// 2-finger Z shape).
83+
///
84+
/// Implement this method if your screen is a modal that can be dismissed without an explicit
85+
/// action. For example, most modals with a close button should implement this method and have
86+
/// the same behavior as tapping close. Return `true` if this method did dismiss the modal.
87+
///
88+
/// Defaults to `false`.
89+
func accessibilityPerformEscape() -> Bool
90+
3291
/// Constructs the root view for this screen. This is only called once to initialize the view.
3392
/// After the initial construction, the view will be updated by injecting new values into the
3493
/// store.
3594
@ViewBuilder
3695
static func makeView(store: Store<Model>) -> Content
3796
}
3897

98+
/// Context that holds view values for `ObservableScreen` customization hooks.
99+
public struct ObservableScreenContext {
100+
/// The view environment of the associated view controller.
101+
public let environment: ViewEnvironment
102+
103+
/// The safe area insets of this screen in its current position.
104+
public let safeAreaInsets: UIEdgeInsets
105+
106+
/// The size of the view controller's containing window, if available.
107+
public let windowSize: CGSize?
108+
109+
public init(
110+
environment: ViewEnvironment,
111+
safeAreaInsets: UIEdgeInsets,
112+
windowSize: CGSize? = nil
113+
) {
114+
self.environment = environment
115+
self.safeAreaInsets = safeAreaInsets
116+
self.windowSize = windowSize
117+
}
118+
}
119+
39120
extension ObservableScreen {
40-
public var sizingOptions: SwiftUIScreenSizingOptions { [] }
121+
public var sizingOptions: SwiftUIScreenSizingOptions {
122+
[]
123+
}
124+
125+
public func preferredStatusBarStyle(in context: ObservableScreenContext) -> UIStatusBarStyle {
126+
.default
127+
}
128+
129+
public func prefersStatusBarHidden(in context: ObservableScreenContext) -> Bool {
130+
false
131+
}
132+
133+
public func preferredStatusBarUpdateAnimation(
134+
in context: ObservableScreenContext
135+
) -> UIStatusBarAnimation {
136+
.fade
137+
}
138+
139+
public func supportedInterfaceOrientations(
140+
in context: ObservableScreenContext
141+
) -> UIInterfaceOrientationMask {
142+
if UIDevice.current.userInterfaceIdiom == .pad {
143+
.all
144+
} else {
145+
[.portrait, .portraitUpsideDown]
146+
}
147+
}
148+
149+
public func preferredScreenEdgesDeferringSystemGestures(
150+
in context: ObservableScreenContext
151+
) -> UIRectEdge {
152+
[]
153+
}
154+
155+
public func prefersHomeIndicatorAutoHidden(in context: ObservableScreenContext) -> Bool {
156+
false
157+
}
158+
159+
public func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) -> Bool {
160+
false
161+
}
162+
163+
public func accessibilityPerformEscape() -> Bool {
164+
false
165+
}
41166
}
42167

43168
extension ObservableScreen {
44169
public func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
45170
ViewControllerDescription(
46-
type: ModeledHostingController<Model, Content>.self,
171+
performInitialUpdate: false,
172+
type: ObservableScreenViewController<Self, Content>.self,
47173
environment: environment,
48174
build: {
49175
let (store, setModel) = Store.make(model: model)
50-
return ModeledHostingController(
176+
return ObservableScreenViewController(
51177
setModel: setModel,
52178
viewEnvironment: environment,
53179
rootView: Self.makeView(store: store),
54-
sizingOptions: sizingOptions
180+
screen: self
55181
)
56182
},
57183
update: { hostingController in
58-
hostingController.setModel(model)
184+
hostingController.update(screen: self)
59185
// ViewEnvironment updates are handled by the ModeledHostingController internally
60186
}
61187
)
@@ -89,33 +215,35 @@ private final class ViewEnvironmentHolder: ObservableObject {
89215
}
90216
}
91217

92-
private final class ModeledHostingController<Model, Content: View>: UIHostingController<ModifiedContent<Content, ViewEnvironmentModifier>>, ViewEnvironmentObserving {
93-
let setModel: (Model) -> Void
218+
private final class ObservableScreenViewController<ScreenType: ObservableScreen, Content: View>:
219+
UIHostingController<ModifiedContent<Content, ViewEnvironmentModifier>>,
220+
ViewEnvironmentObserving
221+
{
222+
typealias Model = ScreenType.Model
94223

224+
private let setModel: (Model) -> Void
95225
private let viewEnvironmentHolder: ViewEnvironmentHolder
96226

97-
var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions {
98-
didSet {
99-
updateSizingOptionsIfNeeded()
100-
if isViewLoaded {
101-
setNeedsLayoutBeforeFirstLayoutIfNeeded()
102-
}
103-
}
104-
}
105-
227+
private var screen: ScreenType
106228
private var hasLaidOutOnce = false
107229
private var maxFrameWidth: CGFloat = 0
108230
private var maxFrameHeight: CGFloat = 0
109231

232+
private var previousPreferredStatusBarStyle: UIStatusBarStyle?
233+
private var previousPrefersStatusBarHidden: Bool?
234+
private var previousSupportedInterfaceOrientations: UIInterfaceOrientationMask?
235+
private var previousPreferredScreenEdgesDeferringSystemGestures: UIRectEdge?
236+
private var previousPrefersHomeIndicatorAutoHidden: Bool?
237+
110238
init(
111239
setModel: @escaping (Model) -> Void,
112240
viewEnvironment: ViewEnvironment,
113241
rootView: Content,
114-
sizingOptions swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions
242+
screen: ScreenType
115243
) {
116244
self.setModel = setModel
117245
self.viewEnvironmentHolder = ViewEnvironmentHolder(viewEnvironment: viewEnvironment)
118-
self.swiftUIScreenSizingOptions = swiftUIScreenSizingOptions
246+
self.screen = screen
119247

120248
super.init(
121249
rootView: rootView
@@ -130,6 +258,12 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
130258
fatalError("not implemented")
131259
}
132260

261+
func update(screen: ScreenType) {
262+
self.screen = screen
263+
setModel(screen.model)
264+
updateViewControllerContainmentForwarding()
265+
}
266+
133267
override func viewDidLoad() {
134268
super.viewDidLoad()
135269

@@ -146,7 +280,7 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
146280

147281
defer { hasLaidOutOnce = true }
148282

149-
if swiftUIScreenSizingOptions.contains(.preferredContentSize) {
283+
if screen.sizingOptions.contains(.preferredContentSize) {
150284
// Use the largest frame ever laid out in as a constraint for preferredContentSize
151285
// measurements.
152286
let width = max(view.frame.width, maxFrameWidth)
@@ -175,6 +309,8 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
175309
if preferredContentSize != size {
176310
preferredContentSize = size
177311
}
312+
} else if preferredContentSize != .zero {
313+
preferredContentSize = .zero
178314
}
179315
}
180316

@@ -184,16 +320,97 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
184320
applyEnvironmentIfNeeded()
185321
}
186322

323+
override var preferredStatusBarStyle: UIStatusBarStyle {
324+
screen.preferredStatusBarStyle(in: makeCurrentContext())
325+
}
326+
327+
override var prefersStatusBarHidden: Bool {
328+
screen.prefersStatusBarHidden(in: makeCurrentContext())
329+
}
330+
331+
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
332+
screen.preferredStatusBarUpdateAnimation(in: makeCurrentContext())
333+
}
334+
335+
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
336+
screen.supportedInterfaceOrientations(in: makeCurrentContext())
337+
}
338+
339+
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
340+
screen.preferredScreenEdgesDeferringSystemGestures(in: makeCurrentContext())
341+
}
342+
343+
override var prefersHomeIndicatorAutoHidden: Bool {
344+
screen.prefersHomeIndicatorAutoHidden(in: makeCurrentContext())
345+
}
346+
347+
override func accessibilityPerformEscape() -> Bool {
348+
screen.accessibilityPerformEscape()
349+
}
350+
351+
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
352+
let handled = screen.pressesBegan(presses, with: event)
353+
if !handled {
354+
super.pressesBegan(presses, with: event)
355+
}
356+
}
357+
358+
private func makeCurrentContext() -> ObservableScreenContext {
359+
ObservableScreenContext(
360+
environment: environment,
361+
safeAreaInsets: viewIfLoaded?.safeAreaInsets ?? .zero,
362+
windowSize: view.window?.bounds.size
363+
)
364+
}
365+
187366
private func updateSizingOptionsIfNeeded() {
188-
if !swiftUIScreenSizingOptions.contains(.preferredContentSize),
189-
preferredContentSize != .zero
190-
{
367+
if !screen.sizingOptions.contains(.preferredContentSize), preferredContentSize != .zero {
191368
preferredContentSize = .zero
192369
}
193370
}
194371

372+
private func updateViewControllerContainmentForwarding() {
373+
// Update status bar.
374+
let preferredStatusBarStyle = preferredStatusBarStyle
375+
let prefersStatusBarHidden = prefersStatusBarHidden
376+
if (previousPreferredStatusBarStyle != nil && previousPreferredStatusBarStyle != preferredStatusBarStyle) ||
377+
(previousPrefersStatusBarHidden != nil && previousPrefersStatusBarHidden != prefersStatusBarHidden)
378+
{
379+
setNeedsStatusBarAppearanceUpdate()
380+
}
381+
previousPreferredStatusBarStyle = preferredStatusBarStyle
382+
previousPrefersStatusBarHidden = prefersStatusBarHidden
383+
384+
// Update interface orientation.
385+
let supportedInterfaceOrientations = supportedInterfaceOrientations
386+
if previousSupportedInterfaceOrientations != nil,
387+
previousSupportedInterfaceOrientations != supportedInterfaceOrientations
388+
{
389+
setNeedsUpdateOfSupportedInterfaceOrientationsAndRotateIfNeeded()
390+
}
391+
previousSupportedInterfaceOrientations = supportedInterfaceOrientations
392+
393+
// Update screen edges deferring system gestures.
394+
let preferredScreenEdgesDeferringSystemGestures = preferredScreenEdgesDeferringSystemGestures
395+
if previousPreferredScreenEdgesDeferringSystemGestures != nil,
396+
previousPreferredScreenEdgesDeferringSystemGestures != preferredScreenEdgesDeferringSystemGestures
397+
{
398+
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
399+
}
400+
previousPreferredScreenEdgesDeferringSystemGestures = preferredScreenEdgesDeferringSystemGestures
401+
402+
// Update home indicator visibility.
403+
let prefersHomeIndicatorAutoHidden = prefersHomeIndicatorAutoHidden
404+
if previousPrefersHomeIndicatorAutoHidden != nil,
405+
previousPrefersHomeIndicatorAutoHidden != prefersHomeIndicatorAutoHidden
406+
{
407+
setNeedsUpdateOfHomeIndicatorAutoHidden()
408+
}
409+
previousPrefersHomeIndicatorAutoHidden = prefersHomeIndicatorAutoHidden
410+
}
411+
195412
private func setNeedsLayoutBeforeFirstLayoutIfNeeded() {
196-
if swiftUIScreenSizingOptions.contains(.preferredContentSize), !hasLaidOutOnce {
413+
if screen.sizingOptions.contains(.preferredContentSize), !hasLaidOutOnce {
197414
// Without manually calling setNeedsLayout here it was observed that a call to
198415
// layoutIfNeeded() immediately after loading the view would not perform a layout, and
199416
// therefore would not update the preferredContentSize in viewDidLayoutSubviews().

0 commit comments

Comments
 (0)