Changing orientation for a single screen in SwiftUI

Sponsored

Codemagic logo
Codemagic CI/CD for mobile teams

What do you get when you put love for iOS and DevOps together? Answer: Codemagic CI/CD

Last week, we started to migrate the playback feature in iPlayer to our brand new SwiftUI navigation. Very quickly we ran into an issue with the way our app works on iPhone devices and the inability of easily achieving the same behaviour using SwiftUI.

The iPlayer app only supports portrait orientation for all screens except for one: the video player, which only supports landscape. This behaviour is very common across multiple streaming service apps on iPhone devices such as Amazon Prime or Netflix.

In our legacy UIKit navigation system, we achieve this by supporting all orientations at the app target level and then locking them down to portrait at the root of each UINavigationController. Then, when presenting the video player, we wrap it in a UIViewController which overrides supportedInterfaceOrientations and sets this value to .landscape:

We set out on a mission to replicate this behaviour in our SwiftUI navigation and tried a number of approaches but none of them worked in the way we wanted it to:

  1. Wrapping the app’s root SwiftUI view in a UIViewController with potrait as the only supported orientation and then doing the same for the player screen but with landscape as its only supported orientation.
  2. Using a combination of the above and requestGeometryUpdate to force updates on the video player view.
  3. Rotating the view manually on presentation using the rotationEffect view modifier.

We thought all hope was lost and that we would need to rewrite our whole navigation in UIKit when one of my colleagues came across a thread in Apple’s developer forums. The solution described by Jim Dovey in the thread seemed to do exactly what we needed by subclassing UIHostingController, setting it as the window’s root view controller and dynamically updating its supportedInterfaceOrientations from within a given SwiftUI view by making use of SwiftUI preferences.

⚠️ Disclaimer! All credit for this solution goes to Jim Dovey who wrote the thread on Apple’s Developer forums. In this article, I am trying to document it and show any extra work we had to do to make the solution work. Also, I want to give a huge shoutout to my colleagues Dan Ellis and Dave Burrows as it was a real team effort to get us over this hurdle.

Reverting to UIKit for the app root

The first thing we did, as the thread states, was to go back to UIKit’s way of bootstrapping an application. To do this, we removed the @main decorator from the app’s root View and created an AppDelegate and a SceneDelegate.

The AppDelegate’s only job, now decorated with @main to mark it as the app’s entrypoint, was to define the configuration for connecting to a scene:

AppDelegate.swift
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
}

The SceneDelegate, in turn, needed to create a UIWindow from the provided UIWindowScene and set a root view controller on it:

SceneDelegate.swift
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }

        let window = UIWindow(windowScene: windowScene)
        // We'll replace this in a second...
        window.rootViewController = UIViewController()
        window.makeKeyAndVisible()
        self.window = window
    }
}

Last but not least, we needed to define the scene that we told the AppDelegate to connect to (with configuration name: "DefaultConfiguration") in the app’s Info.plist file:

Info.plist
<!-- ... -->
<dict>
	<key>UIApplicationSceneManifest</key>
	<dict>
		<key>UIApplicationSupportsMultipleScenes</key>
		<false/>
		<key>UISceneConfigurations</key>
		<dict>
			<key>UIWindowSceneSessionRoleApplication</key>
			<array>
				<dict>
					<key>UISceneConfigurationName</key>
					<string>Default Configuration</string>
					<key>UISceneDelegateClassName</key>
					<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
				</dict>
			</array>
		</dict>
	</dict>
</dict>
<!-- ... -->

Subclassing UIHostingController

Once all this setup was done, we needed to create the root view controller for the application. As the thread in Apple’s Developer forum states, we needed to make this a subclass of UIHostingController. This subclassed UIHostingController would then override the supportedInterfaceOrientations property and update it dynamically based on the values passed up by SwiftUI views using a preference.

The subclass would also need to define a RootView to hold the SwiftUI application and have a way of updating the supported orientations in the subclassed UIHostingController through a mediator object called OrientationsHolder.

OrientationLockedController.swift
// This code was taken and slightly modified from the original answer on Apple's Developer Forums
// https://developer.apple.com/forums/thread/125155
class OrientationLockedController<Content: View>: UIHostingController<OrientationLockedController.Root<Content>> {
    class OrientationsHolder {
        var supportedOrientations: UIInterfaceOrientationMask

        init() {
            self.supportedOrientations = UIDevice.current.userInterfaceIdiom == .pad ? .all : .allButUpsideDown
        }
    }

    var orientations: OrientationsHolder!

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        orientations.supportedOrientations
    }

    init(rootView: Content) {
        let orientationsHolder = OrientationsHolder()
        let orientationRoot = Root(contentView: rootView, orientationsHolder: orientationsHolder)
        super.init(rootView: orientationRoot)
        self.orientations = orientationsHolder
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }


    struct Root<Content: View>: View {
        let contentView: Content
        let orientationsHolder: OrientationsHolder

        var body: some View {
            contentView
        }
    }
}

