在 SwiftUI 中实现页面的方向锁定

在 SwiftUI 中实现页面的方向锁定

在许多情况下,我们希望某一个视图能够锁定在一个特定的方向下,例如:相机页面等

本文将分享一种我自己捣腾出来的一种实现方式以及背后的逻辑。

本文仅适用于 iOS

实现逻辑

思路是使用 @UIApplicationDelegateAdaptor(AppDelegate.self) 配合 AppDelegate 中的 func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask 来实现方向的锁定。

在需要锁定方向的 View 中,修改 AppDelegate 中的 orientationLock 并重新返回一个全局的 UIInterfaceOrientationMask

退出锁定的 View 之后,只需要还原 orientationLock 并重新返回 UIInterfaceOrientationMask 即可。

踩过的坑

  • @UIApplicationDelegateAdaptor 需要创建在程序入口处,即 @main 对应的 App Protocol 下,否则,AppDelegate 不起作用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import SwiftUI

    @main
    struct TestApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
    WindowGroup {
    ContentView()
    }
    }
    }

  • 在 iOS 15 及以下和 iOS 16+ 的旋转策略是不同的,即系统调用 func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask 的时机和需要做的事情是不同的。

    • iOS 15 及以下:在每次转动屏幕的时候都会调用该方法重新评估以决定是否完成 UI 旋转,如果当前的设备方向不在 UIInterfaceOrientationMask 中,也不会自动将屏幕旋转至正确的方向。

      因此,除了要设定好 supportedInterfaceOrientations 之外,还需要判断下当前的屏幕方向,根据具体情况旋转到正确的方向上。

    • iOS 16+:在屏幕旋转的时候并不会再调用 func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask 了,因此,旋转设备时系统不会一直刷新 UIInterfaceOrientationMask

      iOS 16 屏幕旋转适配 找到了解决方案:通过在 rootViewController 中调用 setNeedsUpdateOfSupportedInterfaceOrientations() 即可重新调用该方法更新 UIInterfaceOrientationMask

      实测发现,若当前的设备方向不在 UIInterfaceOrientationMask 中,会自动调整。

    • 对于 iOS 15 及以下还有一个 bug,如果设备方向没有发生改变而方向锁发生了改变(例如:设备是 .portrait,方向锁从 .landscapeLeft 变到 .portrait 时),则此时无法自动旋转到正确的方向。

      原因是:虽然程序化执行了方向更新,但是其前后结果一致(设备方向实际不发生变化),系统就不会重新获取 UIInterfaceOrientationMask ,即保持了原来的 Mask 没有更新。

      解决方案是:在最后使用 NotificationCenter.default.post(name: UIDevice.orientationDidChangeNotification, object: nil) 手动发送屏幕旋转的通知,让系统重新获取 UIInterfaceOrientationMask

API 封装

完成了可行性验证之后决定做成一个可以在任何视图下使用的 View Modifier,其中需要实现以下功能:

  1. 单个页面的方向锁
  2. 能够设定默认方向遮罩,用于恢复 / 解锁某个页面设定的方向锁
  3. 由数据驱动,支持程序化改变该页面的方向

因为要实现数据驱动,因此不能只是简单的使用 onAppearonDisappear,当数据变动的时候也需要响应。

这里我选择使用 Preference + onPreferenceChange 来实现(这里并不是只能用 Preference,也可以使用 onChangetask(id:)

我们将 AppDelegateView + Extension 合并在一起,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import SwiftUI

class AppDelegate: NSObject, UIApplicationDelegate {
static var defaultOrientation = UIInterfaceOrientationMask.allButUpsideDown
static var orientationLock = UIInterfaceOrientationMask.allButUpsideDown

func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
AppDelegate.orientationLock
}
}

// MARK: - Orientation Lock Preferences

struct DefaultOrientationMask: PreferenceKey {
static var defaultValue: UIInterfaceOrientationMask { UIDevice.current.userInterfaceIdiom == .pad ? .all : .allButUpsideDown }

static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) {
value = nextValue()
}
}

struct DeviceOrientationMask: PreferenceKey {
static var defaultValue: UIInterfaceOrientationMask { UIDevice.current.userInterfaceIdiom == .pad ? .all : .allButUpsideDown }

static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) {
value = nextValue()
}
}

// MARK: - View + Orientation Lock

extension View {
private func updateOrientation(_ orientation: UIInterfaceOrientationMask) {
UIInterfaceOrientation.updateOrientation(orientation)
}

func deviceOrientation(_ orientation: UIInterfaceOrientationMask) -> some View {
preference(
key: DeviceOrientationMask.self,
value: orientation
)
.onPreferenceChange(DeviceOrientationMask.self, perform: updateOrientation(_:))
.onAppear {
updateOrientation(orientation)
}
.onDisappear {
updateOrientation(AppDelegate.defaultOrientation)
}
}

func defaultOrientationMask(_ orientation: UIInterfaceOrientationMask) -> some View {
preference(
key: DefaultOrientationMask.self,
value: orientation
)
.onPreferenceChange(DefaultOrientationMask.self) {
AppDelegate.defaultOrientation = $0
/// `defaultOrientation` == `orientationLock` means
/// there is no other orientation lockers been activated. In this case,
/// if the default orientation changes, we need to update current orientation.
guard AppDelegate.defaultOrientation == AppDelegate.orientationLock else { return }
updateOrientation($0)
}
.onAppear {
updateOrientation(orientation)
}
}
}

// MARK: - UIInterfaceOrientation + Update

extension UIInterfaceOrientation {
init?(deviceOrientation: UIDeviceOrientation) {
let orientation: UIInterfaceOrientation = {
switch deviceOrientation {
case .portraitUpsideDown: UIInterfaceOrientation.portraitUpsideDown
case .landscapeLeft: UIInterfaceOrientation.landscapeLeft
case .landscapeRight: UIInterfaceOrientation.landscapeRight
default: UIInterfaceOrientation.portrait
}
}()
self.init(rawValue: orientation.rawValue)
}

static func updateOrientation(_ orientation: UIInterfaceOrientationMask) {
AppDelegate.orientationLock = orientation
// Tells System to re-call
// `application(_:supportedInterfaceOrientationsFor)` method
if #available(iOS 16.0, *) {
let scenes = UIApplication.shared.connectedScenes
let windowScene = scenes.first as? UIWindowScene
let window = windowScene?.windows.first
window?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
} else {
// Manually rotate your screen and refresh InterfaceMask.
let newOrientation: UIInterfaceOrientation = {
switch orientation {
case .portrait: return .portrait
case .landscapeLeft: return .landscapeLeft
case .landscapeRight: return .landscapeRight
case .portraitUpsideDown: return .portraitUpsideDown
default:
let current = UIInterfaceOrientation(deviceOrientation: UIDevice.current.orientation)!
guard orientation != .all else { return current }
if orientation == .allButUpsideDown {
return current == .portraitUpsideDown ? .portrait : current
}
if current != .landscapeLeft && current != .landscapeRight {
return .landscapeLeft
} else {
return current
}
}
}()
UIDevice.current.setValue(newOrientation.rawValue, forKey: "orientation")
// Always send a notification in case the device orientation doesn't change
NotificationCenter.default.post(name: UIDevice.orientationDidChangeNotification, object: nil)
}
}
}

在 SwiftUI 中实现页面的方向锁定

https://liyanan2004.github.io/lock-orientation-in-swiftui/

作者

LiYanan

发布于

2023-06-19

更新于

2023-06-19

许可协议

评论