Search code examples
iosswiftanimationautolayoutuikit

additionalSafeAreaInsets is not accounted for during view controller dismissal, using custom UIViewControllerTransitioningDelegate


So, straight to the problem:

I've created a custom UIViewControllerTransitioningDelegate that I use to animate a view from one view controller, to full-screen in another view controller. Im doing this by creating UIViewControllerAnimatedTransitioning-objects that animate the presented view's frame. And it works great! Except when I try to adjust the additionalSafeAreaInsets of the view controller owning the view during dismissal...

It looks like this property is not accounted for when I'm trying to animate the dismissal of the view controller and its view. It works fine during presentation.

The gif below shows how it looks. The red box is the safe area (plus some padding) of the presented view - which I'm trying to compensate for during animation, using the additionalSafeAreaInsets property of the view controller owning the view.

additionalSafeAreaInsets only accounted for during presentation

As the gif shows, the safe area is properly adjusted during presentation but not during dismissal.

So, what I want is: use additionalSafeAreaInsets to diminish the effect of the safe area during animation, by setting additionalSafeAreaInsets to the "inverted" values of the safe area. So that the effective safe area starts at 0 and "animates" to the expected value during presentation, and starts at expected value and "animates" to 0 during dismissal. (I'm quoting "animates", since its actually the view's frame that is animated. But UIKit/Auto Layout use these properties when calculating the frames)

Any thoughts on how to battle this issue is great welcome!

The code for the custom UIViewControllerTransitioningDelegate is provided below.

//
//  FullScreenTransitionManager.swift
//

import Foundation
import UIKit

// MARK: FullScreenPresentationController

final class FullScreenPresentationController: UIPresentationController {
    private let backgroundView: UIView = {
        let view = UIView()
        view.backgroundColor = .systemBackground
        view.alpha = 0
        return view
    }()
    
    private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onTap))
    
    @objc private func onTap(_ gesture: UITapGestureRecognizer) {
        presentedViewController.dismiss(animated: true)
    }
}
    
// MARK: UIPresentationController
    
extension FullScreenPresentationController {
    override func presentationTransitionWillBegin() {
        guard let containerView = containerView else { return }
        
        containerView.addGestureRecognizer(tapGestureRecognizer)
        
        containerView.addSubview(backgroundView)
        backgroundView.frame = containerView.frame
        
        guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
        
        transitionCoordinator.animate(alongsideTransition: { context in
            self.backgroundView.alpha = 1
        })
    }
    
    override func presentationTransitionDidEnd(_ completed: Bool) {
        if !completed {
            backgroundView.removeFromSuperview()
            containerView?.removeGestureRecognizer(tapGestureRecognizer)
        }
    }
    
    override func dismissalTransitionWillBegin() {
        guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
        
        transitionCoordinator.animate(alongsideTransition: { context in
            self.backgroundView.alpha = 0
        })
    }
    
    override func dismissalTransitionDidEnd(_ completed: Bool) {
        if completed {
            backgroundView.removeFromSuperview()
            containerView?.removeGestureRecognizer(tapGestureRecognizer)
        }
    }
    
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        guard
            let containerView = containerView,
            let presentedView = presentedView
        else { return }
        coordinator.animate(alongsideTransition: { context in
            self.backgroundView.frame = containerView.frame
            presentedView.frame = self.frameOfPresentedViewInContainerView
        })
    }
}

// MARK: FullScreenTransitionManager

final class FullScreenTransitionManager: NSObject, UIViewControllerTransitioningDelegate {
    private weak var anchorView: UIView?
    
    init(anchorView: UIView) {
        self.anchorView = anchorView
    }
    
    func presentationController(forPresented presented: UIViewController,
                                presenting: UIViewController?,
                                source: UIViewController) -> UIPresentationController? {
        FullScreenPresentationController(presentedViewController: presented, presenting: presenting)
    }
    
    func animationController(forPresented presented: UIViewController,
                             presenting: UIViewController,
                             source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        let anchorFrame = anchorView?.safeAreaLayoutGuide.layoutFrame ?? CGRect(origin: presented.view.center, size: .zero)
        return FullScreenAnimationController(animationType: .present,
                                             anchorFrame: anchorFrame)
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        let anchorFrame = anchorView?.safeAreaLayoutGuide.layoutFrame ?? CGRect(origin: dismissed.view.center, size: .zero)
        return FullScreenAnimationController(animationType: .dismiss,
                                             anchorFrame: anchorFrame)
    }
}

// MARK: UIViewControllerAnimatedTransitioning

