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

iOS关键帧交互动画实现Youtube式关闭效果时手势结束恢复异常

问题根因

你遇到的跳转问题是交互式转场和UIView关键帧动画的属性提交逻辑不匹配导致的,具体原因有两个:

  1. 你将所有约束(或手动frame)的最终值修改逻辑直接写在了animateKeyframes的根闭包中,没有包裹在对应时间区间的addKeyframe闭包里。这会导致动画启动的瞬间,所有属性的最终状态就已经被提交给系统,关键帧仅控制layoutIfNeeded的动画插值过程,属性的最终值本身不参与动画进度的绑定。
  2. UIPercentDrivenInteractiveTransition对UIView.animateKeyframes的 scrub 兼容本身存在缺陷,尤其是使用calculationModeCubic模式时,当调用cancel()回退动画时,系统不会自动回滚你直接提交的属性修改,就会出现停留在最终帧的问题。

非交互场景下动画正常是因为动画会完整从头播放到尾,属性的连续插值会掩盖掉提前提交最终值的问题。

解决方案

推荐优先替换为UIViewPropertyAnimator实现动画,这是iOS10之后专门为交互式场景设计的动画API,对进度控制、取消/完成的状态处理兼容性更好,修改方案如下:

1. 替换动画实现

将原来的animateTransition中的关键帧动画替换为多段式UIViewPropertyAnimator实现:

// 新增存储初始顶部约束的属性
var playerTopCon: NSLayoutConstraint!

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    self.transitionContext = transitionContext
    let fromVC = transitionContext.viewController(forKey: .from) as! ViewController
    let toVC = transitionContext.viewController(forKey: .to) as! RootViewController
    let containerView = transitionContext.containerView
    innerViewCopy = fromVC.view
    tabBarHeight = toVC.tabBarVC.tabBar.frame.height
    let duration = transitionDuration(using: transitionContext)
    fromVC.view.isHidden = true
    let tabBar = toVC.tabBarVC.tabBar
    let tabBarSuperview = tabBar.superview!
    
    // 先初始化所有视图、约束到初始状态
    setupInitialState(containerView: containerView, fromVC: fromVC)
    
    // 创建动画器
    let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
    
    // 第一阶段动画:0~70%进度
    animator.addAnimations({
        self.playerHeightCon.constant /= self.playerResizeByInitial
        NSLayoutConstraint.deactivate([self.playerTopCon])
        NSLayoutConstraint.activate([self.playerBottomCon])
        self.dimmingView.alpha = 0.75
        containerView.insertSubview(tabBar, aboveSubview: self.dimmingView)
        tabBar.frame = tabBar.frame.offsetBy(dx: 0, dy: -self.tabBarHeight)
        containerView.layoutIfNeeded()
    }, delayFactor: 0)
    
    // 第二阶段动画:70%~100%进度
    animator.addAnimations({
        NSLayoutConstraint.deactivate([self.playerTrailingCon])
        NSLayoutConstraint.activate([
            self.playerCopy.widthAnchor.constraint(equalTo: containerView.widthAnchor, multiplier: self.playerMinimizedMultiplier)
        ])
        self.playerHeightCon.constant /= self.playerResizeByFinal
        self.playerBottomCon.constant = -self.tabBarHeight
        containerView.layoutIfNeeded()
    }, delayFactor: 0.7)
    
    // 动画完成回调
    animator.addCompletion { position in
        tabBarSuperview.addSubview(tabBar)
        self.tableSnapshot.removeFromSuperview()
        self.dimmingView.removeFromSuperview()
        let success = position == .end ? !transitionContext.transitionWasCancelled : false
        transitionContext.completeTransition(success)
        self.transitionContext = nil
    }
    
    animator.startAnimation()
}

// 单独抽离初始状态设置,不涉及任何动画修改
func setupInitialState(containerView: UIView, fromVC: ViewController) {
    playerCopy = innerViewCopy.viewWithTag(ViewTags.video) as? YTPlayer
    containerView.addSubview(playerCopy)
    
    tableSnapshot = fromVC.relatedVideosTableView.snapshotView(afterScreenUpdates: true)!
    tableSnapshot.translatesAutoresizingMaskIntoConstraints = false
    containerView.addSubview(tableSnapshot)
    
    dimmingView = UIView()
    dimmingView.translatesAutoresizingMaskIntoConstraints = false
    dimmingView.backgroundColor = .white
    dimmingView.alpha = 0
    containerView.addSubview(dimmingView)
    
    playerHeightCon = playerCopy.heightAnchor.constraint(equalToConstant: playerMaxHeight)
    playerTrailingCon = playerCopy.trailingAnchor.constraint(equalTo: containerView.trailingAnchor)
    let playerLeadingCon = playerCopy.leadingAnchor.constraint(equalTo: containerView.leadingAnchor)
    playerTopCon = playerCopy.topAnchor.constraint(equalTo: containerView.topAnchor, constant: UIApplication.shared.keyWindow!.safeAreaInsets.top)
    playerBottomCon = playerCopy.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -tabBarHeight-tabBarDividerHeight)
    
    NSLayoutConstraint.deactivate(playerCopy.constraints)
    NSLayoutConstraint.activate([
        playerLeadingCon,
        playerTrailingCon,
        playerTopCon,
        playerHeightCon,
        tableSnapshot.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
        tableSnapshot.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
        tableSnapshot.topAnchor.constraint(equalTo: playerCopy.bottomAnchor),
        tableSnapshot.heightAnchor.constraint(equalToConstant: fromVC.relatedVideosTableView.frame.height),
        dimmingView.leadingAnchor.constraint(equalTo: tableSnapshot.leadingAnchor),
        dimmingView.trailingAnchor.constraint(equalTo: tableSnapshot.trailingAnchor),
        dimmingView.bottomAnchor.constraint(equalTo: tableSnapshot.bottomAnchor),
        dimmingView.heightAnchor.constraint(equalTo: tableSnapshot.heightAnchor)
    ])
    
    UIView.performWithoutAnimation {
        containerView.layoutIfNeeded()
    }
}

2. 交互逻辑适配

不需要修改原有DismissalController的代码,UIViewPropertyAnimator和UIPercentDrivenInteractiveTransition完全兼容,原来的update(_ progress:)、finish()、cancel()调用都可以正常工作。

3. 额外修复点

你原来的代码中注释掉了转场完成的回调逻辑,无论转场成功还是取消都必须调用transitionContext.completeTransition(_:),否则会出现内存泄漏和界面无响应的问题。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.26 23:24:03