使用自定义 UIViewControllerTransitioningDelegate,View 的帧在关闭动画之前跳转

View’s frame jumps right before dismiss animation, using custom UIViewControllerTransitioningDelegate

那么,开门见山:

我正在使用自定义的 UIViewControllerTransitioningDelegate,它提供自定义的 UIPresentationController 和 present/dismiss 动画来为视图设置动画从一个视图控制器到另一个。当在第一个视图控制器的 table 视图单元格中粘贴图像时,图像在第二个视图控制器中以全屏显示,从它在 table 视图单元格中的位置到它在呈现的视图控制器。

下面的 gif 显示了正在发生的事情。请注意,当前动画一切正常,但关闭动画则不然。

我遇到的问题是,当关闭动画触发时,看起来动画视图的框架在某种程度上发生了偏移或变形。我不知道为什么!动画开始时的帧未被触及(至少我是这样),动画结束时的帧与当前动画的帧相同 - 效果非常好!

有人知道发生了什么事吗?

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

//
//  FullScreenTransitionManager.swift
//

import Foundation
import UIKit

// MARK: FullScreenPresentationController

final class FullScreenPresentationController: UIPresentationController {
    private lazy var backgroundView: UIVisualEffectView = {
        let blurVisualEffectView = UIVisualEffectView(effect: blurEffect)
        blurVisualEffectView.effect = nil
        return blurVisualEffectView
    }()
    
    private let blurEffect = UIBlurEffect(style: .systemThinMaterial)
    
    private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onTap))
    
    @objc private func onTap(_ gesture: UITapGestureRecognizer) {
        presentedViewController.dismiss(animated: true)
    }
    
    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.effect = self.blurEffect
        })
    }
    
    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.effect = nil
        })
    }
    
    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? {
        guard let anchorView = anchorView, let anchorViewSuperview = anchorView.superview else { return nil }
        let anchorViewFrame = CGRect(origin: anchorViewSuperview.convert(anchorView.frame.origin, to: nil), size: anchorView.frame.size)
        let anchorViewTag = anchorView.tag
        return FullScreenAnimationController(animationType: .present, anchorViewFrame: anchorViewFrame, anchorViewTag: anchorViewTag)
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        guard let anchorView = anchorView, let anchorViewSuperview = anchorView.superview else { return nil }
        let anchorViewFrame = CGRect(origin: anchorViewSuperview.convert(anchorView.frame.origin, to: nil), size: anchorView.frame.size)
        let anchorViewTag = anchorView.tag
        return FullScreenAnimationController(animationType: .dismiss, anchorViewFrame: anchorViewFrame, anchorViewTag: anchorViewTag)
    }
}

// MARK: UIViewControllerAnimatedTransitioning

final class FullScreenAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    enum AnimationType {
        case present
        case dismiss
    }
    
    private let animationType: AnimationType
    private let anchorViewFrame: CGRect
    private let anchorViewTag: Int
    private let animationDuration: TimeInterval
    private var propertyAnimator: UIViewPropertyAnimator?
    
    init(animationType: AnimationType, anchorViewFrame: CGRect, anchorViewTag: Int, animationDuration: TimeInterval = 0.3) {
        self.animationType = animationType
        self.anchorViewFrame = anchorViewFrame
        self.anchorViewTag = anchorViewTag
        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 window = transitionContext.containerView.window!
        let finalRootViewFrame = transitionContext.finalFrame(for: viewController)
        viewController.view.frame = finalRootViewFrame
        viewController.view.setNeedsUpdateConstraints()
        viewController.view.setNeedsLayout()
        viewController.view.layoutIfNeeded()
        let view: UIView = viewController.view.viewWithTag(anchorViewTag) ?? viewController.view
        let finalFrame = view.frame
        view.frame = CGRect(origin: window.convert(anchorViewFrame.origin, to: view.superview!), size: anchorViewFrame.size)
        view.setNeedsUpdateConstraints()
        view.setNeedsLayout()
        view.layoutIfNeeded()
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut], animations: {
            view.frame = finalFrame
            view.setNeedsUpdateConstraints()
            view.setNeedsLayout()
            view.layoutIfNeeded()
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
    
    private func dismissAnimator(with transitionContext: UIViewControllerContextTransitioning,
                                 animating viewController: UIViewController) -> UIViewPropertyAnimator {
        let window = transitionContext.containerView.window!
        let view: UIView = viewController.view.viewWithTag(anchorViewTag) ?? viewController.view
        let finalFrame = CGRect(origin: window.convert(anchorViewFrame.origin, to: view.superview!), size: anchorViewFrame.size)
        viewController.view.setNeedsUpdateConstraints()
        viewController.view.setNeedsLayout()
        viewController.view.layoutIfNeeded()
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut], animations: {
            view.frame = finalFrame
            view.setNeedsUpdateConstraints()
            view.setNeedsLayout()
            view.layoutIfNeeded()
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
}

同时为我的 FullScreenImageViewController 添加代码。

//
//  FullScreenImageViewController.swift
//

import UIKit
import TinyConstraints

class FullScreenImageViewController: UIViewController {
    private let imageView: UIImageView = {
        let image = UIImage(named: "Bananas")!
        let imageView = UIImageView(image: image)
        let aspectRatio = imageView.intrinsicContentSize.width / imageView.intrinsicContentSize.height
        imageView.contentMode = .scaleAspectFit
        imageView.widthToHeight(of: imageView, multiplier: aspectRatio)
        return imageView
    }()
    
    private lazy var imageViewWidthConstraint = imageView.widthToSuperview(relation: .equalOrLess)
    private lazy var imageViewHeightConstraint = imageView.heightToSuperview(relation: .equalOrLess)
    
    init(tag: Int) {
        super.init(nibName: nil, bundle: nil)
        imageView.tag = tag
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        configureUI()
    }
  
    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        traitCollectionChanged(from: previousTraitCollection)
    }
   
    private func configureUI() {
        view.backgroundColor = .clear
        
        view.addSubview(imageView)
        
        imageView.centerInSuperview()
        
        traitCollectionChanged(from: nil)
    }
    
    private func traitCollectionChanged(from previousTraitCollection: UITraitCollection?) {
        if traitCollection.horizontalSizeClass != .compact {
            // Landscape
            imageViewWidthConstraint.isActive = false
            imageViewHeightConstraint.isActive = true
        } else {
            // Portrait
            imageViewWidthConstraint.isActive = true
            imageViewHeightConstraint.isActive = false
        }
    }
}

