Changing orientation for a single screen in SwiftUI
Runway handles the release coordination and busywork so you can focus on building great apps. You do the building, we'll do the shipping.
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:
- Wrapping the app’s root SwiftUI view in a
UIViewController
withpotrait
as the only supported orientation and then doing the same for the player screen but withlandscape
as its only supported orientation. - Using a combination of the above and requestGeometryUpdate to force updates on the video player view.
- 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:
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:
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:
<!-- ... -->
<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
.
// 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:
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:
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:
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:
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…
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:
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:
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.