Search code examples
iosswiftautolayoutuikitcore-animation

Safe area is not updated after UIViewController.viewWillDisappear is called


To me it looks like the safe area of a view is not updated after the owning view controller's .viewWillDisappear() method is called.

Is this intended or a bug in the framework?

The issue is easily visualised by creating a custom UIViewControllerTransitioningDelegate that animates a smaller view in one view controller, to full screen size in another (constrained to safe areas). Then the safe area will expand as the present animation goes on (as expected), but will not shrink as the dismiss animation goes on (not expected!). The expected behaviour would be that the safe area expands during the present animation, and shrinks during the dismiss animation.

The gif below shows the unexpected behaviour. The grey area of the presented view controller is the safe area.

Safe area not shrinking during dismiss animation

Below is the code I used to visualise this problem. ViewController.swift presents MyViewController.swift using FullScreenTransitionManager.swift

//
//  FullScreenTransitionManager.swift
//

import Foundation
import UIKit

// MARK: FullScreenPresentationController

final class FullScreenPresentationController: UIPresentationController {
    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)
    }
    
    override func presentationTransitionDidEnd(_ completed: Bool) {
        if !completed {
            containerView?.removeGestureRecognizer(tapGestureRecognizer)
        }
    }
    
    override func dismissalTransitionDidEnd(_ completed: Bool) {
        if completed {
            containerView?.removeGestureRecognizer(tapGestureRecognizer)
        }
    }
}

// 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?.frame ?? CGRect(origin: presented.view.center, size: .zero)
        return FullScreenAnimationController(animationType: .present,
                                             anchorFrame: anchorFrame)
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        let anchorFrame = anchorView?.frame ?? 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 = 5) {
        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)
        viewController.view.frame = anchorFrame
        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(with transitionContext: UIViewControllerContextTransitioning,
                                 animating viewController: UIViewController) -> UIViewPropertyAnimator {
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext),
                                                              delay: 0,
                                                              options: [.curveEaseInOut],
                                                              animations: {
            viewController.view.frame = self.anchorFrame
            viewController.view.layoutIfNeeded()
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
}
//
//  MyViewController.swift
//

import UIKit

class MyViewController: UIViewController {
    private let square: UIView = {
        let view = UIView()
        view.backgroundColor = .white
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemGray
        
        view.addSubview(square)
        
        NSLayoutConstraint.activate([
            square.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            square.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            square.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            square.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        ])
    }
}
//
//  ViewController.swift
//

import UIKit

class ViewController: UIViewController {
    private let button: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("Click Me!", for: .normal)
        button.setTitleColor(.black, for: .normal)
        button.backgroundColor = .white
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    private var fullScreenTransitionManager: FullScreenTransitionManager?

    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground
        
        view.addSubview(button)
        
        NSLayoutConstraint.activate([
            button.widthAnchor.constraint(equalToConstant: 200),
            button.heightAnchor.constraint(equalToConstant: 200),
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
        
        button.addTarget(self, action: #selector(presentMyViewController), for: .primaryActionTriggered)
    }

    @objc private func presentMyViewController(_ button: UIButton) {
        let fullScreenTransitionManager = FullScreenTransitionManager(anchorView: button)
        let myViewController = MyViewController()
        myViewController.modalPresentationStyle = .custom
        myViewController.transitioningDelegate = fullScreenTransitionManager
        present(myViewController, animated: true)
        self.fullScreenTransitionManager = fullScreenTransitionManager
    }
}

Solution

  • As confirmed by the answer on this post on the Apple Developer forums, this behaviour is expected. And safe areas is not updated if a view controller is not in the appearing state (between viewWillAppear and viewWillDisappear)