Home > Software design >  additionalSafeAreaInsets is not accounted for during view controller dismissal, using custom UIViewC
additionalSafeAreaInsets is not accounted for during view controller dismissal, using custom UIViewC

Time:12-20

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)
        })
    }
}

CodePudding user response:

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.

  • Related