yerpy
yerpy

Reputation: 1446

Color animation

I've written simple animations for drawing rectangles in lines, we can treat them as a bars.

Each bar is one shape layer which has a path which animates ( size change and fill color change ).

@IBDesignable final class BarView: UIView {

    lazy var pathAnimation: CABasicAnimation = {
        let animation = CABasicAnimation(keyPath: "path")
        animation.duration = 1
        animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        animation.fillMode = kCAFillModeBoth
        animation.isRemovedOnCompletion = false
        return animation
    }()

    let red = UIColor(red: 249/255, green: 26/255, blue: 26/255, alpha: 1)
    let orange = UIColor(red: 1, green: 167/255, blue: 463/255, alpha: 1)
    let green = UIColor(red: 106/255, green: 239/255, blue: 47/255, alpha: 1)

    lazy var backgroundColorAnimation: CABasicAnimation = {
        let animation = CABasicAnimation(keyPath: "fillColor")
        animation.duration = 1
        animation.fromValue = red.cgColor
        animation.byValue = orange.cgColor
        animation.toValue = green.cgColor
        animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        animation.fillMode = kCAFillModeBoth
        animation.isRemovedOnCompletion = false
        return animation
    }()

    @IBInspectable var spaceBetweenBars: CGFloat = 10

    var numberOfBars: Int = 5
    let data: [CGFloat] = [5.5, 9.0, 9.5, 3.0, 8.0]

    override func awakeFromNib() {
        super.awakeFromNib()
        initSublayers()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        setupLayers()
    }

    func setupLayers() {
        let width = bounds.width - (spaceBetweenBars * CGFloat(numberOfBars + 1)) // There is n + 1 spaces between bars.
        let barWidth: CGFloat = width / CGFloat(numberOfBars)
        let scalePoint: CGFloat = bounds.height / 10.0 // 10.0 - 10 points is max

        guard let sublayers = layer.sublayers as? [CAShapeLayer] else { return }

        for i in 0...numberOfBars - 1 {
            let barHeight: CGFloat = scalePoint * data[i]
            let shapeLayer = CAShapeLayer()
            layer.addSublayer(shapeLayer)

            var xPos: CGFloat!
            if i == 0 {
                xPos = spaceBetweenBars
            } else if i == numberOfBars - 1 {
                xPos = bounds.width - (barWidth + spaceBetweenBars)
            } else {
                xPos = barWidth * CGFloat(i) + spaceBetweenBars * CGFloat(i) + spaceBetweenBars
            }

            let startPath = UIBezierPath(rect: CGRect(x: xPos, y: bounds.height, width: barWidth, height: 0)).cgPath
            let endPath = UIBezierPath(rect: CGRect(x: xPos, y: bounds.height, width: barWidth, height: -barHeight)).cgPath

            sublayers[i].path = startPath
            pathAnimation.toValue = endPath

            sublayers[i].removeAllAnimations()

            sublayers[i].add(pathAnimation, forKey: "path")
            sublayers[i].add(backgroundColorAnimation, forKey: "backgroundColor")
        }
    }


    func initSublayers() {
        for _ in 1...numberOfBars {
            let shapeLayer = CAShapeLayer()
            layer.addSublayer(shapeLayer)
        }
    }
}

The size ( height ) of bar depends of the data array, each sublayers has a different height. Based on this data I've crated a scale.

PathAnimation is changing height of the bars. BackgroundColorAnimation is changing the collors of the path. It starts from red one, goes through the orange and finish at green.

My goal is to connect backgroundColorAnimation with data array as well as it's connected with pathAnimation.

Ex. When in data array is going to be value 1.0 then the bar going to be animate only to the red color which is a derivated from a base red color which is declared as a global variable. If the value in the data array going to be ex. 4.5 then the color animation will stop close to the delcared orange color, the 5.0 limit going to be this orange color or color close to this. Value closer to 10 going to be green.

How could I connect these conditions with animation properties fromValue, byValue, toValue. Is it an algorithm for that ? Any ideas ?

Upvotes: 0

Views: 142

Answers (1)

rob mayoff
rob mayoff

Reputation: 385500

