swiftuiviewcontrolleruinavigationcontrollerautolayoutuistoryboardsegue

Layout broken when pushing VC into a UINavigationController presented with a custom transition


I have an extension for performing transitions between view controllers. It will perform an animation with the views of each VC and then replace the current vc with the destination vc using this method:

private func replace(with: UIViewController, completion: (() -> Void)?) {
    if let baseWindow = self.view.window, baseWindow.rootViewController == self {
        baseWindow.rootViewController = with
    }
}

Everything works fine except in the following scenario:

  1. VC1 calls transition.to(NC1, animation: .fade) // NC1 is a navigation controller with VC2 as its primary view controller
  2. VC2 calls self.navigationController?.pushViewController(VC3, animated: true)
  3. VC3 will ignore the safe area when presenting and it will be pushed without an animation. This is the issue that I am unable to solve.

Using animation: .none works, so it must be something to do with my transition code, which I will share below:

extension UIViewController {
    enum HorizontalDirection { case left, right }
    enum TransitionAnimation {
        case slide(_ direction: HorizontalDirection)
        case pageIn(_ direction: HorizontalDirection)
        case pageOut(_ direction: HorizontalDirection)
        case zoomOut
        case zoomIn
        case fade
        case none
    }
    
    private func replace(with: UIViewController, completion: (() -> Void)?) {
        if let baseWindow = self.view.window, baseWindow.rootViewController == self {
            baseWindow.rootViewController = with
        }
    }

    // Note that these transitions will not work inside a macOS modal
    func transition(to: UIViewController, animation: TransitionAnimation, completion: (() -> Void)? = nil) {
        // initialSpringVelocity (default 0) determines how quickly the view moves during the first part of the animation, before the spring starts to slow it down.
        // A higher value for initialSpringVelocity means that the view will move more quickly at the beginning of the animation, while a lower value means that it will start more slowly. The velocity value is measured in points per second.
        // The usingSpringWithDamping (default 0.5) parameter determines how quickly the view slows down during each oscillation. A smaller value for usingSpringWithDamping creates a bouncier effect with more oscillations, while a larger value creates a more damped effect with fewer oscillations.
        
        switch animation {
        case .none: replace(with: to, completion: completion)
        case .fade:
            to.view.alpha = 0
            self.view.insertSubview(to.view, aboveSubview: self.view)
            UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseOut], animations: {
                to.view.alpha = 1
            }, completion: { [self] _ in replace(with: to, completion: completion) })
        case .slide(.left):
            let width = self.view.frame.size.width
            self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
            to.view.transform = CGAffineTransform(translationX: width, y: 0)

            UIView.animate(withDuration: 0.7, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.2, options: [.curveEaseInOut], animations: { [self] in
                self.view.transform = CGAffineTransform(translationX: -width, y: 0)
                self.view.subviews.forEach({ $0.alpha = 0 })
                to.view.transform = .identity
            }, completion: { [self] _ in replace(with: to, completion: completion) })

        case .slide(.right):
            let width = self.view.frame.size.width
            self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
            to.view.transform = CGAffineTransform(translationX: -width, y: 0)

            UIView.animate(withDuration: 0.7, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.2, options: [.curveEaseInOut], animations: { [self] in
                self.view.transform = CGAffineTransform(translationX: width, y: 0)
                self.view.subviews.forEach({ $0.alpha = 0 })
                to.view.transform = .identity
            }, completion: { [self] _ in replace(with: to, completion: completion) })

        case .pageIn(.left):
            let width = self.view.frame.size.width
            self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
            to.view.transform = CGAffineTransform(translationX: width, y: 0)

            UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
                self.view.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
                self.view.alpha = 0
                to.view.transform = .identity
            }, completion: { [self] _ in replace(with: to, completion: completion) })

        case .pageIn(.right):
            let width = self.view.frame.size.width
            self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
            to.view.transform = CGAffineTransform(translationX: -width, y: 0)

            UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
                self.view.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
                self.view.alpha = 0
                to.view.transform = .identity
                self.view.layoutIfNeeded() // This is here to prevent the safe area from being impacted by the scaling animation
            }, completion: { [self] _ in replace(with: to, completion: completion) })

        case .pageOut(.left):
            let width = self.view.frame.size.width
            self.view.superview?.insertSubview(to.view, belowSubview: self.view)
            to.view.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
            to.view.alpha = 0

            UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
                self.view.transform = CGAffineTransform(translationX: width, y: 0)
                to.view.transform = .identity
                to.view.alpha = 1
                self.view.layoutIfNeeded() // This is here to prevent the safe area from being impacted by the scaling animation
            }, completion: { [self] _ in replace(with: to, completion: completion) })

        case .pageOut(.right):
            let width = self.view.frame.size.width
            self.view.superview?.insertSubview(to.view, belowSubview: self.view)
            to.view.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
            to.view.alpha = 0

            UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
                self.view.transform = CGAffineTransform(translationX: -width, y: 0)
                to.view.transform = .identity
                to.view.alpha = 1
                self.view.layoutIfNeeded() // This is here to prevent the safe area from being impacted by the scaling animation
            }, completion: { [self] _ in replace(with: to, completion: completion) })

        case .zoomOut:
            self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
            to.view.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
            to.view.alpha = 0

            // This backgroundView is added behind the 2 views transitioning because in certain scenarios, there are pages still visible behind them and the zoom animation reveals them.
            let backgroundView = UIView(frame: self.view.frame)
            backgroundView.backgroundColor = self.view.backgroundColor
            self.view.superview?.insertSubview(backgroundView, belowSubview: self.view)

            UIView.animate(withDuration: 0.2, delay: 0.0, options: [.curveEaseInOut], animations: { to.view.alpha = 1 }, completion: nil)

            UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
                self.view.transform = CGAffineTransform(scaleX: 0.7, y: 0.7)
                to.view.transform = .identity
                self.view.layoutIfNeeded() // This is here to prevent the safe area from being impacted by the scaling animation
            }, completion: { [self] _ in replace(with: to, completion: completion) })

        case .zoomIn:
            self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
            to.view.transform = CGAffineTransform(scaleX: 0.7, y: 0.7)
            to.view.alpha = 0

            // This backgroundView is added behind the 2 views transitioning because in certain scenarios, there are pages still visible behind them and the zoom animation reveals them.
            let backgroundView = UIView(frame: self.view.frame)
            backgroundView.backgroundColor = self.view.backgroundColor
            self.view.superview?.insertSubview(backgroundView, belowSubview: self.view)

            UIView.animate(withDuration: 0.15, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in view.alpha = 0 }, completion: nil)

            UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
                view.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
                to.view.transform = .identity
                to.view.alpha = 1
                view.layoutIfNeeded() // This is here to prevent the safe area from being impacted by the scaling animation
            }, completion: { [self] _ in replace(with: to, completion: completion) })
        }
    }
}

I have also created a demo Xcode project demonstrating the issue if anyone is this helps: https://drive.google.com/file/d/1aX3sQCCcp56wqRS5kH0wG8VzPpE5XIny/view?usp=share_link

Any hints about what causes this issue and how to fix it will be greatly appreciated.


Solution

  • You're manipulating view hierarchy in ways that cause problems - as we see from the results.

    Notice that running your test project as-is, we get this in the debug console:

    [Window] Manually adding the rootViewController's view to the view hierarchy 
        is no longer supported. Please allow UIWindow to add the rootViewController's view 
        to the view hierarchy itself.
    
    Unbalanced calls to begin/end appearance transitions for
        <UINavigationController: 0x7f7f2002de00>.
    
    Unbalanced calls to begin/end appearance transitions for
        <Test_project.Container: 0x7f7f1780ca10>.
    

    What your code is doing is pulling the navigation controller's view out of the view hierarchy, then trying to add that controller.

    You can try this...

    IF the "to" controller is a UINavigationController,

    after adding and showing the "to" view,

    With only quick testing, this appears to solve the issue:

            case .fade:
    
                var origNavController: UINavigationController!
                var origFirstVC: UIViewController!
    
                if let toVC = to as? UINavigationController,
                   let fVC = toVC.viewControllers.first {
                    origNavController = toVC
                    origFirstVC = fVC
                }
    
                to.view.alpha = 0
                
                // we can add it -- no need to "insert above"
                //self.view.insertSubview(to.view, aboveSubview: self.view)
                self.view.addSubview(to.view)
    
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseOut], animations: {
                    to.view.alpha = 1
                }, completion: { [self] _ in
                    
                    // if the "to" controller is a UINavigationController
                    if origNavController != nil, origFirstVC != nil {
    
                        // instantiate a NEW UINavigationController
                        let newNC = UINavigationController(rootViewController: origFirstVC)
                        // match the original
                        newNC.isNavigationBarHidden = origNavController.isNavigationBarHidden
                        // now, replace
                        self.replace(with: newNC, completion: completion)
    
                    } else {
                        
                        self.replace(with: to, completion: completion) 
    
                    }
                    
                })
    

    Notes:

    It may get you headed in the right direction.