The next step was to replace the window’s rootViewController with the newly created OrientationLockedController, which would lock the orientation of every SwiftUI view in the app to portrait:

SceneDelegate.swift
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }

        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = OrientationLockedController(rootView: ContentView())
        window.makeKeyAndVisible()
        self.window = window
    }
}

Updating the supported orientations from a View

Now that we had a way of locking the orientation of the whole application using a subclass of UIHostingViewController, we needed a way of modifying it on a per view basis. This modification would need to happen from many layers down in the application and bubble all the way up to the OrientationLockedController so that the supportedInterfaceOrientations property on it could be updated.

Making use of the SwiftUI preferences API would allow us to update the supported orientations from any SwiftUI view in the app and listen to it from the Root view. The Root view would then update the supportedInterfaceOrientations property in the OrientationLockedController through the OrientationsHolder mediator class.

Creating a preference is as easy as creating a struct which conforms to the PreferenceKey protocol, giving it a default value and telling it how it should be updated by implementing the protocol’s reduce method:

PreferenceKey.swift
struct SupportedOrientationsPreferenceKey: PreferenceKey {
    static var defaultValue: UIInterfaceOrientationMask {
        UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
    }

    static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) {
        value.formIntersection(nextValue())
    }
}

The Root view would then listen for any changes to this preference key through the onPreferenceChange modifier:

OrientationLockedController.swift
struct Root<Content: View>: View {
    let contentView: Content
    let orientationsHolder: OrientationsHolder

    var body: some View {
        contentView
            .onPreferenceChange(SupportedOrientationsPreferenceKey.self) {
                orientationsHolder.supportedOrientations = $0
            }
    }
}

We then created a small extension to modify the preference key following what the thread suggested:

View+SupportedOrientations.swift
extension View {
    func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View {
        preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations)
    }
}

We thought we were all done at this point and ready to see our landscape view by using the modifier like so…

ContentView.swift
struct ContentView: View {
    @State private var isPresented: Bool = false

    var body: some View {
        Button("I am a portrait view...", action: { isPresented = true })
            .fullScreenCover(isPresented: $isPresented) {
                ZStack {
                    Color.red
                    Text("I am a landscape view yay!!")
                        .background(Color.red)
                }
                .ignoresSafeArea()
                .supportedOrientations(.landscape)
            }
    }
}

But this is what happened instead… 😭

After debugging the code we realised that the onPreferenceChange method at the RootView was not firing when presenting the landscape view. While this didn’t make sense at the time, it does now. The preference gets updated from a view modifier and it needs a re-render of the view to propagate the event upwards to the Root view, which meant that we needed to do some extra work to get this working…

The landscapeFullScreenModifier

We decided to create a ViewModifier to wrap the view being presented and use a @State variable to update the orientation preference key only when the view was on screen. This would allow us to present a view in landscape mode using a modifier similar to SwiftUI’s fullScreenCover while keeping the rest of the app in potrait mode:

LandscapeFullScreenCover.swift
import SwiftUI

struct LandscapeFullScreenCover<CoverContent: View>: ViewModifier {
    @Binding var isPresented: Bool
    @ViewBuilder let coverContent: () -> CoverContent
    @State private var supportedOrientations: UIInterfaceOrientationMask = .portrait

    func body(content: Content) -> some View {
        content
            .fullScreenCover(isPresented: $isPresented) {
                coverContent()
            }
            .onChange(of: isPresented, perform: { supportedOrientations = $0 ? .landscape : .portrait })
            .supportedOrientations(supportedOrientations)
    }
}

extension View {
    func landscapeFullScreenCover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
        modifier(LandscapeFullScreenCover(isPresented: isPresented, coverContent: content))
    }
}

We then used this modifier to present a view in landscape mode:

ContentView.swift
struct ContentView: View {
    @State private var isPresented: Bool = false

    var body: some View {
        Button("I am a portrait view...", action: { isPresented = true })
            .landscapeFullScreenCover(isPresented: $isPresented) {
                ZStack {
                    Color.red
                    Text("I am a landscape view yay!!")
                        .background(Color.red)
                }
                .ignoresSafeArea()
            }
    }
}

Take it with a pinch of salt 🧂

Before you go, I want to stress that while this is the only workaround that we were able to find, it is by no means a robust and future-proof solution. We have found that navigation behaviour in SwiftUI tends to change in every iOS version and changing a single screen from portrait to landscape orientation works well on iOS 16 but not on iOS 15, where you’ll probably want to set the orientation to allow .allButUpsideDown rather than constraining it to .landscape only.

For this reason, I would take what has been discussed in this article with a big pinch of salt and make sure you have sufficient UI/manual tests around the screen you’re locking orientation for.

Nonetheless, this has been a very interesting feature to work on as I have learnt a lot about SwiftUI’s internals and how it lays out its views, as well as understanding how preferences work.