使用自定义 UIViewControllerTransitioningDelegate 在视图控制器关闭期间不考虑 additionalSafeAreaInsets

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

所以,直奔问题:

我创建了一个自定义 UIViewControllerTransitioningDelegate,我用它来为来自一个视图控制器的视图设置动画,以在另一个视图控制器中全屏显示。我通过创建 UIViewControllerAnimatedTransitioning 对象来实现这一点,这些对象为呈现的视图的框架设置动画。而且效果很好!除非我在解雇期间尝试调整拥有视图的视图控制器的 additionalSafeAreaInsets...

当我尝试为视图控制器及其视图的关闭设置动画时,似乎没有考虑到 属性。它在演示期间工作正常。

下面的 gif 显示了它的外观。红色框是呈现视图的安全区域(加上一些填充) - 我试图在动画期间使用视图控制器的 additionalSafeAreaInsets 属性 来补偿拥有视图。

如 gif 所示,安全区域在演示期间已正确调整,但在解散期间未正确调整。

所以,我想要的是:使用 additionalSafeAreaInsets 通过将 additionalSafeAreaInsets 设置为安全区域的“反转”值。因此,有效安全区域从 0 开始并在呈现期间“动画化”到预期值,并从预期值开始并在解散期间“动画化”到 0。 (我引用的是“动画”,因为它实际上是动画视图的帧。但是 UIKit/Auto 布局在计算帧时使用这些属性)

非常欢迎任何关于如何解决这个问题的想法!

下面提供了自定义 UIViewControllerTransitioningDelegate 的代码。

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

经过一些调试后,我设法找到了解决此问题的方法。

简而言之,看起来安全区域在调用 UIViewController.viewWillDisappear 后没有更新,因此对 .additionalSafeAreaInsets 的任何更改都将被忽略(因为这些插图修改了视图控制器的安全区域查看)。

我当前的解决方法有点老套,但可以完成工作。由于 UIViewControllerTransitioningDelegate.animationController(forDismissed...)UIViewController.viewWillDisappearUIViewControllerAnimatedTransitioning.animateTransition(using transitionContext...) 之前被调用,我已经在该方法中启动了关闭动画。这样动画的布局计算就会正确,并设置正确的安全区域。

下面是我的自定义代码 UIViewControllerTransitioningDelegate 以及解决方法。 注意: 我已经删除了 .additionalSafeAreaInsets 的使用,因为它根本没有必要!而且我不知道为什么我认为我首先需要它...

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

此外,这里是 Apple 论坛上的 regarding the safe area not updating after UIViewController.viewWillDisappear. And a link to a similar post