以及实际呈现 FullScreenImageViewController 的代码(只是为了更好的衡量)

//
//  ViewController.swift
//

import UIKit

class ViewController: UITableViewController {
    // ...
    // ...
    
    private var fullScreenTransitionManager: FullScreenTransitionManager?

    // ...
    // ...
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let cell = tableView.cellForRow(at: indexPath) as? TableViewCell else { return }
        let fullScreenTransitionManager = FullScreenTransitionManager(anchorView: cell.bananaImageView)
        let viewController = FullScreenImageViewController(tag: cell.bananaImageView.tag)
        viewController.modalPresentationStyle = .custom
        viewController.transitioningDelegate = fullScreenTransitionManager
        present(viewController, animated: true)
        self.fullScreenTransitionManager = fullScreenTransitionManager
    }
}

玩了半天终于搞明白了。 而且我想我一直对问题的根源有一种感觉...

简答: 使用自动布局约束时,不要尝试通过更改 .frame.bounds 来为视图设置动画。更改这些属性可能会导致未定义的行为(就像我遇到的那样)。相反,通过更改约束或 .center and/or .transform 属性 来动画视图。这些属性不与布局引擎冲突。查询视图的大小时,请使用 .bounds 属性,因为在使用自动布局约束时此 属性 比 .frame 更可靠。

稍微长一点的回答: 由于我到处都在使用自动布局约束,因此将它与在动画期间手动更改视图帧相结合是行不通的。或者更正确 - 有未定义的行为。由于自动布局引擎使用约束为您修改视图的框架,因此您应该避免自己触摸 .frame(和 .bounds)属性。相反,通过更改 .center.transform 等属性来为您的视图添加动画效果。这些属性似乎与自动布局不冲突,并且在自动布局引擎完成计算后,对这些属性的更改将应用​​于您的视图。事件想法更改视图的 .frame.bounds 有时可能会与自动布局约束结合使用,就像我体验自定义演示动画一样(它似乎完美无缺!),你真的应该避免它.在某些情况下,一个解决方法可能是临时转向 .translatesAutoresizingMaskIntoConstraints == true,但这真的不是一个好主意,因为它会导致 UIKit 为您生成自动布局约束,而这些约束可能会与您自己的约束冲突。查询视图的大小时,请使用 .bounds 属性,因为在使用自动布局约束和 .transform [时,此 属性 比 .frame 更可靠=63=].

Apple 文档中值得一提的地方:

UIView.center:

Use this property, instead of the frame property, when you want to change the position of a view. The center point is always valid, even when scaling or rotation factors are applied to the view's transform. Changes to this property can be animated.

UIView.transform:

In iOS 8.0 and later, the transform property does not affect Auto Layout. Auto layout calculates a view’s alignment rectangle based on its untransformed frame.

