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
@Statevariable when a new value is received to reflect the changes. - You use the
UIDeviceOrientationaccording 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 orientationI 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:
Environmentgives you access to what is stored underEnvironmentKeybut does not generate observer for its internals (i.e., you would be notified if the value ofEnvironmentKeychanged 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!