使用CircularCustomTransition后自定义转场动画出现残留视图求助
核心问题根源
你遇到的黄色残留视图问题,本质是转场上下文管理混乱和视图/图层未正确清理导致的,具体来看:
1. 多次调用completeTransition(_:)破坏转场流程
在CircularTransition中,你在四个不同位置都调用了context?.completeTransition(_:):
animateOldTextOffscreen的completion闭包animate方法内直接调用animateToTextView的completion闭包CAAnimationDelegate的animationDidStop(_:finished:)方法
completeTransition(_:)是UIKit转场的终结方法,只能被调用一次。多次调用会让UIKit的转场状态异常,容器视图中的临时视图(快照、背景视图)无法被自动清理,这些残留视图就会在后续转场中显示出来。
2. 手动操作视图层级违反转场规范
你在animateTransition中直接执行了fromVC.mainView.removeFromSuperview(),跳过了UIKit的转场视图管理逻辑。转场过程中,视图的添加/移除应该完全由transitionContext处理,手动操作会导致视图引用残留。
3. 临时视图与图层未主动清理
- 转场中添加的
snapshot和backgroundView没有在转场完成后移除,会堆积在容器视图中 - 给
toVC.mainView.layer添加的maskLayer在动画结束后未被移除,若转场流程异常,这个mask会残留到其他视图上
4. 其他动画控制器的潜在问题
CustomBounceUpAnimationController中重复写了两段完全相同的UIView.animate代码,每次都会调用completeTransition(true),进一步加重了转场上下文的混乱。
分步修复方案
一、修复CircularTransition核心问题
1. 确保completeTransition(_:)只调用一次
用UIView.animateKeyframes统一管理所有UI动画,在所有动画完成后仅调用一次转场终结方法:
override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let fromVC = transitionContext.viewController(forKey: .from) as? CircleTransitionable, let toVC = transitionContext.viewController(forKey: .to) as? CircleTransitionable, let snapshot = fromVC.mainView.snapshotView(afterScreenUpdates: false) else { transitionContext.completeTransition(false) return } context = transitionContext let containerView = transitionContext.containerView // 添加临时背景视图 let backgroundView = UIView() backgroundView.frame = toVC.mainView.frame backgroundView.backgroundColor = fromVC.mainView.backgroundColor containerView.addSubview(backgroundView) // 添加转场所需视图(不要手动移除fromVC的view) containerView.addSubview(snapshot) containerView.addSubview(toVC.mainView) // 初始化文本视图的初始状态 let originalCenter = toVC.contentTextView.center toVC.contentTextView.alpha = 0.0 toVC.contentTextView.center = fromVC.triggerButton.center toVC.contentTextView.transform = CGAffineTransform(scaleX: 0.1, y: 0.1) // 用关键帧动画统一管理UI动画序列 UIView.animateKeyframes(withDuration: 0.35, delay: 0, options: [], animations: { // 旧视图滑出动画 UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.25) { snapshot.center = CGPoint(x: snapshot.center.x - 1000, y: snapshot.center.y + 1500) snapshot.transform = CGAffineTransform(scaleX: 5.0, y: 5.0) } // 文本淡入动画(延迟0.1s开始) UIView.addKeyframe(withRelativeStartTime: 0.1/0.35, relativeDuration: 0.25/0.35) { toVC.contentTextView.transform = .identity toVC.contentTextView.center = originalCenter toVC.contentTextView.alpha = 1.0 } }, completion: { [weak self] finished in // 清理临时视图 snapshot.removeFromSuperview() backgroundView.removeFromSuperview() // 标记转场完成,同时处理取消场景 transitionContext.completeTransition(finished && !transitionContext.transitionWasCancelled) self?.context = nil }) // 单独处理圆形mask的CA动画 animateCircleMask(toView: toVC.mainView, fromTriggerButton: fromVC.triggerButton) }
2. 重构圆形mask动画并清理图层
修改animate方法为animateCircleMask,并在动画结束后移除mask:
func animateCircleMask(toView: UIView, fromTriggerButton triggerButton: UIButton) { // Starting Path let rect = CGRect(x: triggerButton.frame.origin.x, y: triggerButton.frame.origin.y, width: triggerButton.frame.width, height: triggerButton.frame.width) let circleMaskPathInitial = UIBezierPath(ovalIn: rect) // Destination Path let fullHeight = toView.bounds.height let extremePoint = CGPoint(x: triggerButton.center.x, y: fullHeight) let radius = sqrt((extremePoint.x*extremePoint.x) + (extremePoint.y*extremePoint.y)) let circleMaskPathFinal = UIBezierPath(ovalIn: triggerButton.frame.insetBy(dx: -radius, dy: -radius)) // Actual mask layer let maskLayer = CAShapeLayer() maskLayer.path = circleMaskPathFinal.cgPath toView.layer.mask = maskLayer // Mask Animation let maskLayerAnimation = CABasicAnimation(keyPath: "path") maskLayerAnimation.fromValue = circleMaskPathInitial.cgPath maskLayerAnimation.toValue = circleMaskPathFinal.cgPath maskLayerAnimation.duration = 0.25 maskLayerAnimation.delegate = self maskLayer.add(maskLayerAnimation, forKey: "path") }
更新CAAnimationDelegate方法,仅清理mask不再调用completeTransition:
extension CircularTransition: CAAnimationDelegate { func animationDidStop(_ anim: CAAnimation, finished flag: Bool) { // 动画结束后移除mask,避免图层残留 context?.viewController(forKey: .to)?.view.layer.mask = nil } }
3. 移除手动操作fromVC视图的代码
删除fromVC.mainView.removeFromSuperview(),转场完成后UIKit会自动处理fromVC视图的移除。
二、修复CustomBounceUpAnimationController的重复动画问题
删除重复的UIView.animate代码,确保只调用一次转场终结方法:
class CustomBounceUpAnimationController: CustomAnimator { override func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 2.5 } override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { let fromViewController = transitionContext.viewController(forKey: .from)! let toViewController = transitionContext.viewController(forKey: .to)! let finalFrameForVC = transitionContext.finalFrame(for: toViewController) let containerView = transitionContext.containerView containerView.bringSubviewToFront(toViewController.view) let bounds = UIScreen.main.bounds toViewController.view.frame = finalFrameForVC.offsetBy(dx: 0.0, dy: bounds.size.height) containerView.addSubview(toViewController.view) UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0.0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.0, options: .curveLinear, animations: { fromViewController.view.alpha = 0.5 toViewController.view.frame = finalFrameForVC }) { finished in fromViewController.view.alpha = 1.0 transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } }
额外最佳实践建议
- 始终依赖
transitionContext提供的视图和框架,不要直接操作VC的视图层级 - 对于CAAnimation,务必在动画结束后清理相关图层(比如mask)
- 转场完成后及时将
context置为nil,避免循环引用(你已经用了weak var context,保持这个好习惯) - 测试转场取消场景,确保在
transitionWasCancelled为true时正确恢复视图状态
内容的提问来源于stack exchange,提问作者iKK

