Force iOS view to not rotate, while still allowing child to rotate
Asked Answered
T

7

9

I have a view controller with a child view controller.

tab bar controller
|
|
nav controller
|
|
UIPageViewController (should rotate)
|
|
A (Video Player) (shouldn't rotate)
|
|
B (Controls overlay) (should rotate)

A should be forced to stay portrait at all times, but B should be allowed to rotate freely.

I know shouldAutorotate applies to any view controllers and its children, but is there any way to get around this? It seems like I could use shouldAutorotateToInterfaceOrientation, but this is blocked in iOS 8.

I'd like to keep a video player static (so horizontal videos are always horizontal regardless of device orientation), while the controls layer subview overlay is allowed to freely rotate.

I'm using Swift.

Tuba answered 2/2, 2015 at 12:44 Comment(2)
Are you pushing ViewControllerB from ViewControllerA?Patterson
I'm adding it as a subview. self.view.addSubview(viewController.view)Tuba
A
23

I had this exact problem, and found out quickly there's a lot of bad advice floating around about autorotation, especially because iOS 8 handles it differently than previous versions.

First of all, you don't want to apply a counterrotation manually or subscribe to UIDevice orientation changes. Doing a counterrotation will still result in an unsightly animation, and device orientation isn't always the same as interface orientation. Ideally you want the camera preview to stay truly frozen, and your app UI to match the status bar orientation and size as they change, exactly like the native Camera app.

During an orientation change in iOS 8, the window itself rotates rather than the view(s) it contains. You can add the views of multiple view controllers to a single UIWindow, but only the rootViewController will get an opportunity to respond via shouldAutorotate(). Even though you make the rotation decision at the view controller level, it's the parent window that actually rotates, thus rotating all of its subviews (including ones from other view controllers).

The solution is two UIWindow stacked on top of each other, each rotating (or not) with its own root view controller. Most apps only have one, but there's no reason you can't have two and overlay them just like any other UIView subclass.

Here's a working proof-of-concept, which I've also put on GitHub here. Your particular case is a little more complicated because you have a stack of containing view controllers, but the basic idea is the same. I'll touch on some specific points below.

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var cameraWindow: UIWindow!
    var interfaceWindow: UIWindow!

    func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject : AnyObject]?) -> Bool {
        let screenBounds = UIScreen.mainScreen().bounds
        let inset: CGFloat = fabs(screenBounds.width - screenBounds.height)

        cameraWindow = UIWindow(frame: screenBounds)
        cameraWindow.rootViewController = CameraViewController()
        cameraWindow.backgroundColor = UIColor.blackColor()
        cameraWindow.hidden = false

        interfaceWindow = UIWindow(frame: CGRectInset(screenBounds, -inset, -inset))
        interfaceWindow.rootViewController = InterfaceViewController()
        interfaceWindow.backgroundColor = UIColor.clearColor()
        interfaceWindow.opaque = false
        interfaceWindow.makeKeyAndVisible()

        return true
    }
}

Setting a negative inset on interfaceWindow makes it slightly larger than the screen bounds, effectively hiding the black rectangular mask you'd see otherwise. Normally you wouldn't notice because the mask rotates with the window, but since the camera window is fixed the mask becomes visible in the corners during rotation.

class CameraViewController: UIViewController {
    override func shouldAutorotate() -> Bool {
        return false
    }
}

Exactly what you'd expect here, just add your own setup for AVCapturePreviewLayer.

class InterfaceViewController: UIViewController {
    var contentView: UIView!

    override func viewDidLoad() {
        super.viewDidLoad()

        contentView = UIView(frame: CGRectZero)
        contentView.backgroundColor = UIColor.clearColor()
        contentView.opaque = false

        view.backgroundColor = UIColor.clearColor()
        view.opaque = false
        view.addSubview(contentView)
    }

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()

        let screenBounds = UIScreen.mainScreen().bounds
        let offset: CGFloat = fabs(screenBounds.width - screenBounds.height)

        view.frame = CGRectOffset(view.bounds, offset, offset)
        contentView.frame = view.bounds
    }

    override func supportedInterfaceOrientations() -> Int {
        return Int(UIInterfaceOrientationMask.All.rawValue)
    }

    override func shouldAutorotate() -> Bool {
        return true
    }
}

The last trick is undoing the negative inset we applied to the window, which we achieve by offsetting view the same amount and treating contentView as the main view.

For your app, interfaceWindow.rootViewController would be your tab bar controller, which in turn contains a navigation controller, etc. All of these views need to be transparent when your camera controller appears so the camera window can show through beneath it. For performance reasons you might consider leaving them opaque and only setting everything to transparent when the camera is actually in use, and set the camera window to hidden when it's not (while also shutting down the capture session).

Sorry to post a novel; I haven't seen this addressed anywhere else and it took me a while to figure out, hopefully it helps you and anyone else who's trying to get the same behavior. Even Apple's AVCam sample app doesn't handle it quite right.

The example repo I posted also includes a version with the camera already set up. Good luck!

