Created
May 25, 2016 14:45
-
-
Save keithnorm/8f3b4d3e2673c1c5e5eefdcd0abd3e9b to your computer and use it in GitHub Desktop.
enable animating a scroll view's contentOffset property with custom easing
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
// | |
// UICollectionView+ScrollViewAnimateable.swift | |
// persimmon | |
// | |
// Created by Keith Norman on 5/24/16. | |
// Copyright © 2016 Good Eggs. All rights reserved. | |
// | |
/// Based on https://github.com/plancalculus/MOScrollView. This allows animating contentOffset with a timing function. It's not possible to animate contentOffset via a CAAnimation. It is possible to animate bounds but then scrollview delegate methods don't get called which may be an issue if the animation exposes new cells in a table view or collection view and you expect those cells to render when the scroll into view. | |
import Foundation | |
import ObjectiveC | |
protocol ScrollViewAnimateable { | |
func setContentOffset(offset: CGPoint, timingFunction: CAMediaTimingFunction, duration: CFTimeInterval) | |
} | |
private var displayLinkKey: UInt8 = 0 | |
private var animationStartedKey: UInt8 = 1 | |
private var beginTimeKey: UInt8 = 2 | |
private var beginContentOffsetKey: UInt8 = 3 | |
private var deltaContentOffsetKey: UInt8 = 4 | |
private var durationKey: UInt8 = 5 | |
private var timingFunctionKey: UInt8 = 6 | |
private func CGPointScalarMult(s: CGFloat, _ p: CGPoint) -> CGPoint { | |
return CGPoint(x: s * p.x, y: s * p.y) | |
} | |
private func CGPointAdd(p: CGPoint, _ q: CGPoint) -> CGPoint { | |
return CGPoint(x: p.x + q.x, y: p.y + q.y) | |
} | |
private func CGPointMinus(p: CGPoint, _ q: CGPoint) -> CGPoint { | |
return CGPoint(x: p.x - q.x, y: p.y - q.y) | |
} | |
extension CAMediaTimingFunction { | |
public func getControlPoint(index: UInt) -> (x: CGFloat, y: CGFloat)? { | |
switch index { | |
case 0...3: | |
let controlPoint = UnsafeMutablePointer<Float>.alloc(2) | |
self.getControlPointAtIndex(Int(index), values: controlPoint) | |
let x: Float = controlPoint[0] | |
let y: Float = controlPoint[1] | |
controlPoint.dealloc(2) | |
return (CGFloat(x), CGFloat(y)) | |
default: | |
return nil | |
} | |
} | |
public var controlPoints: [CGPoint] { | |
var controlPoints = [CGPoint]() | |
for index in 0..<4 { | |
let controlPoint = UnsafeMutablePointer<Float>.alloc(2) | |
self.getControlPointAtIndex(Int(index), values: controlPoint) | |
let x: Float = controlPoint[0] | |
let y: Float = controlPoint[1] | |
controlPoint.dealloc(2) | |
controlPoints.append(CGPoint(x: CGFloat(x), y: CGFloat(y))) | |
} | |
return controlPoints | |
} | |
func valueAtTime(x: Double) -> Double { | |
let cp = self.controlPoints | |
// Look for t value that corresponds to provided x | |
let a = Double(-cp[0].x+3*cp[1].x-3*cp[2].x+cp[3].x) | |
let b = Double(3*cp[0].x-6*cp[1].x+3*cp[2].x) | |
let c = Double(-3*cp[0].x+3*cp[1].x) | |
let d = Double(cp[0].x)-x | |
let t = rootOfCubic(a, b, c, d, x) | |
// Return corresponding y value | |
let y = cubicFunctionValue(Double(-cp[0].y+3*cp[1].y-3*cp[2].y+cp[3].y), Double(3*cp[0].y-6*cp[1].y+3*cp[2].y), Double(-3*cp[0].y+3*cp[1].y), Double(cp[0].y), t) | |
return y | |
} | |
private func rootOfCubic(a: Double, _ b: Double, _ c: Double, _ d: Double, _ startPoint: Double) -> Double { | |
// We use 0 as start point as the root will be in the interval [0,1] | |
var x = startPoint | |
var lastX: Double = 1 | |
let kMaximumSteps = 10 | |
let kApproximationTolerance = 0.00000001 | |
// Approximate a root by using the Newton-Raphson method | |
var y = 0 | |
while (y <= kMaximumSteps && fabs(lastX - x) > kApproximationTolerance) { | |
lastX = x | |
x = x - (cubicFunctionValue(a, b, c, d, x) / cubicDerivativeValue(a, b, c, d, x)) | |
y += 1 | |
} | |
return x | |
} | |
private func cubicFunctionValue(a: Double, _ b: Double, _ c: Double, _ d: Double, _ x: Double) -> Double { | |
return (a*x*x*x)+(b*x*x)+(c*x)+d | |
} | |
private func cubicDerivativeValue(a: Double, _ b: Double, _ c: Double, _ d: Double, _ x: Double) -> Double { | |
/// Derivation of the cubic (a*x*x*x)+(b*x*x)+(c*x)+d | |
return (3*a*x*x)+(2*b*x)+c | |
} | |
} | |
extension UICollectionView: ScrollViewAnimateable { | |
var displayLink: CADisplayLink? { | |
get { | |
return objc_getAssociatedObject(self, &displayLinkKey) as? CADisplayLink | |
} | |
set { | |
objc_setAssociatedObject(self, &displayLinkKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) | |
} | |
} | |
var duration: CFTimeInterval? { | |
get { | |
return objc_getAssociatedObject(self, &durationKey) as? CFTimeInterval | |
} | |
set { | |
objc_setAssociatedObject(self, &durationKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) | |
} | |
} | |
var timingFunction: CAMediaTimingFunction? { | |
get { | |
return objc_getAssociatedObject(self, &timingFunctionKey) as? CAMediaTimingFunction | |
} | |
set { | |
objc_setAssociatedObject(self, &timingFunctionKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) | |
} | |
} | |
var animationStarted: CADisplayLink? { | |
get { | |
return objc_getAssociatedObject(self, &animationStartedKey) as? CADisplayLink | |
} | |
set { | |
objc_setAssociatedObject(self, &animationStartedKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) | |
} | |
} | |
var beginTime: CFTimeInterval { | |
get { | |
return (objc_getAssociatedObject(self, &beginTimeKey) as? CFTimeInterval) ?? 0.0 | |
} | |
set { | |
objc_setAssociatedObject(self, &beginTimeKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) | |
} | |
} | |
var beginContentOffset: CGPoint? { | |
get { | |
return (objc_getAssociatedObject(self, &beginContentOffsetKey) as? NSValue)?.CGPointValue() | |
} | |
set { | |
let val = NSValue(CGPoint: newValue!) | |
objc_setAssociatedObject(self, &beginContentOffsetKey, val, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) | |
} | |
} | |
var deltaContentOffset: CGPoint? { | |
get { | |
return (objc_getAssociatedObject(self, &deltaContentOffsetKey) as? NSValue)?.CGPointValue() | |
} | |
set { | |
let val = NSValue(CGPoint: newValue!) | |
objc_setAssociatedObject(self, &deltaContentOffsetKey, val, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) | |
} | |
} | |
func setContentOffset(offset: CGPoint, timingFunction: CAMediaTimingFunction, duration: CFTimeInterval) { | |
self.duration = duration | |
self.timingFunction = timingFunction | |
self.deltaContentOffset = CGPointMinus(offset, self.contentOffset) | |
if (self.displayLink == nil) { | |
self.displayLink = CADisplayLink(target: self, selector: #selector(updateContentOffset)) | |
self.displayLink!.frameInterval = 1 | |
self.displayLink?.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode) | |
} else { | |
self.displayLink!.paused = false | |
} | |
} | |
func updateContentOffset(displayLink: CADisplayLink) { | |
if (self.beginTime == 0.0) { | |
self.beginTime = displayLink.timestamp | |
self.beginContentOffset = CGPoint(x: -200, y: 0) | |
} else { | |
let deltaTime = displayLink.timestamp - self.beginTime | |
// Ratio of duration that went by | |
let progress = (CGFloat)(deltaTime / self.duration!) | |
if (progress < 1.0) { | |
// Ratio adjusted by timing function | |
let adjustedProgress = self.timingFunction!.valueAtTime(Double(progress)) | |
self.updateProgress(CGFloat(adjustedProgress)) | |
} else { | |
self.stopAnimation() | |
} | |
} | |
} | |
private func updateProgress(progress: CGFloat) { | |
let currentDeltaContentOffset = CGPointScalarMult(progress, self.deltaContentOffset!) | |
self.contentOffset = CGPointAdd(self.beginContentOffset!, currentDeltaContentOffset) | |
} | |
private func stopAnimation() { | |
self.displayLink?.paused = true | |
self.beginTime = 0.0 | |
self.contentOffset = CGPointAdd(self.beginContentOffset!, self.deltaContentOffset!) | |
self.delegate?.scrollViewDidEndScrollingAnimation?(self) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment