iOS: Interactive animation with keyframes not resumed correctly on gesture end

Viewed 110

I'm trying to make an interactive dismissal animation, just like in the Youtube app. The animation I have in mind is here (sorry it's an iCloud link, but converting it to GIF it's over 2MB).

I'm using the standard UIViewControllerAnimatedTransitioning interface for the animator and UIPercentDrivenInteractiveTransition.

Simplified version of my animator class:

class MinimizeDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    var transitionContext: UIViewControllerContextTransitioning!
    var dimmingView: UIView!
    var innerViewCopy: UIView!
    var playerCopy: YTPlayer!
    let interactionController: DismissalController!
    var tableSnapshot: UIView!
    var separatorView: UIView!
    
    var tabBarHeight: CGFloat!
    let playerMaxHeight: CGFloat = 250
    var playerResizeByInitial: CGFloat = 2
    var playerResizeByFinal: CGFloat = 2.25
    let playerMinimizedMultiplier: CGFloat = 0.3
    
    var playerHeightCon: NSLayoutConstraint!
    var playerTrailingCon: NSLayoutConstraint!
    var playerBottomCon: NSLayoutConstraint!
    
    init(interactionController: DismissalController) {
      // stored here to be used by the transitioning delegate, doesn't have any specific purpose in this file
      self.interactionController = interactionController
    }

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        0.75
    }
    
    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 = 0.75
        
        fromVC.view.isHidden = true
        let tabBar = toVC.tabBarVC.tabBar
        let tabBarSuperview = tabBar.superview!
        
        UIView.animateKeyframes(withDuration: duration, delay: 0, options: .calculationModeCubic, animations: {
            self.animateImageMoveDown()
            
            UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.7, animations: {
                self.dimmingView.alpha = 0.75
                containerView.layoutIfNeeded()
            })
            
            UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5, animations: {
                containerView.insertSubview(tabBar, aboveSubview: self.dimmingView)
                tabBar.frame = tabBar.frame.offsetBy(dx: 0, dy: -self.tabBarHeight)
            })
            
            self.animateImageToBottomLeft()

            UIView.addKeyframe(withRelativeStartTime: 0.7, relativeDuration: 0.3, animations: {
                containerView.layoutIfNeeded()
            })
        }, completion: { done in
            tabBarSuperview.addSubview(tabBar)
            self.tableSnapshot.removeFromSuperview()
            self.dimmingView.removeFromSuperview()
            
            // TODO: some more cleanup of temporary views
            
            //self.transitionContext!.finishInteractiveTransition()
            //transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            self.transitionContext = nil
        })
    }

    func animateImageMoveDown() {
        let fromVC = transitionContext.viewController(forKey: .from) as! ViewController
        let containerView = transitionContext.containerView
        
        // not exactly copy, but still used to be animated to a different size 
        playerCopy = innerViewCopy.viewWithTag(ViewTags.video) as? YTPlayer
        containerView.addSubview(playerCopy)
        
        // details of the video below the player + related videos
        tableSnapshot = fromVC.relatedVideosTableView.snapshotView(afterScreenUpdates: true)!
        tableSnapshot.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(tableSnapshot)
        
        // dims the table snapshot as the animation progresses
        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)
        let playerTopCon = playerCopy.topAnchor.constraint(equalTo: containerView.topAnchor, constant: UIApplication.shared.keyWindow!.safeAreaInsets.top)
        
        // there's additional "separator" view with fixed height to be animated, but it's not part of this excerpt
        playerBottomCon = playerCopy.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -tabBarHeight-tabBarDividerHeight)
        
        NSLayoutConstraint.deactivate(playerCopy.constraints) // deactivate constraints from the original view controller
        NSLayoutConstraint.activate([
            playerLeadingCon,
            playerTrailingCon,
            playerTopCon,
            playerHeightCon,
            
            // exactly below the player, contains video info and related videos
            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),
             
            // placed exactly over the snapshot
            dimmingView.leadingAnchor.constraint(equalTo: tableSnapshot.leadingAnchor),
            dimmingView.trailingAnchor.constraint(equalTo: tableSnapshot.trailingAnchor),
            dimmingView.bottomAnchor.constraint(equalTo: tableSnapshot.bottomAnchor),
            dimmingView.heightAnchor.constraint(equalTo: tableSnapshot.heightAnchor)
        ])
        
        // first recalculate frames (starting values for the animation in the keyframe)
        UIView.performWithoutAnimation {
            containerView.layoutIfNeeded()
        }
        
        // resize player a bit
        playerHeightCon.constant /= playerResizeByInitial
        
        // move it down, close to the tab bar
        NSLayoutConstraint.deactivate([playerTopCon])
        NSLayoutConstraint.activate([playerBottomCon])
    }
    
    func animateImageToBottomLeft() {
        let containerView = transitionContext!.containerView
        
        // resize width, this is the final, minimized player width (check end of video in question)
        let newConstraints: [NSLayoutConstraint] = [
            playerCopy.widthAnchor.constraint(equalTo: containerView.widthAnchor, multiplier: playerMinimizedMultiplier)
        ]
        
        // in order to resize it, it shouldn't stretch to the container view's trailing
        NSLayoutConstraint.deactivate([playerTrailingCon])
            
        NSLayoutConstraint.activate(newConstraints)
        
        playerHeightCon.constant /= playerResizeByFinal // decrease height further
        playerBottomCon.constant = -tabBarHeight // position player exactly above the tab bar
    }
}


