I have a SwiftUI application in development, and for most screens, I'm fine with them being either landscape or portrait, and making designs for each orientation. However, for some screens, I would like to only allow the portrait orientation, and a few in only landscape. Essentially, something like the following StackOverflow post, but for SwiftUI:
https://stackoverflow.com/questions/25606442/how-to-lock-portrait-orientation-for-only-main-view-using-swift
Anything like this for SwiftUI yet?
This would make for a good bug report, because Apple could so easily build this into SwiftUI. Still, after some experimenting and pulling of hair, I have a solution for you.
UIViewController
has a property, supportedInterfaceOrientations
, that is used to specify what you need. However, it's only requested of the root view controller, which in a SwiftUI view is an instance of UIHostingController
. It might be possible to drop an implementation of that property onto UIHostingController
with an extension, but we don't really know (Apple may have written one already). So, it seem like the only route is to create a subclass of UIHostingController
that implements this value.Next, you need a way of propagating it from your lower views. Happily, SwiftUI has our backs here, and we can define a type conforming to
PreferenceKey
wrapping the orientation, and then use the .preference()
and .onPreferenceChanged()
view operators to publish and react to it. PreferenceKey
value types need to be reduced down to a single value from across all views, so it's quite handy that UIInterfaceOrientationMask
is an OptionSet
; we can reduce multiple values into an ever-contracting set of supported orientations via formIntersection()
.Now, you need a
View
type to call onPreferenceChange()
, and at the same time you can't reference any member variables in your new root controller's initializer before you call super.init(rootView:)
, which is a pain—you can't just use rootView.onPreferenceChange()
. Additionally, we don't know the exact type returned from onPreferenceChange()
, and UIHostingController
is a generic type where we'd need to specify that type accurately, so that's an unworkable route anyway.So, what I settled on was a box type—a class (reference) type containing a value. The initializer of the new root controller creates one on the stack and passes it down into a special generic view that wraps the input
rootView
and this box, and which uses onPreferenceChange()
to set the value inside the box to the resolved value. The controller then calls super.init()
with this new type (which is definable as Root<Content>
, so no opaque-type generic problems there), and assigns the box to an implicitly-unwrapped-optional member variable afterwards, before leaving its initializer.Lastly, a custom method in a
View
extension wraps the whole thing (from the client's perspective) in a simple .supportedOrientations()
operation on a view. The only remaining change is to use the new controller as the root view controller in your SceneDelegate.
Here's the code:
import SwiftUI
struct SupportedOrientationsPreferenceKey: PreferenceKey {
typealias Value = UIInterfaceOrientationMask
static var defaultValue: UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .pad {
return .all
}
else {
return .allButUpsideDown
}
}
static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) {
// use the most restrictive set from the stack
value.formIntersection(nextValue())
}
}
/// Use this in place of `UIHostingController` in your app's `SceneDelegate`.
///
/// Supported interface orientations come from the root of the view hierarchy.
class OrientationLockedController<Content: View>: UIHostingController<OrientationLockedController.Root<Content>> {
class Box {
var supportedOrientations: UIInterfaceOrientationMask
init() {
self.supportedOrientations =
UIDevice.current.userInterfaceIdiom == .pad
? .all
: .allButUpsideDown
}
}
var orientations: Box!
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
orientations.supportedOrientations
}
init(rootView: Content) {
let box = Box()
let orientationRoot = Root(contentView: rootView, box: box)
super.init(rootView: orientationRoot)
self.orientations = box
}
@objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
struct Root<Content: View>: View {
let contentView: Content
let box: Box
var body: some View {
contentView
.onPreferenceChange(SupportedOrientationsPreferenceKey.self) { value in
// Update the binding to set the value on the root controller.
self.box.supportedOrientations = value
}
}
}
}
extension View {
func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View {
// When rendered, export the requested orientations upward to Root
preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations)
}
}
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.supportedOrientations(.portrait)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Meanwhile, in SceneDelegate.swift:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = OrientationLockedController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
Now fire up the iPhone or iPad simulator and start rotating!