Search code examples
iosuiviewcontrolleruiinterfaceorientationavplayerviewcontroller

AVPlayerViewController messes up underlying modal view controller in popup on iOS10


I have an app (supported interface orientation - portrait only) with the next hierarchy of modally presented view controllers:

A -> B -> AVP

Where A is a view controller sitting in tab bar controller, tab bar controller is in turn root of window.

B is a rather simple view controller with button, image and labels but presented as a popup:

// ... presentation method in A
let B = // create B
B.modalPresentationStyle = .popover
B.preferredContentSize = CGSize(width: 300, height: 400)
B.isModalInPopover = true
if let BPopover = B.popoverPresentationController {
    BPopover.delegate = self
    BPopover.permittedArrowDirections = []
    let window = // grab current window
    BPopover.sourceView = window
    BPopover.sourceRect = window.bounds
    BPopover.passthroughViews = nil
}
self.tabBarController?.present(B, animated: true, completion: nil)

AVP is a AVPlayerViewController presented from B:

// This method is in B.
@IBAction func playVideoButtonPressed(_ sender: Any) {
    if let videoURL = self.videoURL {
        let videoPlayer = AVPlayer(url: videoURL)
        let videoVC = AVPlayerViewController()
        videoVC.player = videoPlayer
        self.present(videoVC, animated: true, completion: nil)
    }
}

On iOS 10.0 I have an issue if I perform next steps:

  1. present AVP.
  2. rotate device so AVP rotates video in horizontal orientation
  3. I dismiss AVP in horizontal orientation (using its system provided controls)

When I come back, my view controller B is messed up - moved to the top of window and it size is smaller (also messed up inside, but I guess insides are messed up as a result of my autolayout constraints).

This doesn't seem to happen on iOS 11.

Is there anything I could do to fix it?

EDIT: Screenshots as requested (tabbar was hidden for privacy reasons):

Normal state of popup Popup after video player

Additional info:

I have also intercepted a delegate callback for more info:

func popoverPresentationController(_ popoverPresentationController: UIPopoverPresentationController,
                                   willRepositionPopoverTo rect: UnsafeMutablePointer<CGRect>,
                                   in view: AutoreleasingUnsafeMutablePointer<UIView>) {
    print("willRepositionPopoverTo")
    print(popoverPresentationController)
    print(rect.pointee)
    print(view.pointee)
}

