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:
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:
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?
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:
.to
view to the right, off-screen. During the animation, gradually transform it back to identity (center of screen).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.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)
})
}
(I added a text view to the detail view controller for clarity)