Warning: When the value of this property is anything other than the identity transform, the value in the frame property is undefined and should be ignored.

UIView.translatesAutoresizingMaskIntoConstraints:

If this property’s value is true, the system creates a set of constraints that duplicate the behavior specified by the view’s autoresizing mask. This also lets you modify the view’s size and location using the view’s frame, bounds, or center properties, allowing you to create a static, frame-based layout within Auto Layout.

Note that the autoresizing mask constraints fully specify the view’s size and position; therefore, you cannot add additional constraints to modify this size or position without introducing conflicts. If you want to use Auto Layout to dynamically calculate the size and position of your view, you must set this property to false, and then provide a non ambiguous, nonconflicting set of constraints for the view.

By default, the property is set to true for any view you programmatically create. If you add views in Interface Builder, the system automatically sets this property to false.

对于那些感兴趣的人,下面是我的自定义 UIViewControllerTransitioningDelegate 的最终代码。仅使用自动布局约束,并且仅修改上述视图属性。 注意:我正在使用TinyConstraints来使编写约束更加愉快。

//
//  FullScreenTransitionManager.swift
//

import Foundation
import UIKit
import TinyConstraints

// MARK: FullScreenPresentationController

final class FullScreenPresentationController: UIPresentationController {
    private lazy var backgroundView: UIVisualEffectView = {
        let blurVisualEffectView = UIVisualEffectView(effect: blurEffect)
        blurVisualEffectView.effect = nil
        return blurVisualEffectView
    }()
    
    private let blurEffect = UIBlurEffect(style: .systemThinMaterial)
    
    private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onTap))
    
    @objc private func onTap(_ gesture: UITapGestureRecognizer) {
        presentedViewController.dismiss(animated: true)
    }
    
    override func presentationTransitionWillBegin() {
        guard let containerView = containerView else { return }
        
        containerView.addGestureRecognizer(tapGestureRecognizer)
        
        containerView.addSubview(backgroundView)
        backgroundView.edgesToSuperview()
        
        guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
        
        transitionCoordinator.animate(alongsideTransition: { context in
            self.backgroundView.effect = self.blurEffect
        })
    }
    
    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.effect = nil
        })
    }
    
    override func dismissalTransitionDidEnd(_ completed: Bool) {
        if completed {
            backgroundView.removeFromSuperview()
            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? {
        guard let anchorView = anchorView else { return nil }
        return FullScreenAnimationController(animationType: .present, anchorView: anchorView)
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        guard let anchorView = anchorView else { return nil }
        return FullScreenAnimationController(animationType: .dismiss, anchorView: anchorView)
    }
}

// MARK: UIViewControllerAnimatedTransitioning

final class FullScreenAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    fileprivate enum AnimationType {
        case present
        case dismiss
    }
    
    private let animationType: AnimationType
    private let anchorViewCenter: CGPoint
    private let anchorViewSize: CGSize
    private let anchorViewTag: Int
    private let animationDuration: TimeInterval
    private var propertyAnimator: UIViewPropertyAnimator?
    
    fileprivate init(animationType: AnimationType, anchorView: UIView, animationDuration: TimeInterval = 0.3) {
        self.animationType = animationType
        self.anchorViewCenter = anchorView.superview?.convert(anchorView.center, to: nil) ?? .zero
        self.anchorViewSize = anchorView.bounds.size
        self.anchorViewTag = anchorView.tag
        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)
            toViewController.view.edgesToSuperview()
            toViewController.view.layoutIfNeeded() // Force a layout update so that the view is ready for the animator
            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 view: UIView = viewController.view.viewWithTag(anchorViewTag) ?? viewController.view
        let finalSize = view.bounds.size
        let finalCenter = view.center
        view.transform = CGAffineTransform(scaleX: anchorViewSize.width / finalSize.width,
                                           y: anchorViewSize.height / finalSize.height)
        view.center = view.superview!.convert(anchorViewCenter, from: nil)
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut], animations: {
            view.transform = .identity
            view.center = finalCenter
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
    
    private func dismissAnimator(with transitionContext: UIViewControllerContextTransitioning,
                                 animating viewController: UIViewController) -> UIViewPropertyAnimator {
        let view: UIView = viewController.view.viewWithTag(anchorViewTag) ?? viewController.view
        let initialSize = view.bounds.size
        let finalCenter = view.superview!.convert(anchorViewCenter, from: nil)
        let finalTransform = CGAffineTransform(scaleX: self.anchorViewSize.width / initialSize.width,
                                               y: self.anchorViewSize.height / initialSize.height)
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut], animations: {
            view.transform = finalTransform
            view.center = finalCenter
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
}