1 import Foundation
2 import ExpoModulesCore
3 
4 public protocol ScreenOrientationController: AnyObject {
screenOrientationDidChangenull5   func screenOrientationDidChange(_ orientation: UIInterfaceOrientation)
6 }
7 
8 /**
9  This singleton that holds information about desired orientation for every app which uses expo-screen-orientation.
10  Marked @objc and public, because this it is also used in EXAppViewController.
11  */
12 @objc
13 public class ScreenOrientationRegistry: NSObject, UIApplicationDelegate {
14   @objc
15   public static let shared = ScreenOrientationRegistry()
16 
17   public var currentScreenOrientation: UIInterfaceOrientation
18   var orientationControllers: [ScreenOrientationController] = []
19   var controllerInterfaceMasks: [ObjectIdentifier: UIInterfaceOrientationMask] = [:]
20   @objc
21   public weak var currentTraitCollection: UITraitCollection?
22   var lastOrientationMask: UIInterfaceOrientationMask
23   var rootViewController: UIViewController? {
24     let keyWindow = UIApplication
25       .shared
26       .connectedScenes
27       .flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
28       .last { $0.isKeyWindow }
29 
30     return keyWindow?.rootViewController
31   }
32 
33   public var currentOrientationMask: UIInterfaceOrientationMask {
34     var currentOrientationMask: UIInterfaceOrientationMask = []
35 
36     EXUtilities.performSynchronously {
37       currentOrientationMask = self.rootViewController?.supportedInterfaceOrientations ?? []
38     }
39     return currentOrientationMask
40   }
41 
42   private override init() {
43     self.currentScreenOrientation = .unknown
44     self.currentTraitCollection = nil
45     self.lastOrientationMask = []
46 
47     super.init()
48 
49     // This is most likely already executed on the main thread, but we need to be sure
50     RCTExecuteOnMainQueue {
51       UIDevice.current.beginGeneratingDeviceOrientationNotifications()
52     }
53   }
54 
55   /**
56    Called by ScreenOrientationAppDelegate in order to set initial interface orientation.
57    */
58   public func updateCurrentScreenOrientation() {
59     let windows = UIApplication.shared.windows
60     if !windows.isEmpty {
61       self.currentScreenOrientation = windows[0].windowScene?.interfaceOrientation ?? .unknown
62     }
63   }
64 
65   deinit {
66     EXUtilities.performSynchronously {
67       UIDevice.current.endGeneratingDeviceOrientationNotifications()
68     }
69   }
70 
71   // MARK: - Affecting screen orientation
72 
73   /**
74    Rotates the view to currentScreenOrientation or default orientation from the orientationMask.
75    */
76   @objc
77   public func enforceDesiredDeviceOrientation(withOrientationMask orientationMask: UIInterfaceOrientationMask) {
78     var newOrientation = orientationMask.defaultOrientation()
79 
80     if orientationMask.contains(currentScreenOrientation) {
81       newOrientation = currentScreenOrientation
82     }
83 
84     guard newOrientation != .unknown else {
85       return
86     }
87 
88     RCTExecuteOnMainQueue { [weak self] in
89       guard let self = self else {
90         return
91       }
92 
93       if #available(iOS 16.0, *) {
94         let windowScene = self.rootViewController?.view.window?.windowScene
95         windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientationMask))
96         self.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
97       } else {
98         UIDevice.current.setValue(newOrientation.rawValue, forKey: "orientation")
99         UIViewController.attemptRotationToDeviceOrientation()
100       }
101 
102       if self.currentScreenOrientation == .unknown {
103         // CurrentScreenOrientation might be unknown (especially just after launch), but at this point we already know it.
104         // Later the currentScreenOrientation will be updated by the iOS orientation change notifications.
105         self.screenOrientationDidChange(newOrientation)
106       }
107     }
108   }
109 
110   public func setMask(_ mask: UIInterfaceOrientationMask, forController controller: any ScreenOrientationController) {
111     let controllerIdentifier = ObjectIdentifier(controller)
112 
113     controllerInterfaceMasks[controllerIdentifier] = mask
114     enforceDesiredDeviceOrientation(withOrientationMask: mask)
115   }
116 
117   // MARK: - Getters
118 
119   /**
120    Gets the orientationMask for the app. Uses an intersection of all applied orientation masks. Also used for Expo Go in EXAppViewController.
121    */
122   @objc
123   public func requiredOrientationMask() -> UIInterfaceOrientationMask {
124     if controllerInterfaceMasks.isEmpty {
125       return []
126     }
127 
128     // We want to apply an orientation mask which is an intersection of locks applied by the modules.
129     var mask = doesDeviceHaveNotch ? UIInterfaceOrientationMask.allButUpsideDown : UIInterfaceOrientationMask.all
130 
131     for moduleMask in controllerInterfaceMasks {
132       mask = mask.intersection(moduleMask.value)
133     }
134 
135     return mask
136   }
137 
138   // MARK: - Events
139 
140   /**
141    Called by ScreenOrientationViewController when the dimensions of the view change.
142    Also used for Expo Go in EXAppViewController.
143    */
144   @objc
145   public func viewDidTransition(toOrientation orientation: UIInterfaceOrientation) {
146     let currentDeviceOrientation = UIDevice.current.orientation.toInterfaceOrientation()
147     let currentOrientationMask = self.rootViewController?.supportedInterfaceOrientations ?? []
148 
149     var newScreenOrientation = UIInterfaceOrientation.unknown
150 
151     // We need to deduce what is the new screen orientaiton based on currentOrientationMask and new dimensions of the view
152     if orientation.isPortrait {
153       // From trait collection, we know that screen is in portrait or upside down orientation.
154       let portraitMask = currentOrientationMask.intersection([.portrait, .portraitUpsideDown])
155 
156       if portraitMask == .portrait {
157         // Mask allows only proper portrait - we know that the device is in either proper portrait or upside down
158         // we deduce it is proper portrait.
159         newScreenOrientation = .portrait
160       } else if portraitMask == .portraitUpsideDown {
161         // Mask allows only upside down portrait - we know that the device is in either proper portrait or upside down
162         // we deduce it is upside down portrait.
163         newScreenOrientation = .portraitUpsideDown
164       } else if currentDeviceOrientation == .portrait || currentDeviceOrientation == .portraitUpsideDown {
165         // Mask allows portrait or upside down portrait - we can try to deduce orientation
166         // from device orientation.
167         newScreenOrientation = currentDeviceOrientation
168       }
169     } else if orientation.isLandscape {
170       // From trait collection, we know that screen is in landscape left or right orientation.
171       let landscapeMask = currentOrientationMask.intersection(.landscape)
172 
173       if landscapeMask == .landscapeLeft {
174         // Mask allows only proper landscape - we know that the device is in either proper landscape left or right
175         // we deduce it is proper left.
176         newScreenOrientation = .landscapeLeft
177       } else if landscapeMask == .landscapeRight {
178         // Mask allows only landscape right - we know that the device is in either proper landscape left or right
179         // we deduce it is landscape right.
180         newScreenOrientation = .landscapeRight
181       } else if currentDeviceOrientation == .landscapeLeft || currentDeviceOrientation == .landscapeRight {
182         // Mask allows landscape left or right - we can try to deduce orientation
183         // from device orientation.
184         newScreenOrientation = currentDeviceOrientation
185       } else if currentDeviceOrientation == .portrait || currentDeviceOrientation == .portraitUpsideDown {
186         // If the desired orientation is .landscape but the device is in .portrait orientation it will rotate to .landscapeRight
187         newScreenOrientation = .landscapeRight
188       }
189     }
190     screenOrientationDidChange(newScreenOrientation)
191   }
192 
193   @objc
194   public func traitCollectionDidChange(to traitCollection: UITraitCollection) {
195     currentTraitCollection = traitCollection
196   }
197 
198   /**
199    Called at the end of the screen orientation change. Notifies the controllers about the orientation change.
200    */
201   func screenOrientationDidChange(_ newScreenOrientation: UIInterfaceOrientation) {
202     currentScreenOrientation = newScreenOrientation
203 
204     for controller in orientationControllers {
205       controller.screenOrientationDidChange(newScreenOrientation)
206     }
207   }
208 
209   public func registerController(_ controller: ScreenOrientationController) {
210     orientationControllers.append(controller)
211   }
212 
213   public func unregisterController(_ controller: ScreenOrientationController) {
214     let controllerIdentifier = ObjectIdentifier(controller)
215 
216     controllerInterfaceMasks.removeValue(forKey: controllerIdentifier)
217     orientationControllers.removeAll(where: { $0 === controller })
218   }
219 }
220