final class FullScreenAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    enum AnimationType {
        case present
        case dismiss
    }
    
    private let animationType: AnimationType
    private let anchorFrame: CGRect
    private let animationDuration: TimeInterval
    private var propertyAnimator: UIViewPropertyAnimator?
    
    init(animationType: AnimationType, anchorFrame: CGRect, animationDuration: TimeInterval = 0.3) {
        self.animationType = animationType
        self.anchorFrame = anchorFrame
        self.animationDuration = animationDuration
    }
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        animationDuration
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        switch animationType {
        case .present:
            guard
                let toViewController = transitionContext.viewController(forKey: .to)
            else {
                return transitionContext.completeTransition(false)
            }
            transitionContext.containerView.addSubview(toViewController.view)
            propertyAnimator = presentAnimator(with: transitionContext, animating: toViewController)
        case .dismiss:
            guard
                let fromViewController = transitionContext.viewController(forKey: .from)
            else {
                return transitionContext.completeTransition(false)
            }
            propertyAnimator = dismissAnimator(with: transitionContext, animating: fromViewController)
        }
    }
    
    private func presentAnimator(with transitionContext: UIViewControllerContextTransitioning,
                                 animating viewController: UIViewController) -> UIViewPropertyAnimator {
        let finalFrame = transitionContext.finalFrame(for: viewController)
        let safeAreaInsets = transitionContext.containerView.safeAreaInsets
        let safeAreaCompensation = UIEdgeInsets(top: -safeAreaInsets.top,
                                                left: -safeAreaInsets.left,
                                                bottom: -safeAreaInsets.bottom,
                                                right: -safeAreaInsets.right)
        viewController.additionalSafeAreaInsets = safeAreaCompensation
        viewController.view.frame = anchorFrame
        viewController.view.setNeedsLayout()
        viewController.view.layoutIfNeeded()
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut, .layoutSubviews], animations: {
            viewController.additionalSafeAreaInsets = .zero
            viewController.view.frame = finalFrame
            viewController.view.setNeedsLayout()
            viewController.view.layoutIfNeeded()
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
    
    private func dismissAnimator(with transitionContext: UIViewControllerContextTransitioning,
                                 animating viewController: UIViewController) -> UIViewPropertyAnimator {
        let finalFrame = anchorFrame
        let safeAreaInsets = transitionContext.containerView.safeAreaInsets
        let safeAreaCompensation = UIEdgeInsets(top: -safeAreaInsets.top,
                                                left: -safeAreaInsets.left,
                                                bottom: -safeAreaInsets.bottom,
                                                right: -safeAreaInsets.right)
        viewController.view.setNeedsLayout()
        viewController.view.layoutIfNeeded()
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut, .layoutSubviews], animations: {
            viewController.additionalSafeAreaInsets = safeAreaCompensation
            viewController.view.frame = finalFrame
            viewController.view.setNeedsLayout()
            viewController.view.layoutIfNeeded()
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
}


Solution

  • After some debugging I managed to find a workaround to this problem.

    In short, it looks like the safe area is not updated after UIViewController.viewWillDisappear is called, and hence any changes to .additionalSafeAreaInsets is ignored (since these insets modifies the safe area of the view controller's view).

    My current workaround is somewhat hacky, but it gets the job done. Since UIViewControllerTransitioningDelegate.animationController(forDismissed...) is called right before UIViewController.viewWillDisappear and UIViewControllerAnimatedTransitioning.animateTransition(using transitionContext...), I start the dismiss animation already in that method. That way the layout calculations for the animation get correct, and the correct safe area is set.

    Below is the code for my custom UIViewControllerTransitioningDelegate with the workaround. Note: I've removed the use of .additionalSafeAreaInsets since its not necessary at all! And I've no idea why I thought I needed it in the first place...

    //
    //  FullScreenTransitionManager.swift
    //
    
    import Foundation
    import UIKit
    
    // MARK: FullScreenPresentationController
    
    final class FullScreenPresentationController: UIPresentationController {
        private let backgroundView: UIView = {
            let view = UIView()
            view.backgroundColor = .systemBackground
            view.alpha = 0
            return view
        }()
        
        private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onTap))
        
        @objc private func onTap(_ gesture: UITapGestureRecognizer) {
            presentedViewController.dismiss(animated: true)
        }
    }
        
    // MARK: UIPresentationController
        
    extension FullScreenPresentationController {
        override func presentationTransitionWillBegin() {
            guard let containerView = containerView else { return }
            
            containerView.addGestureRecognizer(tapGestureRecognizer)
            
            containerView.addSubview(backgroundView)
            backgroundView.frame = containerView.frame
            
            guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
            
            transitionCoordinator.animate(alongsideTransition: { context in
                self.backgroundView.alpha = 1
            })
        }
        
        override func presentationTransitionDidEnd(_ completed: Bool) {
            if !completed {
                backgroundView.removeFromSuperview()
                containerView?.removeGestureRecognizer(tapGestureRecognizer)
            }
        }
        
        override func dismissalTransitionWillBegin() {
            guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
            
            transitionCoordinator.animate(alongsideTransition: { context in
                self.backgroundView.alpha = 0
            })
        }
        
        override func dismissalTransitionDidEnd(_ completed: Bool) {
            if completed {
                backgroundView.removeFromSuperview()
                containerView?.removeGestureRecognizer(tapGestureRecognizer)
            }
        }
        
        override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
            guard let containerView = containerView else { return }
            coordinator.animate(alongsideTransition: { context in
                self.backgroundView.frame = containerView.frame
            })
        }
    }
    
    // MARK: FullScreenTransitionManager
    
    final class FullScreenTransitionManager: NSObject, UIViewControllerTransitioningDelegate {
        fileprivate enum AnimationState {
            case present
            case dismiss
        }
        
        private weak var anchorView: UIView?
        
        private var animationState: AnimationState = .present
        private var animationDuration: TimeInterval = Resources.animation.duration
        private var anchorViewFrame: CGRect = .zero
        
        private var propertyAnimator: UIViewPropertyAnimator?
        
        init(anchorView: UIView) {
            self.anchorView = anchorView
        }
        
        func presentationController(forPresented presented: UIViewController,
                                    presenting: UIViewController?,
                                    source: UIViewController) -> UIPresentationController? {
            FullScreenPresentationController(presentedViewController: presented, presenting: presenting)
        }
        
        func animationController(forPresented presented: UIViewController,
                                 presenting: UIViewController,
                                 source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
            prepare(animationState: .present)
        }
    
        func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
            // Starting the animation here, since UIKit do not update safe area insets after UIViewController.viewWillDisappear() is called
            defer {
                propertyAnimator = dismissAnimator(animating: dismissed)
            }
            return prepare(animationState: .dismiss)
        }
    }
    
    // MARK: UIViewControllerAnimatedTransitioning
    
    extension FullScreenTransitionManager: UIViewControllerAnimatedTransitioning {
        private func prepare(animationState: AnimationState,
                             animationDuration: TimeInterval = Resources.animation.duration) -> UIViewControllerAnimatedTransitioning? {
            guard let anchorView = anchorView else { return nil }
            
            self.animationState = animationState
            self.animationDuration = animationDuration
            self.anchorViewFrame = anchorView.safeAreaLayoutGuide.layoutFrame
            
            return self
        }
        
        func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
            animationDuration
        }
        
        func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
            switch animationState {
            case .present:
                guard
                    let toViewController = transitionContext.viewController(forKey: .to)
                else {
                    return transitionContext.completeTransition(false)
                }
                propertyAnimator = presentAnimator(with: transitionContext, animating: toViewController)
            case .dismiss:
                guard
                    let fromViewController = transitionContext.viewController(forKey: .from)
                else {
                    return transitionContext.completeTransition(false)
                }
                propertyAnimator = updatedDismissAnimator(with: transitionContext, animating: fromViewController)
            }
        }
        
        private func presentAnimator(with transitionContext: UIViewControllerContextTransitioning,
                                     animating viewController: UIViewController) -> UIViewPropertyAnimator {
            transitionContext.containerView.addSubview(viewController.view)
            let finalFrame = transitionContext.finalFrame(for: viewController)
            viewController.view.frame = anchorViewFrame
            viewController.view.layoutIfNeeded()
            return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext),
                                                                  delay: 0,
                                                                  options: [.curveEaseInOut],
                                                                  animations: {
                viewController.view.frame = finalFrame
                viewController.view.layoutIfNeeded()
            }, completion: { _ in
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            })
        }
        
        private func dismissAnimator(animating viewController: UIViewController) -> UIViewPropertyAnimator {
            let finalFrame = anchorViewFrame
            return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: animationDuration,
                                                                  delay: 0,
                                                                  options: [.curveEaseInOut],
                                                                  animations: {
                viewController.view.frame = finalFrame
                viewController.view.layoutIfNeeded()
            })
        }
        
        private func updatedDismissAnimator(with transitionContext: UIViewControllerContextTransitioning,
                                            animating viewController: UIViewController) -> UIViewPropertyAnimator {
            let propertyAnimator = self.propertyAnimator ?? dismissAnimator(animating: viewController)
            propertyAnimator.addCompletion({ _ in
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            })
            self.propertyAnimator = propertyAnimator
            return propertyAnimator
        }
    }
    

    Also, here is a link to a Stack Overflow post regarding the safe area not updating after UIViewController.viewWillDisappear. And a link to a similar post on the Apple forums.