Exploring SwiftUI: Orientation Property Wrapper
I got this case where the app displays the tab bar item with full name in landscape mode and only the icon in portrait mode in a custom tab bar.
Also, as the iPad app doesn’t support multiple windows, the landscape has a custom split view, and the view is in a list in the portrait orientation.
To tackle both cases, I created an @Orientation
property wrapper similar to how you use @Environment(\.dynamicTypeSize)
for read-only purposes.
If you’re looking for the final implementation of the property wrapper, scroll here.
Initial Implementation
Like I’m used to, I searched for “orientation SwiftUI” and got the first answer from Stack Overflow:
- It creates a publisher that listens for changes to the device orientation.
- Updates the local
@State
variable when a new value is received to reflect the changes. - You use the
UIDeviceOrientation
according to needs.
struct TabBarItem: View {
var item: TabItem
@State private var orientation = UIDevice.current.orientation
private let orientationChanged = NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification)
.makeConnectable()
.autoconnect()
var body: some View {
HStack {
Text(item.title)
if orientation.isLandscape {
Image(item.image)
}
}
.onReceive(orientationChanged) { _ in
orientation = UIDevice.current.orientation
}
}
}
When you launch the app, the value of UIDevice.current.orientation
unknown even if you call the method UIDevice.current.beginGeneratingDeviceOrientationNotifications()
.
So, it’s better to get the UIInterfaceOrientation
on launch for such cases:
.onAppear {
if let scene = UIApplication.shared.connectedScenes.first,
let sceneDelegate = scene as? UIWindowScene,
sceneDelegate.interfaceOrientation.isPortrait {
orientation = .portrait
} else {
orientation = .landscapeLeft
}
}
While this solution works, it gets repetitive if you’ve to implement it in many views. So, time to search for a better solution!
Experimenting with Environment
Apple uses the environment to automatically update the value of text sizes and horizontal and vertical size classes. I hoped to achieve something similar as a one-liner solution:
@Environment(\.orientation) var orientation
I started with a class OrientationManager
conforming to ObservableObject
and a @Published
variable type
of the type UIDeviceOrientation
. It implemented a similar logic of checking the interface orientation and then adding an observer for notifying changes about device rotation:
class OrientationManager: ObservableObject {
@Published var type: UIDeviceOrientation = .unknown
private var cancellables: Set<AnyCancellable> = []
init() {
guard let scene = UIApplication.shared.connectedScenes.first,
let sceneDelegate = scene as? UIWindowScene else { return }
let orientation = sceneDelegate.interfaceOrientation
switch orientation {
case .portrait: type = .portrait
case .portraitUpsideDown: type = .portraitUpsideDown
case .landscapeLeft: type = .landscapeLeft
case .landscapeRight: type = .landscapeRight
default: type = .unknown
}
NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification)
.sink() { [weak self] _ in
self?.type = UIDevice.current.orientation
}
.store(in: &cancellables)
}
}
Then, I created a related EnvironmentKey
and extending EnvironmentValues
to add an environment value as orientation
:
struct OrientationKey: EnvironmentKey {
static let defaultValue = OrientationManager()
}
extension EnvironmentValues {
var orientation: OrientationManager {
get { return self[OrientationKey.self] }
set { self[OrientationKey.self] = newValue }
}
}
To use it in the item view:
struct TabBarItem: View {
var item: TabItem
@Environment(\.orientation) var orientation
var body: some View {
HStack {
Text(item.title)
if orientation.type.isLandscape {
Image(item.image)
}
}
}
}
But, wait. After running the app, the orientation doesn’t change. After digging through it, I learned from Asperi that:
Environment
gives you access to what is stored underEnvironmentKey
but does not generate observer for its internals (i.e., you would be notified if the value ofEnvironmentKey
changed itself, but in your case, it is the instance, and its reference stored under a key is not changed).
I’ve to manually observe the value, which is almost the same as the previous implementation. Let’s do something better!
Orientation Property Wrapper
I stumbled upon Custom Property Wrappers for SwiftUI by Dave DeLong while searching for observing Environment
values.
It gave a great headstart by providing me with the DynamicProperty
protocol. From the documentation:
An interface for a stored variable that updates an external property of a view.
I added a singleton to OrientationManager
, so there’s only one instance throughout the app. I know, I used something that has a bad reputation.
static let shared = OrientationManager()
But, in this case, it serves its purpose, as I’m not accessing the instance directly but only want to access the wrappedValue
.
It results with Orientation
, a property wrapper with read-only wrappedValue
, a UIDeviceOrientation
enum.
Update: I read Donny’s post on Writing custom property wrappers for SwiftUI and realized that there’s only one path, so I can omit the key path.
@propertyWrapper struct Orientation: DynamicProperty {
@StateObject private var manager = OrientationManager.shared
var wrappedValue: UIDeviceOrientation {
manager.type
}
}
Now, finally, it is as simple to use as:
struct TabBarItem: View {
var item: TabItem
@Orientation var orientation
var body: some View {
HStack {
Text(item.title)
if orientation.isLandscape {
Image(item.image)
}
}
}
}
Conclusion
This pursuit of experimentation taught me a lot about Environment
and property wrappers.
While I still yearn for @Environment(\.orientation)
, I would love to know about your experience of creating something similar!
Thanks for reading, and I hope you’re enjoying it!