Agnes answered 1/3, 2015 at 17:27 Comment(8)
I tried using the MainWindow as a way to do this with a PreviewView on that window and a UIViewController above that to handle rotation for the other views on top. It worked prior to iOS 7 but broke with 7. I haven't tried iOS8. Have you tried your code with iOS 7? Using two windows; brilliant!Quaff
Do you expect there to be a way for the FixedViewController to host any user-interactable items such as buttons or gesture recognizers?Quaff
This probably won't work out of the box for 7, but it might with a little modification (I'm only targeting 8). User interaction on FixedViewController works normally because the main view gets inset with the line view.frame = CGRectOffset(view.bounds, offset, offset) (you'll still want to use the contentView for your actual content, though).Agnes
I've awarded you the bounty since your answer is extremely thorough, appears to work, and includes code samples. I haven't had a chance to test it out on my codebase yet, but once I do I will award you the answer. Thanks for the great answer!Tuba
This is BRILLIANT! I created two windows and in the key (and rotating) one I'm receiving the touch methods and IBActions, while the other only renders the changes in a static view. This is indeed FAR much better than counterrotating and animating stuff... One question left: has anybody figured out a way to suppress the xcode warning "The app delegate must implement the window property if it wants to use a main storyboard file" when instatiantiating from a Storyboard? overriding the window property screws up the rotation animationIpa
I am developing a universal app, and on the iPad the rotation variables aren't called (Making the app not require full screen). So I can't figure out how to make one UIWindow not rotate. Is this something that can't be done on the iPad without applying a counter rotation?Nitrate
Thanks a ton for the answer, jstn. A question — if you have two windows that have different orientations, which of the two controls the UI of Control Center and notifications? Which direction must the user swipe up from to show Control Center? Suppose one window is portrait and the other is landscape. Should the user swipe up from the home button to trigger Control Center? Or the side of the screen — the longer edge?Hexachlorophene
@Nitrate you have to force full screen on iPads if you want these methods to be called (ShouldAutorotate() etc.). Take a look at this thread: linkJanessajanet
C
1

You can try this -

Objective -C code if you have its alternative in swift:

-(NSUInteger)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window
{
   if ()//Place your condition here like if A is visible
   {
     return UIInterfaceOrientationMaskPortrait;
   }
     return UIInterfaceOrientationMaskAll;
} 
Ci answered 2/2, 2015 at 15:12 Comment(0)
A
1

You can subscribe to rotation change notifications, and manually set the rotation transform matrix on the subview you want to rotate.

Anomie answered 1/3, 2015 at 7:33 Comment(0)
S
0

I'm not sure, but I think you could create an own class for your subview and override the shouldAutorotate method etc. That way it should override the shouldAutorotate from the parent-viewcontroller.

Soliz answered 2/2, 2015 at 12:46 Comment(0)
B
0

Short answer: No, all visible controllers and views rotate (or don't rotate) together.

Long answer:

First, you must implement autorotate decision functions in the root controller; that may mean making a nav controller subclass.

You can hack your desired behavior by having the parent view autorotate -- but have it manually rotate itself back to appear un-rotated.

Or, you can NOT autorotate, but listen for notifications that the physical device rotated, and manually rotate whatever views you want to, eg: Replicate camera app rotation to landscape IOS 6 iPhone

Also see, fyi:

How to force a UIViewController to Portrait orientation in iOS 6

shouldAutoRotate Method Not Called in iOS6

iOS6: supportedInterfaceOrientations not working (is invoked but the interface still rotates)

How to implement UIViewController rotation in response to orientation changes?

Bichloride answered 1/3, 2015 at 7:58 Comment(0)
G
0

The simplest, most straight-forward answer to this question is to look at Apple's AVCam sample code. The key parts for me were that it:

  1. Uses a view whose layerClass is AVCaptureVideoPreviewLayer.
  2. Sets the videoOrientation of the AVCaptureVideoPreviewLayer's connection to match the application's statusBarOrientation when the view is presented, essentially viewWillAppear(_:).
  3. Sets the videoOrientation to match UIDevice.currentDevice().orientation in viewWillTransitionToSize(_:withTransitionCoordinator:).
  4. Enables autorotation and supports all interface orientations.

I implemented the background window approach described by jstn and it worked fairly well, but the reality is that it is much more complicated than is necessary. AVCam works great and has relatively simple approach.

Greaves answered 30/9, 2015 at 19:7 Comment(0)
R
0

if A present B, A is portrait while B only support landscape, add this in B

override open func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    UIViewController.attemptRotationToDeviceOrientation()
    if let windowScene = view.window?.windowScene {
        if #available(iOS 16.0, *) {
            let preferences = UIWindowScene.GeometryPreferences.iOS(interfaceOrientations: .landscapeRight)
            windowScene.requestGeometryUpdate(preferences) { error in
                UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
                print("Failed to update geometry: \(error)")
            }
        } else {
            UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
        }
    }
}

should cover 90% cases

Romanticism answered 8/9 at 13:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.