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).