Skip to content

Instantly share code, notes, and snippets.

@trevphil
Created August 25, 2018 17:11
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save trevphil/859a139ed6549f1022330b2eb1ceff75 to your computer and use it in GitHub Desktop.
Save trevphil/859a139ed6549f1022330b2eb1ceff75 to your computer and use it in GitHub Desktop.
BlockViewController
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