Search code examples
iosswiftsprite-kitorientation

Keep view portrait but let iOS Rotate with phone


I'm looking for a way to rotate iOS UI, without rotating the game view.

The game I'm working on supports multiple orientations, but the game view takes care of the rotation and all animation related to that orientation change and should be rendered as if it's portrait at all times.

Currently I have the app locked to only support portrait and when the phone is rotated the game rotates. But the home bar and control center stay portrait, which is sort of ok, but I'd like the app to fully support landscape.

As it i now, I listen to orientation notifications

func ListenToRotationUpdates() {
    UIDevice.current.beginGeneratingDeviceOrientationNotifications()
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(deviceOrientationDidChange),
        name: UIDevice.orientationDidChangeNotification,
        object: nil
    )
}

Which will set the orientation within the game

@objc func deviceOrientationDidChange() {
    let orientation = UIDevice.current.orientation

    if orientation == .unknown || orientation == .faceUp || orientation == .faceDown {
        return
    }

    self.gameScene.Set(orientation: orientation)
    setNeedsStatusBarAppearanceUpdate()
}

is there a way to keep a view portrait while the app rotates the OS normally?


Solution

  • One of possible solutions is to split your application to "rotating" part and "non-rotating" part using windows.

    It is not an ideal choice but lately the tools that we get do not give us much options. The problem you are facing when using this procedure is that you can have some chaos when presenting new view controllers. In your case this may not be issue at all but still...

    In short what you do is:

    1. Leave main window as it is but enable your application to rotate into all directions that you need
    2. Create a view controller that only supports one orientation (whichever you prefer) and show it a new window over your main one but below status bar (Default behavior)
    3. Create a view controller with transparent background and that can pass touch events through to the window below it. Also show it in a new window over the previous one. Also this window needs to pass touch events through to bottom window.

    You can create all of these in code or with storyboard. But there are a few components to manage. These are all I used when validating this approach:

    class StandStillViewController: WindowViewController {
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // Do any additional setup after loading the view.
        }
        
        func setupManually() {
            self.view = DelegatedTouchEventsView()
            self.view.backgroundColor = UIColor.darkGray
            
            let label = UILabel(frame: .zero)
            label.text = "A part of this app that stands still"
            label.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(label)
            view.addConstraint(.init(item: label, attribute: .centerX, relatedBy: .equal, toItem: view, attribute: .centerX, multiplier: 1.0, constant: 0.0))
            view.addConstraint(.init(item: label, attribute: .centerY, relatedBy: .equal, toItem: view, attribute: .centerY, multiplier: 1.0, constant: 0.0))
            
            let button = UIButton(frame: .zero)
            button.setTitle("Test button", for: .normal)
            button.translatesAutoresizingMaskIntoConstraints = false
            button.addTarget(self, action: #selector(testButton), for: .touchUpInside)
            view.addSubview(button)
            view.addConstraint(.init(item: button, attribute: .centerX, relatedBy: .equal, toItem: view, attribute: .centerX, multiplier: 1.0, constant: 0.0))
            view.addConstraint(.init(item: button, attribute: .centerY, relatedBy: .equal, toItem: view, attribute: .centerY, multiplier: 1.0, constant: 50.0))
        }
        
        override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return .portrait }
        override var shouldAutorotate: Bool { false }
        override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { return .portrait }
        
        @IBAction private func testButton() {
            print("Button was pressed")
        }
        
    
    }
    

    This is the controller at the bottom. I expect this one will host your game. You need to preserve

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return .portrait }
    override var shouldAutorotate: Bool { false }
    override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { return .portrait }
    

    the rest may be changed, removed.


    class RotatingViewController: WindowViewController {
    
        override func viewDidLoad() {
            super.viewDidLoad()
        }
        
        func setupManually() {
            self.view = DelegatedTouchEventsView()
            self.view.backgroundColor = UIColor.clear // Want to see through it
            
            let label = UILabel(frame: .zero)
            label.text = "A Rotating part of this app"
            label.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(label)
            view.addConstraint(.init(item: label, attribute: .centerX, relatedBy: .equal, toItem: view, attribute: .centerX, multiplier: 1.0, constant: 0.0))
            view.addConstraint(.init(item: label, attribute: .centerY, relatedBy: .equal, toItem: view, attribute: .top, multiplier: 1.0, constant: 60.0))
            
            let button = UIButton(frame: .zero)
            button.setTitle("Test rotating button", for: .normal)
            button.translatesAutoresizingMaskIntoConstraints = false
            button.addTarget(self, action: #selector(testButton), for: .touchUpInside)
            view.addSubview(button)
            view.addConstraint(.init(item: button, attribute: .centerX, relatedBy: .equal, toItem: view, attribute: .centerX, multiplier: 1.0, constant: 0.0))
            view.addConstraint(.init(item: button, attribute: .centerY, relatedBy: .equal, toItem: view, attribute: .centerY, multiplier: 1.0, constant: -50.0))
        }
        
        @IBAction private func testButton() {
            print("Rotating button was pressed")
        }
    
    }
    

    This controller will deal with rotating stuff. You need to preserve

    self.view = DelegatedTouchEventsView()
    self.view.backgroundColor = UIColor.clear // Want to see through it
    

    which may both be set in storyboard as well. Anything you put onto this controller will rotate with your device. A nice place to put some GUI stuff for instance.


    class WindowViewController: UIViewController {
        
        private var window: UIWindow?
        
        func shownInNewWindow(delegatesTouchEvents: Bool, baseWindow: UIWindow? = nil) {
            let scene = baseWindow?.windowScene ?? UIApplication.shared.windows.first!.windowScene!
            let newWindow: UIWindow
            if delegatesTouchEvents {
                newWindow = DelegatedTouchEventsWindow(windowScene: scene)
            } else {
                newWindow = UIWindow(windowScene: scene)
            }
            
            
            newWindow.rootViewController = self
            newWindow.windowLevel = .normal
            newWindow.makeKeyAndVisible()
            
            self.window = newWindow
        }
        
        func dismissFromWindow(completion: (() -> Void)? = nil) {
            removeFromWindow()
            completion?()
        }
        
        func removeFromWindow() {
            self.window?.isHidden = true
            self.window = nil
        }
        
    }
    

    This is what I used as base class for both view controllers above. It is not much but it allows view controllers to be shown in a new window. This code was pasted from one of my older projects and could use some minor improvements. But it does works so...


    class DelegatedTouchEventsView: UIView {
    
        override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            let view = super.hitTest(point, with: event)
            return view == self ? nil : view
        }
        
    }
    
    class DelegatedTouchEventsWindow: UIWindow {
    
        override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            let view = super.hitTest(point, with: event)
            return view == self ? nil : view
        }
        
    }
    

    These are the two subclasses to let touch events go through. A quick explanation on how this works: When event is received your system will first send this event through your view hierarchy asking "who is going to handle this event?". Returning nil means "not me" and default for UIView is self. So in this code we say: "If any of my subviews wants to handle this event (such as a button) then it may handle this event. But if none of them wants to handle them then neither I will." And UIWindow is a subclass of UIView so we need to deal with both of them.


    class ViewController: UIViewController {
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
    //        let standingStillController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "StandStillViewController") as! StandStillViewController
    //        standingStillController.shownInNewWindow(delegatesTouchEvents: false, baseWindow: self.view.window)
    //
    //        let rotatingViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "RotatingViewController") as! RotatingViewController
    //        rotatingViewController.shownInNewWindow(delegatesTouchEvents: true, baseWindow: self.view.window)
            
            let standingStillController = StandStillViewController()
            standingStillController.setupManually()
            standingStillController.shownInNewWindow(delegatesTouchEvents: false, baseWindow: self.view.window)
            
            let rotatingViewController = RotatingViewController()
            rotatingViewController.setupManually()
            rotatingViewController.shownInNewWindow(delegatesTouchEvents: true, baseWindow: self.view.window)
        }
        
        
    }
    

    This is an example on how to use it all together. As promised, either using Storyboards or manually, both should work.

    Seems like a lot of work but you need to set it up once and never look at it again.