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