The interaction controller:

import UIKit

class DismissalController: UIPercentDrivenInteractiveTransition {
    var interactionInProgress = false
    private var shouldCompleteTransition = false
    private weak var viewController: UIViewController!
    
    init(viewController: UIViewController) {
      super.init()
      self.viewController = viewController
      prepareGestureRecognizer(in: viewController)
    }
    
    private func prepareGestureRecognizer(in vc: UIViewController) {
        // custom gesture recognizer, which allows vertical pans only
        let viewPan = PanDirectionGestureRecognizer(direction: .vertical, target: self, action: #selector(self.handleGesture(_:)))
        viewPan.delaysTouchesBegan = false
        viewPan.delaysTouchesEnded = false
        viewPan.cancelsTouchesInView = false
        
        let player = (vc as! ViewController).player
        
        player.addGestureRecognizer(viewPan)
    }
    
    @objc func handleGesture(_ gestureRecognizer: PanDirectionGestureRecognizer) {
        let translation = gestureRecognizer.translation(in: gestureRecognizer.view!.superview!)
        
        // TODO: instead of 800, use dynamic value -> appr. ScreenHeight - SafeAreaTop - TabBarHeight
        var progress = (translation.y / 800)
        progress = CGFloat(fminf(fmaxf(Float(progress), 0.0), 1.0))
        
        switch gestureRecognizer.state {
        case .began:
            (self.viewController as! ViewController).player.isUserInteractionEnabled = false
            interactionInProgress = true
            viewController.dismiss(animated: true, completion: nil)
        case .changed:
            shouldCompleteTransition = progress > 0.35
            update(progress)
        case .cancelled:
            interactionInProgress = false
            cancel()
        case .ended:
            interactionInProgress = false
            if shouldCompleteTransition {
                finish()
            } else {
                cancel()
            }
        default:
            break
        }
    }
}

My issue: If I release my finger early (5-10%), thus ending the pan gesture, the video player animates to the last keyframe values (when it's minimised and in the bottom left corner) but that should happen later, 70% through the animation. Video here

You can see if I just move up and down my finger (interact) layout is updated correctly, with the expected values for the given percentage in time. The issue is reproducible when not using AutoLayout as well (manually calculating frames). Also, the animation works perfectly fine when it's not interactive (performed on button tap let's say).

0 Answers
Related