Orientation and rotation detection in SwiftUI for iOS... cough... with UIKit
Problem
For an app I was working on (try it out, its free!) I needed to know the rotation direction of the device when it switched between orientations, so I could rotate a 2d array. This held the state of some blocks on screen, so if rotated the wrong way, the experience would be quite jarring!
Firstly, as noted in this forum post in SwiftUI it is possible to get the vertical and horizontal size class like so:
@Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass?
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
But this requires us to use some heuristics to determine if the device orientation is landscape or not, plus we won't know if that landscape is left or right so we can't determine the direction it came from. Taking into account that when on iPad these size classes will report different values when the app is not full screen, this becomes difficult. So as someone says in the same post, we need UIKit!
Solution
Making a new Swift file, lets create a new class that will monitor the orientation changes of the device and calculate the rotation direction.
import UIKit
import SwiftUI
@MainActor
@Observable
public final class OrientationMonitor {
// 1
public enum RotationDirection: String {
case anticlockwise
case clockwise
case unknown
}
// 2
public private(set) var current: UIDeviceOrientation
public private(set) var lastRotation: RotationDirection
// 3
public init() {
current = UIDevice.current.orientation
lastRotation = .unknown
// 4
Task {
for await notification in NotificationCenter.default.notifications(named: UIDevice.orientationDidChangeNotification) {
guard let device = notification.object as? UIDevice else { return }
let newOrientation = device.orientation
let direction = calculateRotationDirection(
newOrientation: newOrientation,
currentOrientation: current
)
current = newOrientation
lastRotation = direction
}
}
}
// 5
private func calculateRotationDirection(
newOrientation: UIDeviceOrientation,
currentOrientation: UIDeviceOrientation
) -> RotationDirection {
// More on this later
}
}
The OrientationMonitor class uses the @Observable macro, so that a SwiftUI view can observe changes. I have also isolated usage to the MainActor, which makes things simpler when dealing with UIDevice, which is also MainActor isolated.
Going through the code:
-
Create a RotationDirection enum to represent the rotation data. I chose clockwise/anticlockwise as best to represent this. An unknown case is required, as on startup we only know thw current orientation, so there is no rotation direction. But it is also possible to get an unknown value back from UIDeviceOrientation, which needs to be handled. String conformance is only needed for testing later, disregard if not required.
-
Two properties to hold the current orientation and last known rotation. These are public so will be observable by the SwiftUI.
-
Make an init that sets the initial value of current orientation to that of UIDevice.current and an initial value of unknown for lastRotation.
-
Monitor the orientationDidChangeNotification notification from
NotificationCenter
, which returns anAsyncSequence
of Notification objects, with a new notification for every orientation change. As AsyncSequence is asynchronous, this needs to be wrapped in a Task. Here we can guard that the received notification.object is of typeUIDevice
, then calculate the rotation direction passing in the new orientation from the notification and the current orientation. Finally setting the current orientation to the newly received value and lastRotation to the computed rotation. -
I've left the
calculateRotationDirection
function until last, as its quite large but relatively simple. To work out which way the device was rotated we switch over the newOrientationUIDeviceOrientation
enum. For each case, then switch over the currentOrientationUIDeviceOrientation
enum, setting the appropriate rotation to the direction variable for each.
For example, if the new orientation is landscapeRight and the currentOrientation is portrait, we know the device was rotated 90 clockwise. Then all is left to do is complete the mappings. I ignored faceUp and faceDown, choosing to set unknown if that occurs, as we can't really deduce from those orientations a direction on a 2D plane, which is what I built this for.
private func calculateRotationDirection(
newOrientation: UIDeviceOrientation,
currentOrientation: UIDeviceOrientation
) -> RotationDirection {
var direction: RotationDirection = .unknown
switch newOrientation {
case .unknown:
return .unknown
case .portrait:
switch currentOrientation {
case .portrait:
direction = .unknown
case .portraitUpsideDown:
direction = .unknown
case .landscapeLeft:
direction = .clockwise
case .landscapeRight:
direction = .anticlockwise
case .unknown, .faceUp, .faceDown:
direction = .unknown
@unknown default:
direction = .unknown
}
case .portraitUpsideDown:
switch currentOrientation {
case .portrait:
direction = .unknown
case .portraitUpsideDown:
direction = .unknown
case .landscapeLeft:
direction = .anticlockwise
case .landscapeRight:
direction = .clockwise
case .unknown, .faceUp, .faceDown:
direction = .unknown
@unknown default:
direction = .unknown
}
case .landscapeLeft:
switch currentOrientation {
case .portrait:
direction = .anticlockwise
case .portraitUpsideDown:
direction = .clockwise
case .landscapeLeft:
direction = .unknown
case .landscapeRight:
direction = .unknown
case .unknown, .faceUp, .faceDown:
direction = .unknown
@unknown default:
direction = .unknown
}
case .landscapeRight:
switch currentOrientation {
case .portrait:
direction = .clockwise
case .portraitUpsideDown:
direction = .anticlockwise
case .landscapeLeft:
direction = .unknown
case .landscapeRight:
direction = .unknown
case .unknown, .faceUp, .faceDown:
direction = .unknown
@unknown default:
direction = .unknown
}
case .faceDown, .faceUp:
break
@unknown default:
break
}
return direction
}
I then added this extension on UIDeviceOrientation
so I could display the current detected orientation on screen.
extension UIDeviceOrientation {
var displayValue: String {
switch self {
case .unknown:
"unknown"
case .portrait:
"portrait"
case .portraitUpsideDown:
"portraitUpsideDown"
case .landscapeLeft:
"landscapeLeft"
case .landscapeRight:
"landscapeRight"
case .faceUp:
"faceUp"
case .faceDown:
"faceDown"
@unknown default:
"unknown"
}
}
}
With all of that done, the OrientationMonitor can be used in a SwiftUI View quite easily. Remember though that the monitor will only be calculating rotation directions from the orientation changes once it has been initialised. So make sure this is before your immediate use case if the value is needed immediately.
import SwiftUI
struct OrientationView: View {
@State var orientation = OrientationMonitor()
var body: some View {
VStack {
Text(orientation.current.displayValue)
Text(orientation.lastRotation.rawValue)
}
.padding()
}
}
When run on a device or sim you should see something like the below.
Thank you for reading. Code available here.