Sweeper
Sweeper

Reputation: 271410

How to make the background color property of a custom view animatable?

I want to animate my custom view's background color.

My drawing code is very simple. The draw(in:) method is overridden like this:

@IBDesignable
class SquareView: UIView {
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        let strokeWidth = self.width / 8
        let path = UIBezierPath()
        path.move(to: CGPoint(x: self.width - strokeWidth / 2, y: 0))
        path.addLine(to: CGPoint(x: self.width - strokeWidth / 2, y: self.height - strokeWidth / 2))
        path.addLine(to: CGPoint(x: 0, y: self.height - strokeWidth / 2))
        self.backgroundColor?.darker().setStroke()
        path.lineWidth = strokeWidth
        path.stroke()
    }
}

The darker method just returns a darker version of the color it is called on.

When I set the background to blue, it draws something like this:

enter image description here

I want to animate its background color so that it gradually changes to a red color. This would be the end result:

enter image description here

I first tried:

UIView.animate(withDuration: 1) { 
    self.square.backgroundColor = .red
}

It just changes the color to red in an instant, without animation.

Then I researched and saw that I need to use CALayers. Therefore, I tried drawing the thing in layers:

@IBDesignable
class SquareViewLayers: UIView {
    dynamic var squareColor: UIColor = .blue {
        didSet {
            setNeedsDisplay()
        }
    }

    func setupView() {
        layer.backgroundColor = squareColor.cgColor
        let sublayer = CAShapeLayer()
        let path = UIBezierPath()
        let strokeWidth = self.width / 8
        path.move(to: CGPoint(x: self.width - strokeWidth / 2, y: 0))
        path.addLine(to: CGPoint(x: self.width - strokeWidth / 2, y: self.height - strokeWidth / 2))
        path.addLine(to: CGPoint(x: 0, y: self.height - strokeWidth / 2))
        self.tintColor.darker().setStroke()
        path.lineWidth = strokeWidth
        sublayer.path = path.cgPath
        sublayer.strokeColor = squareColor.darker().cgColor
        sublayer.lineWidth = strokeWidth
        sublayer.backgroundColor = UIColor.clear.cgColor
        layer.addSublayer(sublayer)

    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }
}

But the result is horrible

enter image description here

I used this code to try to animate this:

let anim = CABasicAnimation(keyPath: "squareColor")
anim.fromValue = UIColor.blue
anim.toValue = UIColor.red
anim.duration = 1
square.layer.add(anim, forKey: nil)

Nothing happens though.

I am very confused. What is the correct way to do this?

EDIT:

The darker method is from a cocoa pod called SwiftyColor. This is how it is implemented:

// in an extension of UIColor
public func darker(amount: CGFloat = 0.25) -> UIColor {
    return hueColor(withBrightnessAmount: 1 - amount)
}

private func hueColor(withBrightnessAmount amount: CGFloat) -> UIColor {
    var hue: CGFloat = 0
    var saturation: CGFloat = 0
    var brightness: CGFloat = 0
    var alpha: CGFloat = 0

    if getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
        return UIColor(hue: hue, saturation: saturation,
                           brightness: brightness * amount,
                           alpha: alpha)
    }
    return self
}

Upvotes: 1

Views: 1430

Answers (6)

Chetan Rajauria
Chetan Rajauria

Reputation: 209

Objective-C

view.backgroundColor = [UIColor whiteColor].CGColor;

[UIView animateWithDuration:2.0 animations:^{
    view.layer.backgroundColor = [UIColor greenColor].CGColor;
} completion:NULL];

Swift

view.backgroundColor = UIColor.white.cgColor
UIView.animate(withDuration: 2.0) { 
    view.backgroundColor = UIColor.green.cgColor
}

Upvotes: 1

Sandeep
Sandeep

Reputation: 21144

We have in the code below, two different CAShapeLayer, foreground and background. Background is a rectangle path drawn to the extent where inverted L actually starts. And L is the path created with simple UIBezierPath geometry. I animate the change to fillColor for each layer after color is set.

class SquareViewLayers: UIView, CAAnimationDelegate {

    var color: UIColor = UIColor.blue {
        didSet {
            animate(layer: backgroundLayer,
                    from: oldValue,
                    to: color)
            animate(layer: foregroundLayer,
                    from: oldValue.darker(),
                    to: color.darker())
        }
    }

