-
-
Save trevphil/859a139ed6549f1022330b2eb1ceff75 to your computer and use it in GitHub Desktop.
BlockViewController
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
enum AnimationDirection: Int { | |
case up, down, left, right, undefined | |
} | |
protocol BlockViewControllerDelegate: class { | |
func animationBoundsConfigurationNeeded(using blockViewController: BlockViewController) | |
func slideAnimationWillBegin(blockViewController: BlockViewController) | |
func slideAnimationDidFinish(blockViewController: BlockViewController?, stateChanged: Bool) | |
} | |
class BlockViewController: UIViewController { | |
// MARK: - Properties | |
weak var delegate: BlockViewControllerDelegate? | |
var startingXOffset: CGFloat = 0 | |
var endingXOffset: CGFloat = 0 | |
var startingYOffset: CGFloat = 0 | |
var endingYOffset: CGFloat = 0 | |
var topConstraint = NSLayoutConstraint() | |
var leadingConstraint = NSLayoutConstraint() | |
var animationDirection: AnimationDirection = .undefined | |
var isVerticalAnimation: Bool { | |
return animationDirection == .up || animationDirection == .down | |
} | |
var transitionAnimator: UIViewPropertyAnimator? | |
var animationProgress: CGFloat = 0 | |
// MARK: - View Controller Lifecycle | |
init(delegate: BlockViewControllerDelegate) { | |
self.delegate = delegate | |
super.init(nibName: nil, bundle: nil) | |
} | |
@available(*, unavailable) | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
// MARK: - Setup | |
func setup() { | |
if let superview = view.superview { | |
topConstraint = view.topAnchor.constraint(equalTo: superview.topAnchor, constant: startingYOffset) | |
leadingConstraint = view.leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: startingXOffset) | |
topConstraint.isActive = true | |
leadingConstraint.isActive = true | |
let recognizer = InstantPanGestureRecognizer() | |
recognizer.addTarget(self, action: #selector(viewPanned(recognizer:))) | |
view.addGestureRecognizer(recognizer) | |
} | |
} | |
// MARK: - Animation Helper Functions | |
private func reverseAnimation() { | |
guard let animator = transitionAnimator else { return } | |
animator.isReversed = !animator.isReversed | |
} | |
private func nullifyAnimations() { | |
transitionAnimator = nil | |
animationDirection = .undefined | |
} | |
private func swapXConstraints() { | |
let tmp = endingXOffset | |
endingXOffset = startingXOffset | |
startingXOffset = tmp | |
} | |
private func swapYConstraints() { | |
let tmp = endingYOffset | |
endingYOffset = startingYOffset | |
startingYOffset = tmp | |
} | |
private func oppositeOfInitialAnimation(velocity: CGPoint) -> Bool { | |
switch animationDirection { | |
case .up: | |
return velocity.y > 0 | |
case .down: | |
return velocity.y < 0 | |
case .left: | |
return velocity.x > 0 | |
case .right: | |
return velocity.x < 0 | |
case .undefined: | |
return false | |
} | |
} | |
private func directionFromVelocity(_ velocity: CGPoint) -> AnimationDirection { | |
guard velocity != .zero else { return .undefined } | |
let isVertical = abs(velocity.y) > abs(velocity.x) | |
var derivedDirection: AnimationDirection = .undefined | |
if isVertical { | |
derivedDirection = velocity.y < 0 ? .up : .down | |
} else { | |
derivedDirection = velocity.x < 0 ? .left : .right | |
} | |
return derivedDirection | |
} | |
// MARK: - Public Functions | |
func autoSlide(_ direction: AnimationDirection, amount: CGFloat) { | |
let offset = abs(amount) * (direction == .up || direction == .left ? -1 : 1) | |
animationDirection = direction | |
if isVerticalAnimation { | |
endingYOffset = startingYOffset + offset | |
} else { | |
endingXOffset = startingXOffset + offset | |
} | |
beginAnimation(programaticallyInitiated: true) | |
} | |
// MARK: - Core Animation Logic | |
private func makeAnimation(duration: TimeInterval = Constants.slideAnimationDuration) { | |
guard transitionAnimator == nil, animationDirection != .undefined else { return } | |
transitionAnimator = UIViewPropertyAnimator(duration: duration, dampingRatio: Constants.dampingRatio) { [weak self] in | |
guard let strongSelf = self else { return } | |
if strongSelf.isVerticalAnimation { | |
strongSelf.topConstraint.constant = strongSelf.endingYOffset | |
} else { | |
strongSelf.leadingConstraint.constant = strongSelf.endingXOffset | |
} | |
strongSelf.view.superview?.layoutIfNeeded() | |
} | |
configureAnimationCompletionBlock() | |
delegate?.slideAnimationWillBegin(blockViewController: self) | |
transitionAnimator?.startAnimation() | |
} | |
private func configureAnimationCompletionBlock() { | |
transitionAnimator?.addCompletion { [weak self] position in | |
guard let strongSelf = self, strongSelf.animationDirection != .undefined else { | |
self?.nullifyAnimations() | |
return | |
} | |
switch position { | |
case .start: | |
if strongSelf.transitionAnimator?.isReversed == true { | |
strongSelf.leadingConstraint.constant = strongSelf.startingXOffset | |
strongSelf.topConstraint.constant = strongSelf.startingYOffset | |
} | |
case .end: | |
if strongSelf.isVerticalAnimation { | |
strongSelf.swapYConstraints() | |
} else { | |
strongSelf.swapXConstraints() | |
} | |
case .current: | |
break | |
} | |
strongSelf.view.superview?.layoutIfNeeded() | |
strongSelf.nullifyAnimations() | |
let startingPoint = CGPoint(x: strongSelf.startingXOffset, y: strongSelf.startingYOffset) | |
let currentPoint = strongSelf.view.frame.origin | |
let dist = sqrt(pow(startingPoint.x - currentPoint.x, 2) + pow(startingPoint.y - currentPoint.y, 2)) | |
let stateChanged = position == .end && dist < strongSelf.view.frame.height / 4.0 | |
strongSelf.delegate?.slideAnimationDidFinish(blockViewController: strongSelf, stateChanged: stateChanged) | |
} | |
} | |
private func beginAnimation(programaticallyInitiated: Bool = false) { | |
guard delegate?.noOtherAnimationsRunning == true else { return } | |
// Delegate should set the x-y bounds for this animation if not started programatically | |
if !programaticallyInitiated { | |
delegate?.animationBoundsConfigurationNeeded(using: self) | |
} | |
// Start the animations | |
makeAnimation() | |
// Pause all animations if not started programmatically, since the next event may be a pan changed | |
if !programaticallyInitiated { | |
transitionAnimator?.pauseAnimation() | |
} else { | |
transitionAnimator?.continueAnimation(withTimingParameters: nil, durationFactor: 0) | |
} | |
if let animator = transitionAnimator { | |
animationProgress = animator.fractionComplete | |
} | |
} | |
@objc | |
func viewPanned(recognizer: UIPanGestureRecognizer) { | |
switch recognizer.state { | |
case .began: | |
animationProgress = transitionAnimator?.fractionComplete ?? 0 | |
case .changed: | |
didChangePan(recognizer: recognizer) | |
case .ended: | |
didEndPan(recognizer: recognizer) | |
default: | |
break | |
} | |
} | |
private func didChangePan(recognizer: UIPanGestureRecognizer) { | |
guard transitionAnimator != nil else { | |
animationDirection = directionFromVelocity(recognizer.velocity(in: view)) | |
beginAnimation() | |
return | |
} | |
let translation = recognizer.translation(in: view) | |
var fraction: CGFloat = 0 | |
switch animationDirection { | |
case .up: | |
fraction = max(0, -translation.y / abs(startingYOffset - endingYOffset)) | |
case .down: | |
fraction = max(0, translation.y / abs(startingYOffset - endingYOffset)) | |
case .left: | |
fraction = max(0, -translation.x / abs(startingXOffset - endingXOffset)) | |
case .right: | |
fraction = max(0, translation.x / abs(startingXOffset - endingXOffset)) | |
case .undefined: | |
break | |
} | |
if transitionAnimator?.isReversed == true { fraction *= -1 } | |
transitionAnimator?.fractionComplete = fraction + animationProgress | |
} | |
private func didEndPan(recognizer: UIPanGestureRecognizer) { | |
let velocity = recognizer.velocity(in: view) | |
// if there is no motion, continue all animations and exit early | |
if animationDirection == .undefined || | |
(isVerticalAnimation && velocity.y == 0) || | |
(!isVerticalAnimation && velocity.x == 0) { | |
transitionAnimator?.continueAnimation(withTimingParameters: nil, durationFactor: 0) | |
return | |
} | |
let isOpposite = oppositeOfInitialAnimation(velocity: velocity) | |
if let animator = transitionAnimator { | |
if isOpposite && !animator.isReversed { | |
reverseAnimation() | |
} else if !isOpposite && animator.isReversed { | |
reverseAnimation() | |
} | |
} | |
transitionAnimator?.continueAnimation(withTimingParameters: nil, durationFactor: 0) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment