iosuinavigationcontrolleruivisualeffectview

Modal Navigation Controller with Blurred Background, Seamless During Push Animation?


I am presenting a navigation controller modally, with a table view controller as the root, and pushing more view controllers when the user taps a cell. Now, I want to apply a blur/vibrancy effect to the whole thing.

The storyboard looks like this:

enter image description here

Now, want to apply a blur effect to the whole navigation controller, so that the image in the initial view controller can be seen underneath.

I have somewhat succeeded by applying the same blur effect to both the table view controller and the detail view controller, like this:

Table View Controller

class TableViewController: UITableViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        self.tableView.tableFooterView = UIView(frame: .zero)

        tableView.backgroundColor = UIColor.clear
        let blurEffect = UIBlurEffect(style: .dark)
        let blurEffectView = UIVisualEffectView(effect: blurEffect)
        tableView.backgroundView = blurEffectView
        tableView.separatorEffect = UIVibrancyEffect(blurEffect: blurEffect)
    }
}

Detail View Controller

class DetailViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .clear
        let blurEffect = UIBlurEffect(style: .dark)
        let blurView = UIVisualEffectView(effect: blurEffect)
        blurView.translatesAutoresizingMaskIntoConstraints = false
        view.insertSubview(blurView, at: 0)
        NSLayoutConstraint.activate([
            blurView.heightAnchor.constraint(equalTo: view.heightAnchor),
            blurView.widthAnchor.constraint(equalTo: view.widthAnchor),
        ])
    }
}

(I also set the modal presentation style of the navigation to .overFullScreen).

However, at the end of the navigation transition to the detail view controller, an annoying artifact can be seen in the blurred background:

enter image description here

I think this has to do with how, since iOS 7, the pushed view controller can not have a transparent background, otherwise the pushing one can be seen as it is instantly removed at the end of the transition.

I tried applying the blur effect to the navigation controller, and make both children's views transparent instead, but it doesn't look good either.

What is the proper way to apply a background blur/vibrancy effect to the contents of a navigation controller, seamlessly?


Solution

  • As I figured out in the question, the push/pop animated transitions introduced since iOS 7 (where the view controller that is lower in the navigation stack "slides out at half the speed", so that it underlaps the one being pushed/popped) make it impossible to transition between transparent view controllers without artifacts.

    So I decided to adopt the protocol UINavigationControllerDelegate, more precisely the method: navigationController(_:animationControllerFor:from:to:), essentially replicating the animated transitions of UINavigationController from scratch.

    This allows me to add a mask view to the view that is transitioned away from during the push operation so that the underlapping section is clipped, and no visible artifacts occur.

    So far I have only implemented the push operation (eventually, I'll have to do the pop operation too, and also the interactive transition form swipe gesture-based pop), I have made a custom UINavigationController subclass, and put it on GitHub.

    (Currently, it supports animated push and pop, but not interactivity. Also, I haven't figured how to replicate the navigation bar title's "slide" animation yet - it just cross dissolves.)

    It boils down to the following steps:

    1. Initially, transform the .to view to the right, off-screen. During the animation, gradually transform it back to identity (center of screen).
    2. Set a white UIView as mask to the .from view, initially with bounds equal to the .from view (no masking). During the animation, gradually reduce the width of the mask's frame property to half its initial value.
    3. During the animation, gradually translate the from view halfway offscreen, to the left.

    In code:

    class AnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    
        // Super slow for debugging:
        let duration: TimeInterval = 1
    
        func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
            return duration
        }
    
        func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
            guard let fromView = transitionContext.view(forKey: .from) else {
                return
            }
            guard let toView = transitionContext.view(forKey: .to) else {
                return
            }
            guard let toViewController = transitionContext.viewController(forKey: .to) else {
                return
            }
    
            //
            // (Code below assumes navigation PUSH operation. For POP, 
            //  use similar code but with view roles and direction 
            //  reversed)
            //
    
            // Add target view to container:
            transitionContext.containerView.addSubview(toView)
    
            // Set tagret view frame, centered on screen
            let toViewFinalFrame = transitionContext.finalFrame(for: toViewController)
            toView.frame = toViewFinalFrame
            let containerBounds = transitionContext.containerView.bounds
            toView.center = CGPoint(x: containerBounds.midX, y: containerBounds.midY)
    
            // But translate it to fully the RIGHT, for now:
            toView.transform = CGAffineTransform(translationX: containerBounds.width, y: 0)
    
            // We will slide the source view out, to the LEFT, by half as much:
            let fromTransform = CGAffineTransform(translationX: -0.5*containerBounds.width, y: 0)
    
            // Apply a white UIView as mask to the source view:
            let maskView = UIView(frame: CGRect(origin: .zero, size: fromView.frame.size))
            maskView.backgroundColor = .white
            fromView.mask = maskView
    
            // The mask will shrink to half the width during the animation:
            let maskViewNewFrame = CGRect(origin: .zero, size: CGSize(width: 0.5*fromView.frame.width, height: fromView.frame.height))
    
            // Animate:
            UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseOut], animations: {
    
                fromView.transform = fromTransform // off-screen to the left (half)
    
                toView.transform = .identity // Back on screen, fully centered
    
                maskView.frame = maskViewNewFrame // Mask to half width
    
            }, completion: {(completed) in
    
                // Remove mask, or funny things will happen to a 
                // table view or scroll view if the user 
                // "rubber-bands":
                fromView.mask = nil
    
                transitionContext.completeTransition(completed)
            })
        }
    

    The result:

    enter image description here

    (I added a text view to the detail view controller for clarity)