    let backgroundLayer = CAShapeLayer()
    let foregroundLayer = CAShapeLayer()

    func setupView() {

        foregroundLayer.frame = frame
        layer.addSublayer(foregroundLayer)

        backgroundLayer.frame = frame
        layer.addSublayer(backgroundLayer)


        let strokeWidth = width / 8

        let backgroundPathRect = CGRect(origin: .zero, size: CGSize(width: width - strokeWidth,
                                                                    height: height - strokeWidth))
        let backgroundPath = UIBezierPath(rect: backgroundPathRect)
        backgroundLayer.fillColor = color.cgColor
        backgroundLayer.path = backgroundPath.cgPath

        let foregroundPath = UIBezierPath()
        foregroundPath.move(to: CGPoint(x: backgroundPathRect.width,
                                        y: 0))
        foregroundPath.addLine(to: CGPoint(x: width,
                                           y: 0))
        foregroundPath.addLine(to: CGPoint(x: width, y: height))
        foregroundPath.addLine(to: CGPoint(x: 0,
                                           y: height))
        foregroundPath.addLine(to: CGPoint(x: 0,
                                           y: backgroundPathRect.height))
        foregroundPath.addLine(to: CGPoint(x: backgroundPathRect.width,
                                           y: backgroundPathRect.height))
        foregroundPath.addLine(to: CGPoint(x: backgroundPathRect.width,
                                           y: 0))
        foregroundPath.close()

        foregroundLayer.path = foregroundPath.cgPath
        foregroundLayer.fillColor = color.darker().cgColor
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }

    func animate(layer: CAShapeLayer,
                 from: UIColor,
                 to: UIColor) {
        let fillColorAnimation = CABasicAnimation(keyPath: "fillColor")
        fillColorAnimation.fromValue = from.cgColor
        layer.fillColor = to.cgColor
        fillColorAnimation.duration = 1.0
        layer.add(fillColorAnimation,
                  forKey: nil)
    }
}

And here is the result,

enter image description here

Upvotes: 1

Luffy
Luffy

Reputation: 401

Could you stroke the L-shape with a semi-transparent black instead?

Then you don't have to mess around with redrawing and color calculations, and you can use the basic UIView.animate()

Below the "LShadow" is added onto the SquareView, so you can set the SquareView background color as per usual with UIView.animate()


ViewController.swift

import UIKit

class ViewController: UIViewController {
    var squareView: SquareView!

    override func viewDidLoad() {
        super.viewDidLoad()

        squareView = SquareView.init(frame: CGRect(x:100, y:100, width:200, height:200))
        self.view.addSubview(squareView)

        squareView.backgroundColor = UIColor.blue
        UIView.animate(withDuration: 2.0, delay: 1.0, animations: {
            self.squareView.backgroundColor = UIColor.red
        }, completion:nil)
    }
}

SquareView.swift

import UIKit

class SquareView: UIView {
    func setupView() {
        let shadow = LShadow.init(frame: self.bounds)
        self.addSubview(shadow)
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }
}


class LShadow: UIView {
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = UIColor.clear
    }
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.backgroundColor = UIColor.clear
    }
    override func draw(_ rect: CGRect) {
        let strokeWidth = self.frame.size.width / 8
        let path = UIBezierPath()
        path.move(to: CGPoint(x: self.frame.size.width - strokeWidth / 2, y: 0))
        path.addLine(to: CGPoint(x: self.frame.size.width - strokeWidth / 2, y: self.frame.size.height - strokeWidth / 2))
        path.addLine(to: CGPoint(x: 0, y: self.frame.size.height - strokeWidth / 2))
        UIColor.init(white: 0, alpha: 0.5).setStroke()
        path.lineWidth = strokeWidth
        path.stroke()
    }
}

Upvotes: 1

deoKasuhal
deoKasuhal

Reputation: 2897

I had an implementation of changing the background color of view with animation and did some tweak in the source code to add inverted L shape using CAShapeLayer. I achieved the required animation. here is the sample of the view.

class SquareView: UIView, CAAnimationDelegate {

    fileprivate func setup() {
        self.layer.addSublayer(self.sublayer)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.setup
    }

    let sublayer = CAShapeLayer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.setup()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        let strokeWidth = self.bounds.width / 8
        var frame = self.bounds
        frame.size.width -= strokeWidth
        frame.size.height -= strokeWidth
        self.sublayer.path = UIBezierPath(rect: frame).cgPath
    }



    var color : UIColor = UIColor.clear {
        willSet {
            self.sublayer.fillColor = newValue.cgColor
            self.backgroundColor = newValue.darker()
        }
    }



    func animation(color: UIColor, duration: CFTimeInterval = 1.0) {
        let animation = CABasicAnimation()
        animation.keyPath = "backgroundColor"
        animation.fillMode = kCAFillModeForwards
        animation.duration = duration
        animation.repeatCount = 1
        animation.autoreverses = false
        animation.fromValue = self.backgroundColor?.cgColor
        animation.toValue = color.darker().cgColor
        animation.delegate = self;

        let shapeAnimation = CABasicAnimation()
        shapeAnimation.keyPath = "fillColor"
        shapeAnimation.fillMode = kCAFillModeForwards
        shapeAnimation.duration = duration
        shapeAnimation.repeatCount = 1
        shapeAnimation.autoreverses = false
        shapeAnimation.fromValue = self.sublayer.fillColor
        shapeAnimation.toValue = color.cgColor
        shapeAnimation.delegate = self;


        self.layer.add(animation, forKey: "kAnimation")
        self.sublayer.add(shapeAnimation, forKey: "kAnimation")
        self.color = color
    }



    public func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        self.layer.removeAnimation(forKey: "kAnimation")
        self.sublayer.removeAnimation(forKey: "kAnimation")
    }
}

You can use the method animation(color: duration:) to change the color with animation on required animation duration.

Example

let view = SquareView(frame: CGRect(x: 10, y: 100, width: 200, height: 300))
view.color = UIColor.blue
view.animation(color: UIColor.red) 

Here is the result

enter image description here

Upvotes: 1

Ayman Ibrahim
Ayman Ibrahim

Reputation: 1399

Using CAKeyframeAnimation you need to animate backgroundColor property:

class SquareView: UIView
{
    let sublayer = CAShapeLayer()

    required init?(coder aDecoder: NSCoder)
    {
        super.init(coder: aDecoder)
        let frame = self.frame
        let strokeWidth = self.frame.width / 8
        sublayer.frame = self.bounds

        let path = UIBezierPath()
        path.move(to: CGPoint(x: frame.width  - strokeWidth / 2 , y: 0))
        path.addLine(to: CGPoint(x: frame.width - strokeWidth / 2, y: frame.height - strokeWidth / 2))
        path.addLine(to: CGPoint(x: 0, y: frame.height - strokeWidth / 2))
        path.addLine(to: CGPoint(x: 0 , y: 0))
        path.lineWidth = strokeWidth
        sublayer.path = path.cgPath
        sublayer.fillColor = UIColor.blue.cgColor
        sublayer.lineWidth = strokeWidth
        sublayer.backgroundColor = UIColor.blue.cgColor
        layer.addSublayer(sublayer)
    }

    //Action
    func animateIt()
    {
        let animateToColor = UIColor(cgColor: sublayer.backgroundColor!).darker().cgColor
        animateColorChange(mLayer: self.sublayer, colors: [sublayer.backgroundColor!, animateToColor], duration: 1)
    }

    func animateColorChange(mLayer:CALayer ,colors:[CGColor], duration:CFTimeInterval)
    {
        let animation = CAKeyframeAnimation(keyPath: "backgroundColor")
        CATransaction.begin()
        animation.keyTimes = [0.2, 0.5, 0.7, 1]
        animation.values = colors
        animation.calculationMode = kCAAnimationPaced
        animation.fillMode = kCAFillModeForwards
        animation.isRemovedOnCompletion = false
        animation.repeatCount = 0
        animation.duration = duration
        mLayer.add(animation, forKey: nil)
        CATransaction.commit()
    }
}

enter image description here

Note: I have edited the answer so that the shape will always fit the parent view added from storyboard

Upvotes: 1

caseynolan
caseynolan

Reputation: 1246

You're on the right track with CABasicAnimation.

  1. The keyPath: in let anim = CABasicAnimation(keyPath: "squareColor") should be backgroundColor.

  2. anim.fromValue and anim.toValue require CGColor values (because you're operating on the CALayer, which uses CGColor). You can use UIColor.blue.cgcolor here.

Pretty sure this animation won't persist the color change for you though, so you might need to change that property manually.

Upvotes: 2

Related Questions