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 under EnvironmentKey but does not generate observer for its internals (i.e., you would be notified if the value of EnvironmentKey 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!