iOS icon jiggle algorithm

Viewed 10561

I am writing an iPad app that presents user documents similar to the way Pages presents them (as large icons of the actual document). I also want to mimic the jiggling behavior when the user taps the edit button. This is the same jiggle pattern that icons make on the home screen when you tap and hold on them on both the iPhone and iPad.

I've searched the Internet and have found a few algorithms but they just cause the view to rock back and forth which is not at all like the Apple jiggle. It seems there is some randomness in there as each icon jiggles a little differently.

Does anyone have or know of some code that can re-create the same jiggle pattern (or something very close to it)? Thanks!!!

11 Answers

Paul Popiel gave an excellent answer to this above and I am forever indebted to him for it. There is one small problem I found with his code and that's that it doesn't work well if that routine is called multiple times - the layer animations appear to sometimes get lost or deactivated.

Why call it more than once? I'm implementing it via a UICollectionView, and as the cells are dequeued or moved, I need to reestablish the wiggle. With Paul's original code, my cells would often stop wiggling if they scrolled off screen despite my trying to reestablish the wiggle within the dequeue and the willDisplay callback. However, by giving the two animations named keys, it always works reliably even if called twice on a cell.

This is almost all Paul's code with the above small fix, plus I've created it as an extension to UIView and added a Swift 4 compatible stopWiggle.

private func degreesToRadians(_ x: CGFloat) -> CGFloat {
    return .pi * x / 180.0
}

extension UIView {
    func startWiggle() {
        let duration: Double = 0.25
        let displacement: CGFloat = 1.0
        let degreesRotation: CGFloat = 2.0
        let negativeDisplacement = -1.0 * displacement
        let position = CAKeyframeAnimation.init(keyPath: "position")
        position.beginTime = 0.8
        position.duration = duration
        position.values = [
            NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)),
            NSValue(cgPoint: CGPoint(x: 0, y: 0)),
            NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)),
            NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)),
            NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement))
        ]
        position.calculationMode = "linear"
        position.isRemovedOnCompletion = false
        position.repeatCount = Float.greatestFiniteMagnitude
        position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100))
        position.isAdditive = true

        let transform = CAKeyframeAnimation.init(keyPath: "transform")
        transform.beginTime = 2.6
        transform.duration = duration
        transform.valueFunction = CAValueFunction(name: kCAValueFunctionRotateZ)
        transform.values = [
            degreesToRadians(-1.0 * degreesRotation),
            degreesToRadians(degreesRotation),
            degreesToRadians(-1.0 * degreesRotation)
        ]
        transform.calculationMode = "linear"
        transform.isRemovedOnCompletion = false
        transform.repeatCount = Float.greatestFiniteMagnitude
        transform.isAdditive = true
        transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100))

        self.layer.add(position, forKey: "bounce")
        self.layer.add(transform, forKey: "wiggle")
    }

    func stopWiggle() {
        self.layer.removeAllAnimations()
        self.transform = .identity
    }
}

In case it saves anyone else time implementing this in a UICollectionView, you'll need a few other places to make sure the wiggle stays during moves and scrolls. First, a routine that begins wiggling all the cells that's called at the outset:

func wiggleAllVisibleCells() {
    if let visible = collectionView?.indexPathsForVisibleItems {
        for ip in visible {
            if let cell = collectionView!.cellForItem(at: ip) {
                cell.startWiggle()
            }
        }
    }
}

And as new cells are displayed (from a move or scroll), I reestablish the wiggle:

override func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
    // Make sure cells are all still wiggling
    if isReordering {
        cell.startWiggle()
    }
}

Here is the Swift 4.2 version of @mientus' code (which is itself an update of Paul Popiel's version), as an extension of CALayer:

extension CALayer {

    private enum WigglingAnimationKey: String {
        case position = "wiggling_position_animation"
        case transform = "wiggling_transform_animation"
    }

    func startWiggling() {
        let duration = 0.25
        let displacement = 1.0
        let negativeDisplacement = displacement * -1
        let rotationAngle = Measurement(value: 2, unit: UnitAngle.degrees)

        // Position animation
        let positionAnimation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.position))
        positionAnimation.beginTime = 0.8
        positionAnimation.duration = duration
        positionAnimation.values = [
            NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)),
            NSValue(cgPoint: CGPoint.zero),
            NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)),
            NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement))
        ]
        positionAnimation.calculationMode = .linear
        positionAnimation.isRemovedOnCompletion = false
        positionAnimation.repeatCount = .greatestFiniteMagnitude
        positionAnimation.beginTime = CFTimeInterval(Float(Int.random(in: 0...25)) / 100)
        positionAnimation.isAdditive = true

        // Rotation animation
        let transformAnimation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.transform))
        transformAnimation.beginTime = 2.6
        transformAnimation.duration = duration
        transformAnimation.valueFunction = CAValueFunction(name: .rotateZ)
        transformAnimation.values = [
            CGFloat(rotationAngle.converted(to: .radians).value * -1),
            CGFloat(rotationAngle.converted(to: .radians).value),
            CGFloat(rotationAngle.converted(to: .radians).value * -1)
        ]
        transformAnimation.calculationMode = .linear
        transformAnimation.isRemovedOnCompletion = false
        transformAnimation.repeatCount = .greatestFiniteMagnitude
        transformAnimation.isAdditive = true
        transformAnimation.beginTime = CFTimeInterval(Float(Int.random(in: 0...25)) / 100)

        self.add(positionAnimation, forKey: WigglingAnimationKey.position.rawValue)
        self.add(transformAnimation, forKey: WigglingAnimationKey.transform.rawValue)
    }

    func stopWiggling() {
        self.removeAnimation(forKey: WigglingAnimationKey.position.rawValue)
        self.removeAnimation(forKey: WigglingAnimationKey.transform.rawValue)
    }
}

Usage (where anyLayer is a CALayer):

// Start animating.
anyLayer.startWiggling()
// Stop animating.
anyLayer.stopWiggling()

In case anyone needs the same code in Swift

class Animation {

    static func wiggle(_ btn: UIButton) {
        btn.startWiggling()
    }
} 

extension UIView {

func startWiggling() {

    let count = 5
    let kAnimationRotateDeg = 1.0

    let leftDegrees = (kAnimationRotateDeg * ((count%2 > 0) ? +5 : -5)).convertToDegrees()
    let leftWobble = CGAffineTransform(rotationAngle: leftDegrees)

    let rightDegrees = (kAnimationRotateDeg * ((count%2 > 0) ? -10 : +10)).convertToDegrees()
    let rightWobble = CGAffineTransform(rotationAngle: rightDegrees)

    let moveTransform = rightWobble.translatedBy(x: -2.0, y: 2.0)
    let concatTransform = rightWobble.concatenating(moveTransform)

    self.transform = leftWobble

    UIView.animate(withDuration: 0.1,
                   delay: 0.1,
                   options: [.allowUserInteraction, .repeat, .autoreverse],
                   animations: {
                    UIView.setAnimationRepeatCount(3)
                    self.transform = concatTransform
    }, completion: { success in
        self.layer.removeAllAnimations()
        self.transform = .identity
    })
}
}

Just Call

    Animation.wiggle(viewToBeAnimated)

It is always best to write a wrapper over the functions you are calling so that even if you have to change the function arguments or may be the name of the function, it does not take you to rewrite it everywhere in the code.

Related