Photo Transitioning/AssetTransitionDriver.swift
/* |
Copyright (C) 2016 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
The AssetTransitionDriver class manages the transition from start to finish. This object will live the lifetime of the transition, and |
is resposible for driving the interactivity and animation. It utilizes UIViewPropertyAnimator to smoothly interact and interrupt the |
transition. |
*/ |
import UIKit |
class AssetTransitionDriver: NSObject { |
var transitionAnimator: UIViewPropertyAnimator! |
var isInteractive: Bool { return transitionContext.isInteractive } |
let transitionContext: UIViewControllerContextTransitioning |
private let operation: UINavigationControllerOperation |
private let panGestureRecognizer: UIPanGestureRecognizer |
private var itemFrameAnimator: UIViewPropertyAnimator? |
private var items: Array<AssetTransitionItem> = [] |
private var interactiveItem: AssetTransitionItem? |
// MARK: Initialization |
init(operation: UINavigationControllerOperation, context: UIViewControllerContextTransitioning, panGestureRecognizer panGesture: UIPanGestureRecognizer) { |
self.transitionContext = context |
self.operation = operation |
self.panGestureRecognizer = panGesture |
super.init() |
// Setup the transition "chrome" |
let fromViewController = context.viewController(forKey: .from)! |
let toViewController = context.viewController(forKey: .to)! |
let fromAssetTransitioning = (fromViewController as! AssetTransitioning) |
let toAssetTransitioning = (toViewController as! AssetTransitioning) |
let fromView = fromViewController.view! |
let toView = toViewController.view! |
let containerView = context.containerView |
// Add ourselves as a target of the pan gesture |
self.panGestureRecognizer.addTarget(self, action: #selector(updateInteraction(_:))) |
// Ensure the toView has the correct size and position |
toView.frame = context.finalFrame(for: toViewController) |
// Create a visual effect view and animate the effect in the transition animator |
let effect: UIVisualEffect? = (operation == .pop) ? UIBlurEffect(style: .extraLight) : nil |
let targetEffect: UIVisualEffect? = (operation == .pop) ? nil : UIBlurEffect(style: .light) |
let visualEffectView = UIVisualEffectView(effect: effect) |
visualEffectView.frame = containerView.bounds |
visualEffectView.autoresizingMask = [.flexibleWidth,.flexibleHeight] |
containerView.addSubview(visualEffectView) |
// Insert the toViewController's view into the transition container view |
let topView: UIView |
var topViewTargetAlpha: CGFloat = 0.0 |
if operation == .push { |
topView = toView |
topViewTargetAlpha = 1.0 |
toView.alpha = 0.0 |
containerView.addSubview(toView) |
} else { |
topView = fromView |
topViewTargetAlpha = 0.0 |
containerView.insertSubview(toView, at: 0) |
} |
// Initiate the handshake between view controller, per the AssetTransitioning Protocol |
self.items = fromAssetTransitioning.itemsForTransition(context: context).filter({ (item) -> Bool in |
guard let targetFrame = toAssetTransitioning.targetFrame(transitionItem: item), !targetFrame.isEmpty && !targetFrame.isNull && !targetFrame.isInfinite else { |
return false |
} |
item.targetFrame = containerView.convert(targetFrame, from: toView) |
item.imageView = { |
let imageView = UIImageView(frame: containerView.convert(item.initialFrame, from: nil)) |
imageView.clipsToBounds = true |
imageView.contentMode = .scaleAspectFill |
imageView.isUserInteractionEnabled = true |
imageView.image = item.image |
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(press(_ :))) |
longPressGestureRecognizer.minimumPressDuration = 0.0 |
imageView.addGestureRecognizer(longPressGestureRecognizer) |
containerView.addSubview(imageView) |
return imageView |
}() |
return true |
}) |
// Inform the view controller's the transition is about to start |
fromAssetTransitioning.willTransition(fromController: fromViewController, toController: toViewController, items: items) |
toAssetTransitioning.willTransition(fromController: fromViewController, toController: toViewController, items: items) |
// Add animations and completion to the transition animator |
self.setupTransitionAnimator({ |
topView.alpha = topViewTargetAlpha |
visualEffectView.effect = targetEffect |
}, transitionCompletion: { [unowned self] (position) in |
// Finish the protocol handshake |
fromAssetTransitioning.didTransition(fromController: fromViewController, toController: toViewController, items: self.items) |
toAssetTransitioning.didTransition(fromController: fromViewController, toController: toViewController, items: self.items) |
// Remove all transition views |
for item in self.items { |
item.imageView?.removeFromSuperview() |
} |
visualEffectView.removeFromSuperview() |
}) |
if context.isInteractive { |
// If the transition is initially interactive, ensure we know what item is being manipulated |
self.updateInteractiveItemFor(panGestureRecognizer.location(in: containerView)) |
} else { |
// Begin the animation phase immediately if the transition is not initially interactive |
animate(.end) |
} |
} |
// MARK: Private Helpers |
private func updateInteractiveItemFor(_ locationInContainer: CGPoint) { |
func itemAtPoint(point: CGPoint) -> AssetTransitionItem? { |
if let view = transitionContext.containerView.hitTest(point, with: nil) { |
for item in self.items { |
if item.imageView == view { |
return item |
} |
} |
} |
return nil |
} |
if let item = itemAtPoint(point: locationInContainer), let itemCenter = item.imageView?.center { |
item.touchOffset = locationInContainer - itemCenter |
interactiveItem = item |
} |
} |
private func convert(_ velocity: CGPoint, for item: AssetTransitionItem?) -> CGVector { |
guard let currentFrame = item?.imageView?.frame, let targetFrame = item?.targetFrame else { |
return CGVector.zero |
} |
let dx = abs(targetFrame.midX - currentFrame.midX) |
let dy = abs(targetFrame.midY - currentFrame.midY) |
guard dx > 0.0 && dy > 0.0 else { |
return CGVector.zero |
} |
let range = CGFloat(35.0) |
let clippedVx = clip(-range, range, velocity.x / dx) |
let clippedVy = clip(-range, range, velocity.y / dy) |
return CGVector(dx: clippedVx, dy: clippedVy) |
} |
private func timingCurveVelocity() -> CGVector { |
// Convert the gesture recognizer's velocity into the initial velocity for the animation curve |
let gestureVelocity = panGestureRecognizer.velocity(in: transitionContext.containerView) |
return convert(gestureVelocity, for: interactiveItem) |
} |
private func completionPosition() -> UIViewAnimatingPosition { |
let completionThreshold: CGFloat = 0.33 |
let flickMagnitude: CGFloat = 1200 //pts/sec |
let velocity = panGestureRecognizer.velocity(in: transitionContext.containerView).vector |
let isFlick = (velocity.magnitude > flickMagnitude) |
let isFlickDown = isFlick && (velocity.dy > 0.0) |
let isFlickUp = isFlick && (velocity.dy < 0.0) |
if (operation == .push && isFlickUp) || (operation == .pop && isFlickDown) { |
return .end |
} else if (operation == .push && isFlickDown) || (operation == .pop && isFlickUp) { |
return .start |
} else if transitionAnimator.fractionComplete > completionThreshold { |
return .end |
} else { |
return .start |
} |
} |
private func updateItemsForInteractive(translation: CGPoint) { |
let progressStep = progressStepFor(translation: translation) |
for item in items { |
let initialSize = item.initialFrame.size |
if let imageView = item.imageView, let finalSize = item.targetFrame?.size { |
let currentSize = imageView.frame.size |
let itemPercentComplete = clip(-0.05, 1.05, (currentSize.width - initialSize.width) / (finalSize.width - initialSize.width) + progressStep) |
let itemWidth = lerp(initialSize.width, finalSize.width, itemPercentComplete) |
let itemHeight = lerp(initialSize.height, finalSize.height, itemPercentComplete) |
let scaleTransform = CGAffineTransform(scaleX: (itemWidth / currentSize.width), y: (itemHeight / currentSize.height)) |
let scaledOffset = item.touchOffset.apply(transform: scaleTransform) |
imageView.center = (imageView.center + (translation + (item.touchOffset - scaledOffset))).point |
imageView.bounds = CGRect(origin: CGPoint.zero, size: CGSize(width: itemWidth, height: itemHeight)) |
item.touchOffset = scaledOffset |
} |
} |
} |
private func progressStepFor(translation: CGPoint) -> CGFloat { |
return (operation == .push ? -1.0 : 1.0) * translation.y / transitionContext.containerView.bounds.midY |
} |
// MARK: Gesture Callbacks |
func press(_ longPressGesture: UILongPressGestureRecognizer) { |
switch longPressGesture.state { |
case .began: |
pauseAnimation() |
updateInteractiveItemFor(longPressGesture.location(in: transitionContext.containerView)) |
case .ended, .cancelled: |
endInteraction() |
default: break |
} |
} |
// MARK: Interesting UIViewPropertyAnimator Setup |
/// UIKit calls startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) |
/// on our interaction controller (AssetTransitionController). The AssetTransitionDriver (self) is |
/// then created with the transitionContext to manage the transition. It calls this func from Init(). |
func setupTransitionAnimator(_ transitionAnimations: @escaping ()->(), transitionCompletion: @escaping (UIViewAnimatingPosition)->()) { |
// The duration of the transition, if uninterrupted |
let transitionDuration = AssetTransitionDriver.animationDuration() |
// Create a UIViewPropertyAnimator that lives the lifetime of the transition |
transitionAnimator = UIViewPropertyAnimator(duration: transitionDuration, curve: .easeOut, animations: transitionAnimations) |
transitionAnimator.addCompletion { [unowned self] (position) in |
// Call the supplied completion |
transitionCompletion(position) |
// Inform the transition context that the transition has completed |
let completed = (position == .end) |
self.transitionContext.completeTransition(completed) |
} |
} |
// MARK: Interesting Interruptible Transitioning Stuff |
func updateInteraction(_ fromGesture: UIPanGestureRecognizer) { |
switch fromGesture.state { |
case .began, .changed: |
// Ask the gesture recognizer for it's translation |
let translation = fromGesture.translation(in: transitionContext.containerView) |
// Calculate the percent complete |
let percentComplete = transitionAnimator.fractionComplete + progressStepFor(translation: translation) |
// Update the transition animator's fractionCompete to scrub it's animations |
transitionAnimator.fractionComplete = percentComplete |
// Inform the transition context of the updated percent complete |
transitionContext.updateInteractiveTransition(percentComplete) |
// Update each transition item for the |
updateItemsForInteractive(translation: translation) |
// Reset the gestures translation |
fromGesture.setTranslation(CGPoint.zero, in: transitionContext.containerView) |
case .ended, .cancelled: |
// End the interactive phase of the transition |
endInteraction() |
default: break |
} |
} |
func endInteraction() { |
// Ensure the context is currently interactive |
guard transitionContext.isInteractive else { return } |
// Inform the transition context of whether we are finishing or cancelling the transition |
let completionPosition = self.completionPosition() |
if completionPosition == .end { |
transitionContext.finishInteractiveTransition() |
} else { |
transitionContext.cancelInteractiveTransition() |
} |
// Begin the animation phase of the transition to either the start or finsh position |
animate(completionPosition) |
} |
func animate(_ toPosition: UIViewAnimatingPosition) { |
// Create a property animator to animate each image's frame change |
let itemFrameAnimator = AssetTransitionDriver.propertyAnimator(initialVelocity: timingCurveVelocity()) |
itemFrameAnimator.addAnimations { |
for item in self.items { |
item.imageView?.frame = (toPosition == .end ? item.targetFrame : item.initialFrame)! |
} |
} |
// Start the property animator and keep track of it |
itemFrameAnimator.startAnimation() |
self.itemFrameAnimator = itemFrameAnimator |
// Reverse the transition animator if we are returning to the start position |
transitionAnimator.isReversed = (toPosition == .start) |
// Start or continue the transition animator (if it was previously paused) |
if transitionAnimator.state == .inactive { |
transitionAnimator.startAnimation() |
} else { |
// Calculate the duration factor for which to continue the animation. |
// This has been chosen to match the duration of the property animator created above |
let durationFactor = CGFloat(itemFrameAnimator.duration / transitionAnimator.duration) |
transitionAnimator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor) |
} |
} |
func pauseAnimation() { |
// Stop (without finishing) the property animator used for transition item frame changes |
itemFrameAnimator?.stopAnimation(true) |
// Pause the transition animator |
transitionAnimator.pauseAnimation() |
// Inform the transition context that we have paused |
transitionContext.pauseInteractiveTransition() |
} |
// MARK: Interesting Property Animator Stuff |
class func animationDuration() -> TimeInterval { |
return AssetTransitionDriver.propertyAnimator().duration |
} |
class func propertyAnimator(initialVelocity: CGVector = .zero) -> UIViewPropertyAnimator { |
let timingParameters = UISpringTimingParameters(mass: 4.5, stiffness: 1300, damping: 95, initialVelocity: initialVelocity) |
return UIViewPropertyAnimator(duration: assetTransitionDuration, timingParameters:timingParameters) |
} |
} |
Copyright © 2016 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2016-10-27