You have several problems.

  1. You're setting fillMode and isRemovedOnCompletion. This tells me, to be blunt, that you don't understand Core Animation. You need to watch WWDC 2011 Session 421: Core Animation Essentials.

  2. You're adding more layers every time layoutSubviews is called, but not doing anything with them.

  3. You're adding animation every time layoutSubviews runs. Do you really want to re-animate the bars when the double-height “in-call” status bar appears or disappears, or on an interface rotation? It's probably better to have a separate animateBars() method, and call it from your view controller's viewDidAppear method.

  4. You seem to think byValue means “go through this value on the way from fromValue to toValue”, but that's not what it means. byValue is ignored in your case, because you're setting fromValue and toValue. The effects of byValue are explained in Setting Interpolation Values.

  5. If you want to interpolate between colors, it's best to use a hue-based color space, but I believe Core Animation uses an RGB color space. So you should use a keyframe animation to specify intermediate colors that you calculate by interpolating in a hue-based color space.

Here's a rewrite of BarView that fixes all these problems:

@IBDesignable final class BarView: UIView {

    @IBInspectable var spaceBetweenBars: CGFloat = 10

    var data: [CGFloat] = [5.5, 9.0, 9.5, 3.0, 8.0]
    var maxDatum = CGFloat(10)

    func animateBars() {
        guard window != nil else { return }

        let bounds = self.bounds
        var flatteningTransform = CGAffineTransform.identity.translatedBy(x: 0, y: bounds.size.height).scaledBy(x: 1, y: 0.001)
        let duration: CFTimeInterval = 1
        let frames = Int((duration * 60.0).rounded(.awayFromZero))

        for (datum, barLayer) in zip(data, barLayers) {
            let t = datum / maxDatum
            if let path = barLayer.path {
                let path0 = path.copy(using: &flatteningTransform)
                let pathAnimation = CABasicAnimation(keyPath: "path")
                pathAnimation.duration = 1
                pathAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
                pathAnimation.fromValue = path0
                barLayer.add(pathAnimation, forKey: pathAnimation.keyPath)

                let colors = gradient.colors(from: 0, to: t, count: frames).map({ $0.cgColor })
                let colorAnimation = CAKeyframeAnimation(keyPath: "fillColor")
                colorAnimation.timingFunction = pathAnimation.timingFunction
                colorAnimation.duration = duration
                colorAnimation.values = colors
                barLayer.add(colorAnimation, forKey: colorAnimation.keyPath)
            }
        }
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        createOrDestroyBarLayers()

        let bounds = self.bounds
        let barSpacing = (bounds.size.width - spaceBetweenBars) / CGFloat(data.count)
        let barWidth = barSpacing - spaceBetweenBars

        for ((offset: i, element: datum), barLayer) in zip(data.enumerated(), barLayers) {
            let t = datum / maxDatum
            let barHeight = t * bounds.size.height
            barLayer.frame = bounds
            let rect = CGRect(x: spaceBetweenBars + CGFloat(i) * barSpacing, y: bounds.size.height, width: barWidth, height: -barHeight)
            barLayer.path = CGPath(rect: rect, transform: nil)
            barLayer.fillColor = gradient.color(at: t).cgColor
        }
    }

    private let gradient = Gradient(startColor: .red, endColor: .green)

    private var barLayers = [CAShapeLayer]()

    private func createOrDestroyBarLayers() {
        while barLayers.count < data.count {
            barLayers.append(CAShapeLayer())
            layer.addSublayer(barLayers.last!)
        }
        while barLayers.count > data.count {
            barLayers.removeLast().removeFromSuperlayer()
        }
    }

}

private extension UIColor {
    var hsba: [CGFloat] {
        var hue: CGFloat = 0
        var saturation: CGFloat = 0
        var brightness: CGFloat = 0
        var alpha: CGFloat = 0
        getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
        return [hue, saturation, brightness, alpha]
    }
}

private struct Gradient {
    init(startColor: UIColor, endColor: UIColor) {
        self.startColor = startColor
        self.startHsba = startColor.hsba
        self.endColor = endColor
        self.endHsba = endColor.hsba
    }

    let startColor: UIColor
    let endColor: UIColor

    let startHsba: [CGFloat]
    let endHsba: [CGFloat]

    func color(at t: CGFloat) -> UIColor {
        let out = zip(startHsba, endHsba).map { $0 * (1.0 - t) + $1 * t }
        return UIColor(hue: out[0], saturation: out[1], brightness: out[2], alpha: out[3])
    }

    func colors(from t0: CGFloat, to t1: CGFloat, count: Int) -> [UIColor] {
        var colors = [UIColor]()
        colors.reserveCapacity(count)
        for i in 0 ..< count {
            let s = CGFloat(i) / CGFloat(count - 1)
            let t = t0 * (1 - s) + t1 * s
            colors.append(color(at: t))
        }
        return colors
    }

}

Result:

demo

Upvotes: 1

Related Questions