Which prints view size as (w: 568; h: 320) so it seems when I rotate AVP controller it changes my app's window orientation and this leads to resizing of my popup. Although it doesn't attempt to resize it back :( after I dismiss AVP.


Solution

  • I've successfully reproduced your issue, so you can rest easy knowing that it's not just you. I also spent a fair amount of time trying to fix the iOS 10 behavior using various "hacks". I was not initially successful. Additionally, it seems that although iOS 11 addressed the positioning of the popover, it has also introduced a bug with the tab bar size (pictures). So my solution needed to address that issue as well.

    enter image description here : enter image description here

    Updated Workaround

    Upon revisiting this issue, I reconsidered a solution that I had originally ruled out. It turns out that the AVPlayerViewController will only affect the orientation of it's parent UIWindow instance. Using additional UIWindows is not an often used solution in iOS, but it works out perfectly here.

    The solution is to create a clear UIWindow with a shim rootViewController whose sole purpose is to dismiss itself without animations the second time it appears. This solution works just as well on iOS 10 and iOS 11. It results in no changes to the user experience (other than fixing the bug).

    Steps:

    1. Create vanilla UIWindow and make it's background clear
    2. Set the window's rootVC to an instance of ShimVC
    3. Make the window key and visible (since everything is clear, the user will see nothing)
    4. Present the AVPlayerViewController from the window's rootVC (thus you get animations, just like you otherwise would)
    5. When the video player dismisses, the shim view controller will dismiss itself. When the rootVC of a window dismisses itself, the window will be removed and the original, main window will be made key again.

    Here is a gif showing how well this works:

    enter image description here

    class FirstViewController: UIViewController, UIViewControllerTransitioningDelegate {
    
        @IBAction func popover(_ sender: UIButton) {
    
            let b = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "B") as! B
            b.modalPresentationStyle = .popover
            b.preferredContentSize = CGSize(width: 300, height: 400)
            b.isModalInPopover = true
    
            if let ppc = b.popoverPresentationController {
                ppc.delegate = self
                ppc.permittedArrowDirections = []
                let window = view.window!
                ppc.sourceView = window
                ppc.sourceRect = window.frame
                ppc.passthroughViews = nil
            }
    
            present(b, animated: true, completion: nil)
        }
    }
    
    extension FirstViewController: UIPopoverPresentationControllerDelegate {
    
        func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
            return .none
        }
    }
    
    class ShimVC: UIViewController {
    
        var appearances: Int = 0
    
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
    
            if appearances > 0 {
                // When the rootViewController of a window dismisses, that window
                // gets removed from the view hiearchy and discarded, making the
                // the previous window key automatically
                dismiss(animated: true)
            }
    
            appearances += 1
        }
    }
    
    class B: UIViewController {
    
        let videoURL: URL? = Bundle.main.url(forResource: "ImAfraidWeNeedToUseMath", withExtension: "m4v")
    
        @IBAction func playVideo(_ sender: UIButton) {
    
            if let videoURL = self.videoURL {
                let videoPlayer = AVPlayer(url: videoURL)
                let videoVC = AVPlayerViewController()
                videoVC.player = videoPlayer
    
                let vc = ShimVC(nibName: nil, bundle: nil)
    
                let videoWindow = UIWindow()
                videoWindow.backgroundColor = .clear
                videoWindow.rootViewController = vc
                videoWindow.makeKeyAndVisible()
    
                // Present the `AVPlayerViewController` from the root
                // of the window
                vc.present(videoVC, animated: true)
            }
        }
    
        @IBAction func done(_ sender: UIButton) {
            dismiss(animated: true)
        }
    }
    

    Originally Proposed Workaround

    I'm going to propose a workaround. It's a behavior I've seen in some apps before. Basically, instead of presenting the video player from the popover, switch out B with the AVC. There are probably other solutions that require a bit more deviation from stock UIKit functionality. (Such as possibly implementing your own presentation controller to achieve the popover or implementing the popover as a child view controller so that you can present the AVC directly from A.)

    In the following solution, A -> B then when user presses player B informs A that it needs to present AVC. So then you get A -> AVC. When AVC is dismissed, I re-present B, so you're back to A -> B. Overall there are no UI problems on either iOS 10 or iOS 11, but your users will be requires to wait an additional fraction of a second. You can disable (or attempt to shorten?) the animations if time is critical, but overall it feels pretty fluid and natural.

    Additionally, I recommend filing a radar regarding the tab bar size issue.

    Here is my solution:

    class A: UIViewController, UIViewControllerTransitioningDelegate {
    
        var popover: B?
    
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
    
            if let popover = popover {
                if let ppc = popover.popoverPresentationController {
                    ppc.delegate = self
                    ppc.permittedArrowDirections = []
                    let window = view.window!
                    ppc.sourceView = window
                    ppc.sourceRect = window.frame
                    ppc.passthroughViews = nil
                }
    
                present(popover, animated: true, completion: nil)
            }
        }
    
        @IBAction func popover(_ sender: UIButton) {
    
            let b = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "B") as! B
            b.modalPresentationStyle = .popover
            b.preferredContentSize = CGSize(width: 300, height: 400)
            b.isModalInPopover = true
            b.delegate = self
    
            if let ppc = b.popoverPresentationController {
                ppc.delegate = self
                ppc.permittedArrowDirections = []
                let window = view.window!
                ppc.sourceView = window
                ppc.sourceRect = window.frame
                ppc.passthroughViews = nil
            }
    
            self.popover = b
    
            present(b, animated: true, completion: nil)
        }
    }
    
    extension A: UIPopoverPresentationControllerDelegate {
    
        func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
            return .none
        }
    }
    
    extension A: BDelegate {
    
        func dismissB() {
            popover?.dismiss(animated: true)
            popover = nil
        }
    
        func showAVPlayerViewController(_ vc: AVPlayerViewController) {
    
            popover?.dismiss(animated: true) {
                // Dispatch async allows it to come up in landscape if the phone is already rotated
                DispatchQueue.main.async {
                    self.present(vc, animated: true)
                }
            }
        }
    }
    
    protocol BDelegate: class {
    
        func showAVPlayerViewController(_ vc: AVPlayerViewController)
        func dismissB()
    }
    
    class B: UIViewController {
    
        weak var delegate: BDelegate?
    
        let videoURL: URL? = Bundle.main.url(forResource: "ImAfraidWeNeedToUseMath", withExtension: "m4v")
    
        @IBAction func playVideo(_ sender: UIButton) {
    
            if let videoURL = self.videoURL {
                let videoPlayer = AVPlayer(url: videoURL)
                let videoVC = AVPlayerViewController()
                videoVC.player = videoPlayer
                delegate?.showAVPlayerViewController(videoVC)
            }
        }
    
        @IBAction func done(_ sender: UIButton) {
            delegate?.dismissB()
        }
    }