SwiftUI: Force orientation on a per screen basis

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?

  • What do you do if it says cannot find self in scope?

Add a Comment

Accepted Reply

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!

Replies

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!

Thanks Jim! This is great, the only problem I have is that if, for example, I navigate to a new view that has .supportedOrientations(.landscape) from a portrait view, it won't force it to landscape automatically. I'm sure there is some sort of forceRotate function we can run to make it landscape in those cases.

Yeah, navigation views and preference keys don't go well together. Both the master and detail view both stay around, so they never actually stop contributing to the reduced preference value, even after dismissal.


You could force things by making the PreferenceKey's value a structure or tuple containing the orientation mask along with a 'forced' variable. You could add that to the supportedOrientations modifier as a variable with a default value of false, and then during reduce() if the current value has it set to true you ignore the remaining values.


Unfortunately, dismissed detail views don't actually go away, their contents are simply reassigned the next time you push the same view type (at least for standard master/detail types). You might be able to use @Environment(\.presentationMode) to set the preference value based on whether the detail view is actually presented, passing (.all, false) if not, (.landscape, true) if so. I haven't checked whether that environment variable is toggled on dismiss though. I'll have to look into that.

Confirmed that presentationMode does in fact work in this way, with one caveat: it toggles off the moment it starts animating offscreen. That includes during an interactive edge-of-screen swipe. In the swipe case, stopping halfway and going back to the detail view doesn't set it back to true again. That seems like a bug to me.

This works great for the iPhone. How do you change it to lock the orientation for the iPad?
  • This code also works well for iPad. You may miss the configuration in Xcode. In Xcode -> general -> development info, you have to check only "iPad", choose "portrait" and "landscape left(right)", and check "requires full screen". Then, you can do well, I think.

Add a Comment
Nevermind. I got it to work. Thanks
I guess my question is more on how to make the orientation rotate based on which view orientation is set. I did this in Swift by changing the UIDevice.current.setValue(UIInterfaceOrientation.portraitUpsideDown.rawValue, forKey: "orientation") but in swiftui I keep getting an error and my app crashes
Had to use DispatchQueue to make it work
This is not a perfect solution.
In IOS 14. My solution is as follows:
struct NetflixApp: App {

    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

}

class AppDelegate: NSObject {



    // 屏幕旋转锁定



    static var orientationLock = UIInterfaceOrientationMask.portrait

}

extension AppDelegate: UIApplicationDelegate {

    func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {

        return AppDelegate.orientationLock

    }

}

SomeView
.onDisappear(perform: {

            DispatchQueue.main.async {

                AppDelegate.orientationLock = UIInterfaceOrientationMask.portrait

                UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")

                UINavigationController.attemptRotationToDeviceOrientation()

            }

        })

        .onAppear(perform: {

            AppDelegate.orientationLock = UIInterfaceOrientationMask.landscape

            UIDevice.current.setValue(UIInterfaceOrientation.landscapeLeft.rawValue, forKey: "orientation")

            UINavigationController.attemptRotationToDeviceOrientation()

        })
  • Thanks! This works for me on iPhone iOS17

Add a Comment

This works for my view, in Xcode 13 The only problem is the upside down portrait mode view

struct ContentView5: View {
  var body: some View {
    ZStack {
    (
    Text("Hello, World!")
    )
    .onAppear() {
      let value = UIInterfaceOrientation.landscapeLeft.rawValue
      UIDevice.current.setValue(value, forKey: "orientation")
     
    }
    .onDisappear() {
      var value = UIInterfaceOrientation.portrait.rawValue
      if UIDevice.current.orientation.isPortrait
        {
        UIDevice.current.setValue(value, forKey: "orientation")
        value = UIInterfaceOrientation.unknown.rawValue
        UIDevice.current.setValue(value, forKey: "orientation")
        }
      else
        {
        value = UIInterfaceOrientation.unknown.rawValue
        }
    }
    }
  }
}

Doc: https://developer.apple.com/documentation/uikit/uiinterfaceorientation

Approach described above didn't work for me no matter what I did. Then I discovered swiftui-interface-orientation and it works for me fine. It is even little bit easier to use.

I did these steps to make it work:

  • In Xcode only Portrait orientation is selected.
  • App delegate is marked as @main instead of SwiftUI App.
  • InterfaceOrientationCoordinator.shared.allowOverridingDefaultOrientations is set to true in didFinishLaunchingWithOptions method.
  • I have to create window manually in didFinishLaunchingWithOptions and use UIHostingController as root controller:
let window = UIWindow()
window.rootViewController = UIHostingController(rootView: makeRootView(store: rootStore))
window.makeKeyAndVisible()
self.window = window
  • This method is added to App delegate:
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
        InterfaceOrientationCoordinator.shared.supportedOrientations
 }

Then I use interfaceOrientations() modifier on any view:

MediaBrowserView()
    .interfaceOrientations([.portrait, .landscape])