You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何实现先交互转场,达阈值后执行不同结束动画?

刚好前段时间做过类似App Store Today卡片的dismiss交互,踩了几个小坑,分享下完整的实现思路和代码吧~

实现带阈值切换的交互式转场(类似Today卡片 dismissal)

常规的交互式转场依赖UIViewControllerAnimatedTransitioning定义动画逻辑,UIPercentDrivenInteractiveTransition控制进度,但这次需求的核心是拖动进度超过阈值后,放弃交互式进度驱动,切换到自定义的快速结束动画,下面是具体实现步骤:

核心思路拆解

  1. 自定义动画器:需要区分两种结束状态——常规交互式完成/取消,以及触发阈值后的特殊收尾动画,所以要给动画器加一个标记位来切换逻辑
  2. 自定义交互式控制器:监听拖动手势的进度,当超过设定阈值时,停止进度驱动,手动触发转场完成,并通知动画器切换到特殊动画
  3. 手势绑定:在卡片页面添加边缘拖动手势,关联交互式控制器并触发转场

关键代码实现

1. 自定义转场动画器

这个类负责定义两种不同的动画逻辑:

class CardDismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {
    // 标记是否触发阈值后的特殊动画
    var shouldUseSpecialFinishAnimation = false
    private let animationDuration: TimeInterval = 0.35

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return animationDuration
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let fromVC = transitionContext.viewController(forKey: .from),
              let toVC = transitionContext.viewController(forKey: .to),
              let containerView = transitionContext.containerView else { return }

        containerView.insertSubview(toVC.view, belowSubview: fromVC.view)
        toVC.view.alpha = 0.8 // 初始背景透明度,贴近原生效果

        if shouldUseSpecialFinishAnimation {
            // 阈值触发后的快速收尾动画:卡片缩小+淡出,背景完全显示
            UIView.animate(withDuration: animationDuration * 0.7,
                           delay: 0,
                           usingSpringWithDamping: 0.65,
                           initialSpringVelocity: 1.2,
                           options: .curveEaseOut) {
                fromVC.view.transform = CGAffineTransform(scaleX: 0.88, y: 0.88)
                fromVC.view.alpha = 0
                toVC.view.alpha = 1
            } completion: { _ in
                fromVC.view.transform = .identity
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            }
        } else {
            // 常规交互式动画:跟随拖动进度的位移+轻微缩小
            let finalFrame = CGRect(x: 0, y: containerView.bounds.height, width: fromVC.view.frame.width, height: fromVC.view.frame.height)
            UIView.animate(withDuration: animationDuration, delay: 0, options: .curveLinear) {
                fromVC.view.frame = finalFrame
                fromVC.view.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
            } completion: { _ in
                fromVC.view.transform = .identity
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            }
        }
    }
}

2. 自定义交互式转场控制器

这个类是核心,负责监听手势进度、判断阈值并切换动画逻辑:

class CardInteractiveTransition: UIPercentDrivenInteractiveTransition {
    var isInteractive = false
    // 自定义阈值,比如拖动进度超过30%触发特殊动画
    private let triggerThreshold: CGFloat = 0.3
    weak var animator: CardDismissAnimator?

    func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
        guard let containerView = gesture.view?.superview else { return }
        let translation = gesture.translation(in: containerView)
        // 计算拖动进度:垂直拖动距离 / 容器高度
        let progress = translation.y / containerView.bounds.height

        switch gesture.state {
        case .began:
            isInteractive = true
            // 触发dismiss转场
            gesture.view?.window?.rootViewController?.dismiss(animated: true)
        case .changed:
            update(progress)
            // 进度超过阈值,切换到特殊动画并结束交互式驱动
            if progress > triggerThreshold {
                animator?.shouldUseSpecialFinishAnimation = true
                finish()
                isInteractive = false
            }
        case .ended, .cancelled:
            guard isInteractive else { return }
            // 未到阈值时,判断是否完成/取消转场
            if progress > triggerThreshold * 0.5 {
                animator?.shouldUseSpecialFinishAnimation = true
                finish()
            } else {
                cancel()
            }
            isInteractive = false
        default:
            isInteractive = false
            cancel()
        }
    }
}

3. 在卡片页面绑定手势与转场代理

class CardViewController: UIViewController {
    private let interactiveTransition = CardInteractiveTransition()

    override func viewDidLoad() {
        super.viewDidLoad()
        // 添加左边缘拖动手势(可根据需求改为底部/其他边缘)
        let edgePan = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleEdgePan(_:)))
        edgePan.edges = .left
        view.addGestureRecognizer(edgePan)
    }

    @objc private func handleEdgePan(_ gesture: UIScreenEdgePanGestureRecognizer) {
        interactiveTransition.handlePanGesture(gesture)
    }

    // 重写dismiss方法,设置转场代理
    override func dismiss(animated: Bool, completion: (() -> Void)? = nil) {
        transitioningDelegate = self
        super.dismiss(animated: animated, completion: completion)
    }
}

// 实现转场代理协议
extension CardViewController: UIViewControllerTransitioningDelegate {
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        let animator = CardDismissAnimator()
        interactiveTransition.animator = animator
        return animator
    }

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactiveTransition.isInteractive ? interactiveTransition : nil
    }
}

注意事项

  • 阈值参数triggerThreshold可以根据产品需求调整,比如App Store的阈值大概是拖动卡片到屏幕中间偏下的位置
  • 特殊动画的弹簧参数(阻尼、初速度)需要反复调试,尽量贴近原生流畅感
  • 要处理好手势状态的边界情况,比如拖动到阈值后快速松手,确保动画能正常收尾
  • 如果是导航控制器的push/pop转场,只需要调整转场代理的对应方法(比如animationController(forPop:)),核心逻辑完全通用

内容的提问来源于stack exchange,提问作者aunnnn

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.22 08:32:32