Swift UIBezierPath starting offset from where it should - ios

I have a Button that hides when pressed and instead an animation is shown that fills from left to right, indicating a wait time.
I have the following class which handles the animation:
//MARK: - Class: LinearProgressBarButtonView
class LinearProgressBarButtonView: UIView {
private var myWidth: CGFloat!
private var myHeight: CGFloat!
init(frame: CGRect, width: CGFloat, height: CGFloat) {
super.init(frame: frame)
myWidth = width
myHeight = height
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
private var lineLayer = CAShapeLayer()
private var progressLayer = CAShapeLayer()
func createLinePath() {
// created linePath for lineLayer and progressLayer
let rect = CGRect(x: 0, y: 0, width: myWidth, height: myHeight)
let linePath = UIBezierPath()
linePath.move(to: CGPoint(x: 0.0, y: rect.height/2))
linePath.addLine(to: CGPoint(x: myWidth, y: rect.height/2))
// lineLayer path defined to circularPath
lineLayer.path = linePath.cgPath
// ui edits
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.lineCap = .square
lineLayer.lineWidth = myHeight
lineLayer.strokeEnd = 1.0
lineLayer.strokeColor = colors.Blue.cgColor
// added circleLayer to layer
layer.addSublayer(lineLayer)
// progressLayer path defined to circularPath
progressLayer.path = linePath.cgPath
// ui edits
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.lineCap = .square
progressLayer.lineWidth = myHeight
progressLayer.strokeEnd = 0
progressLayer.strokeColor = colors.red.cgColor
// added progressLayer to layer
layer.addSublayer(progressLayer)
}
func progressAnimation(duration: TimeInterval) {
// created circularProgressAnimation with keyPath
let linearProgressAnimation = CABasicAnimation(keyPath: "strokeEnd")
// set the end time
linearProgressAnimation.duration = duration
linearProgressAnimation.toValue = 1.0
linearProgressAnimation.fillMode = .forwards
linearProgressAnimation.isRemovedOnCompletion = true
progressLayer.add(linearProgressAnimation, forKey: "progressAnim")
}
func endAnimation(){
progressLayer.removeAllAnimations()
}
}
I have set all necessary constraints, which are all correct. Using
linearProgressBarButtonView = LinearProgressBarButtonView(frame: .zero, width: bookButton.frame.width, height: bookButton.frame.height) linearProgressBarButtonView.createLinePath()
I can create the view and later add it as a subview.
I can now use linearProgressBarButtonView.progressAnimation(duration: linearViewDuration) to start the animation, which works exactly as it should. However, the animation does not seem to start at x = 0, but further along the way (somewhere at around 15%). Here is a screenshot of the first second of the animation, which is supposed to last 60 seconds:
I can't seem to figure out why. As far as I understand, it should start from x = 0. And the width should be the exact same width as the view has, which I pass when generating the animated view. Why is it starting with an offset then?

The main problem is:
lineLayer.lineCap = .square
// and
progressLayer.lineCap = .square
Those need to be .butt
When set to .square one-half the line-width will be "added" on each end. Here, the line path goes from 0,20 to 260,20, with a .lineWidth = 40:
You can easily see what's going on by setting layer.borderWidth = 1 on your view ... it will look similar to this:
So, that change should fix your issue.
However, I'd suggest -- instead of saving width/height and having to call createLinePath(), move that code into layoutSubviews(). That will always keep your line path correct, even if you change the frame at a later point:
class LinearProgressBarButtonView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
layer.addSublayer(lineLayer)
layer.addSublayer(progressLayer)
}
private var lineLayer = CAShapeLayer()
private var progressLayer = CAShapeLayer()
override func layoutSubviews() {
super.layoutSubviews()
let linePath = UIBezierPath()
linePath.move(to: CGPoint(x: bounds.minX, y: bounds.midY))
linePath.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY))
lineLayer.path = linePath.cgPath
// ui edits
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.lineCap = .butt
lineLayer.lineWidth = bounds.height
lineLayer.strokeEnd = 1.0
lineLayer.strokeColor = UIColor.systemBlue.cgColor
progressLayer.path = linePath.cgPath
// ui edits
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.lineCap = .butt
progressLayer.lineWidth = bounds.height
progressLayer.strokeEnd = 0.0
progressLayer.strokeColor = UIColor.systemRed.cgColor
}
func progressAnimation(duration: TimeInterval) {
// created ProgressAnimation with keyPath
let linearProgressAnimation = CABasicAnimation(keyPath: "strokeEnd")
// set the end time
linearProgressAnimation.duration = duration
linearProgressAnimation.fromValue = 0.0
linearProgressAnimation.toValue = 1.0
linearProgressAnimation.fillMode = .forwards
linearProgressAnimation.isRemovedOnCompletion = true
progressLayer.add(linearProgressAnimation, forKey: "progressAnim")
}
func endAnimation(){
progressLayer.removeAllAnimations()
}
}

Related

Add Colour Gradient to Circular Progress Bar iOS

I am having difficulty adding a colour gradient to my circular progress bar
My setup:
Storyboard: ViewController with an empty view designated as the Class CircularProgressBar
Custom class for the CircularProgressBar
Steps Taken:
I am able to load circular progress bar
In earlier attempts I created a gradient shape layer & set it to be masked by the bounds of the BarLayer, however the gradient shape layer draw a rectangle starting from the centre of my circle & extend to the bottom right so only that portion of the BarLayer would have a gradient
When I moved the origin of the gradient layer rectangle it shifted the Barlayer off the TrackLayer with it
How do I:
Have the gradient layer cover the entire circular progress bar & mask it to the BarLayer
class CircularProgressBar: UIView {
let shapeLayer = CAShapeLayer()
let trackLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
addProgressBar(radius: 5, progress: 0)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
addProgressBar(radius: 5, progress: 0)
}
func addProgressBar(radius: CGFloat, progress: CGFloat) {
let lineWidth = radius*0.080
let circularPath = UIBezierPath(arcCenter: CGPoint(x: bounds.midX, y: bounds.midY), radius: radius, startAngle: 0, endAngle: CGFloat.pi*2, clockwise: true)
//TrackLayer
trackLayer.path = circularPath.cgPath
trackLayer.fillColor = UIColor.lightGray.cgColor
trackLayer.strokeColor = UIColor.clear.cgColor
trackLayer.opacity = 0.5
trackLayer.lineWidth = lineWidth
trackLayer.lineCap = CAShapeLayerLineCap.round
//BarLayer
shapeLayer.path = circularPath.cgPath
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.systemGreen.cgColor
shapeLayer.lineWidth = lineWidth
shapeLayer.strokeEnd = 0
shapeLayer.lineCap = CAShapeLayerLineCap.round
//Rotate Shape Layer
shapeLayer.transform = CATransform3DMakeRotation(-CGFloat.pi/2, 0, 0, 1)
//Shape Shadow
shapeLayer.shadowColor = UIColor.black.cgColor
shapeLayer.shadowOffset = CGSize(width: -7, height: 7)
shapeLayer.shadowRadius = 1
shapeLayer.shadowOpacity = 0.5
//Animation
loadProgress(percentage: progress)
//LoadLayers
layer.addSublayer(trackLayer)
layer.addSublayer(shapeLayer)
}
func loadProgress(percentage: CGFloat) {
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.fromValue = 0
basicAnimation.duration = 2
basicAnimation.fillMode = CAMediaTimingFillMode.forwards
basicAnimation.isRemovedOnCompletion = false
shapeLayer.strokeEnd = percentage
shapeLayer.add(basicAnimation, forKey: "basicStroke")
}
ViewController
class HomeViewController: UIViewController {
#IBOutlet weak var containerView: UIView!
var circularProgressBar = CircularProgressBar()
var radius: CGFloat!
var progress: CGFloat!
var answeredCorrect = 0
var totalQuestions = 0
override func viewDidLoad() {
super.viewDidLoad()
answeredCorrect = 50
totalQuestions = 100
//Configure Progress Bar
radius = (containerView.frame.height)/2.60
progress = CGFloat(answeredCorrect) / CGFloat (totalQuestions)
circularProgressBar.addProgressBar(radius: radius, progress: progress)
circularProgressBar.center = containerView.center
//Adding view
containerView.addSubview(circularProgressBar)
}
}
If you are looking for something like this
You need to update your code
import UIKit
#IBDesignable
class CircularProgressBar: UIView {
let shapeLayer = CAShapeLayer()
let trackLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
// addProgressBar(radius: 50, progress: 50)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
// addProgressBar(radius: 50, progress: 50)
}
override func layoutSubviews() {
addProgressBar(radius: 50, progress: 50)
}
func addProgressBar(radius: CGFloat, progress: CGFloat) {
let lineWidth = CGFloat(10.0)//radius*0.080
let circularPath = UIBezierPath(arcCenter: CGPoint(x: bounds.midX, y: bounds.midY), radius: radius, startAngle: 0, endAngle: CGFloat.pi*2, clockwise: true)
//TrackLayer
trackLayer.path = circularPath.cgPath
trackLayer.fillColor = UIColor.lightGray.cgColor
trackLayer.strokeColor = UIColor.clear.cgColor
trackLayer.lineWidth = lineWidth
trackLayer.lineCap = CAShapeLayerLineCap.round
//BarLayer
shapeLayer.path = circularPath.cgPath
shapeLayer.fillColor = UIColor.black.cgColor
shapeLayer.strokeColor = UIColor.systemGreen.cgColor
shapeLayer.lineWidth = lineWidth*2
//shapeLayer.strokeEnd = 0
shapeLayer.lineCap = CAShapeLayerLineCap.round
//Rotate Shape Layer
// shapeLayer.transform = CATransform3DMakeRotation(-CGFloat.pi/2, 0, 0, 1)
//Animation
// loadProgress(percentage: progress)
//LoadLayers
layer.addSublayer(trackLayer)
self.addGradient()
// layer.addSublayer(shapeLayer)
}
private func addGradient() {
let gradient = CAGradientLayer()
gradient.colors = [UIColor.red.cgColor,UIColor.purple.cgColor,UIColor.systemPink.cgColor,UIColor.blue.cgColor]
gradient.frame = bounds
gradient.mask = shapeLayer
layer.addSublayer(gradient)
}
}
And for Uniform gradient you can replace addGradient method
private func addGradient() {
let gradient = CAGradientLayer()
gradient.colors = [UIColor.red.cgColor,UIColor.cyan.cgColor,UIColor.brown.cgColor,UIColor.blue.cgColor]
gradient.frame = bounds
gradient.locations = [0.2,0.5,0.75,1]
gradient.startPoint = CGPoint(x: 0.5, y: 0.5)
gradient.endPoint = CGPoint(x: 0.5, y: 0)
gradient.type = .conic
gradient.mask = shapeLayer
layer.addSublayer(gradient)
}

How to create UIButton Pulse animation using Swift? [duplicate]

This question already has answers here:
How to do a native "Pulse effect" animation on a UIButton - iOS
(4 answers)
Closed 3 years ago.
I am trying to create pulse animation for mic UIButton. Whenever a user clicks the UIButton I am showing pause button meanwhile I need to show pulse animation.
Exactly expecting Like below sample image:
Edited answer after your requests: a very, very simple implementation could be this:
class PulsatingButton: UIButton {
let pulseLayer: CAShapeLayer = {
let shape = CAShapeLayer()
shape.strokeColor = UIColor.clear.cgColor
shape.lineWidth = 10
shape.fillColor = UIColor.white.withAlphaComponent(0.3).cgColor
shape.lineCap = .round
return shape
}()
override init(frame: CGRect) {
super.init(frame: frame)
setupShapes()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupShapes()
}
fileprivate func setupShapes() {
setNeedsLayout()
layoutIfNeeded()
let backgroundLayer = CAShapeLayer()
let circularPath = UIBezierPath(arcCenter: self.center, radius: bounds.size.height/2, startAngle: 0, endAngle: 2 * CGFloat.pi, clockwise: true)
pulseLayer.frame = bounds
pulseLayer.path = circularPath.cgPath
pulseLayer.position = self.center
self.layer.addSublayer(pulseLayer)
backgroundLayer.path = circularPath.cgPath
backgroundLayer.lineWidth = 10
backgroundLayer.fillColor = UIColor.blue.cgColor
backgroundLayer.lineCap = .round
self.layer.addSublayer(backgroundLayer)
}
func pulse() {
let animation = CABasicAnimation(keyPath: "transform.scale")
animation.toValue = 1.2
animation.duration = 1.0
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
animation.autoreverses = true
animation.repeatCount = .infinity
pulseLayer.add(animation, forKey: "pulsing")
}
}
then in your viewDidLoad or where you want inside your View Controller:
let button = PulsatingButton(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
button.center = self.view.center
view.addSubview(button)
button.pulse()

iOS animate drawing of multiple circles, only one displays

Based on the answer to this question: Animate drawing of a circle
I now want to display two of these circles simultaneously on the same screen but in two different views. If I just want to animate one circle there are no problems. However if I try to add a second, only the second one is visible.
This is the Circle class:
import UIKit
var circleLayer: CAShapeLayer!
class Circle: UIView {
init(frame: CGRect, viewLayer: CALayer) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
// Use UIBezierPath as an easy way to create the CGPath for the layer.
// The path should be the entire circle.
let circlePath = UIBezierPath(arcCenter: CGPoint(x: frame.size.width / 2.0, y: frame.size.height / 2.0), radius: (frame.size.width - 10)/2, startAngle: (3.0 * .pi)/2.0, endAngle: CGFloat((3.0 * .pi)/2.0 + (.pi * 2.0)), clockwise: true)
// Setup the CAShapeLayer with the path, colors, and line width
circleLayer = CAShapeLayer()
circleLayer.path = circlePath.cgPath
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.strokeColor = UIColor.green.cgColor
circleLayer.lineWidth = 8.0;
// Don't draw the circle initially
circleLayer.strokeEnd = 0.0
// Add the circleLayer to the view's layer's sublayers
viewLayer.addSublayer(circleLayer)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func animateCircle(duration: TimeInterval) {
// We want to animate the strokeEnd property of the circleLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
// Set the animation duration appropriately
animation.duration = duration
// Animate from 0 (no circle) to 1 (full circle)
animation.fromValue = 0
animation.toValue = 1
// Do a linear animation (i.e The speed of the animation stays the same)
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
// Set the circleLayer's strokeEnd property to 1.0 now so that it's the
// Right value when the animation ends
circleLayer.strokeEnd = 1.0
// Do the actual animation
circleLayer.add(animation, forKey: "animateCircle")
}
func removeCircle() {
circleLayer.strokeEnd = 0.0
}
}
And here is how I call it from my ViewController:
var rythmTimer: Circle?
var adrenalineTimer: Circle?
override func viewDidLoad() {
// Create two timers as circles
self.rythmTimer = Circle(frame: CGRect(x: 0, y: 0, width: 100, height: 100), viewLayer: view1.layer)
if let rt = rythmTimer {
view1.addSubview(rt)
rt.center = CGPoint(x: self.view1.bounds.midX, y: self.view1.bounds.midY);
}
self.adrenalineTimer = Circle(frame: CGRect(x: 0, y: 0, width: 100, height: 100), viewLayer: view2.layer)
if let at = adrenalineTimer {
view2.addSubview(at)
at.center = CGPoint(x: self.view2.bounds.midX, y: self.view2.bounds.midY)
}
}
If I remove the code for the adrenalineTimer I can see the circle drawn by the rythmTimer. If I keep it the rythmTimer will be displayed in view2 instead of view1 and will have the duration/color of the rythmTimer
It seems you set the circle path with wrong origin points. In addition, you do it before you place the circle on the view.
Add this code to the animate function instead:
func animateCircle(duration: TimeInterval) {
let circlePath = UIBezierPath(arcCenter: CGPoint(x: self.frame.origin.x + self.frame.size.width / 2, y: self.frame.origin.y + self.frame.size.height / 2), radius: (frame.size.width - 10)/2, startAngle: (3.0 * .pi)/2.0, endAngle: CGFloat((3.0 * .pi)/2.0 + (.pi * 2.0)), clockwise: true)
circleLayer.path = circlePath.cgPath
// We want to animate the strokeEnd property of the circleLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
...
The main problem is that you have declared circleLayer outside of the class:
import UIKit
var circleLayer: CAShapeLayer!
class Circle: UIView {
init(frame: CGRect, viewLayer: CALayer) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
...
}
}
The result is that you only ever have ONE circleLayer object (instance).
If you move it inside the class, then each instance of Circle will have its own instance of circleLayer:
import UIKit
class Circle: UIView {
var circleLayer: CAShapeLayer!
init(frame: CGRect, viewLayer: CALayer) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
...
}
}
There are a number of other odd things you're doing, but that is why you only get one animated circle.
Edit: Here's a modified version of your code that will allow you to use auto-layout instead of fixed / hard-coded sizes and positions. You can run this directly in a Playground page:
import UIKit
import PlaygroundSupport
class Circle: UIView {
var circleLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
// Setup the CAShapeLayer with colors and line width
circleLayer = CAShapeLayer()
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.strokeColor = UIColor.green.cgColor
circleLayer.lineWidth = 8.0;
// We haven't set the path yet, so don't draw initially
circleLayer.strokeEnd = 0.0
// Add the layer to the self's layer
self.layer.addSublayer(circleLayer)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func layoutSubviews() {
super.layoutSubviews()
// Use UIBezierPath as an easy way to create the CGPath for the layer.
// The path should be the entire circle.
// this will update whenever the frame size changes (makes it easy to use with auto-layout)
let circlePath = UIBezierPath(arcCenter: CGPoint(x: frame.size.width / 2.0, y: frame.size.height / 2.0), radius: (frame.size.width - 10)/2, startAngle: (3.0 * .pi)/2.0, endAngle: CGFloat((3.0 * .pi)/2.0 + (.pi * 2.0)), clockwise: true)
circleLayer.path = circlePath.cgPath
}
func animateCircle(duration: TimeInterval) {
// We want to animate the strokeEnd property of the circleLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
// Set the animation duration appropriately
animation.duration = duration
// Animate from 0 (no circle) to 1 (full circle)
animation.fromValue = 0
animation.toValue = 1
// Do a linear animation (i.e The speed of the animation stays the same)
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
// Set the circleLayer's strokeEnd property to 1.0 now so that it's the
// Right value when the animation ends
circleLayer.strokeEnd = 1.0
// Do the actual animation
circleLayer.add(animation, forKey: "animateCircle")
}
func removeCircle() {
circleLayer.strokeEnd = 0.0
}
}
class MyViewController : UIViewController {
var rythmTimer: Circle?
var adrenalineTimer: Circle?
var theButton: UIButton = {
let b = UIButton()
b.setTitle("Tap Me", for: .normal)
b.translatesAutoresizingMaskIntoConstraints = false
b.backgroundColor = .red
return b
}()
var view1: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .blue
return v
}()
var view2: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .orange
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.white
view.addSubview(theButton)
// constrain button to Top: 32 and centerX
NSLayoutConstraint.activate([
theButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 32.0),
theButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 0.0),
])
// add an action for the button tap
theButton.addTarget(self, action: #selector(didTap(_:)), for: .touchUpInside)
view.addSubview(view1)
view.addSubview(view2)
NSLayoutConstraint.activate([
view1.widthAnchor.constraint(equalToConstant: 120.0),
view1.heightAnchor.constraint(equalTo: view1.widthAnchor, multiplier: 1.0),
view1.centerXAnchor.constraint(equalTo: view.centerXAnchor),
view1.topAnchor.constraint(equalTo: theButton.bottomAnchor, constant: 40.0),
view2.widthAnchor.constraint(equalTo: view1.widthAnchor, multiplier: 1.0),
view2.heightAnchor.constraint(equalTo: view1.widthAnchor, multiplier: 1.0),
view2.centerXAnchor.constraint(equalTo: view.centerXAnchor),
view2.topAnchor.constraint(equalTo: view1.bottomAnchor, constant: 40.0),
])
rythmTimer = Circle(frame: CGRect.zero)
adrenalineTimer = Circle(frame: CGRect.zero)
if let rt = rythmTimer,
let at = adrenalineTimer {
view1.addSubview(rt)
view2.addSubview(at)
rt.translatesAutoresizingMaskIntoConstraints = false
at.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
rt.widthAnchor.constraint(equalToConstant: 100.0),
rt.heightAnchor.constraint(equalToConstant: 100.0),
rt.centerXAnchor.constraint(equalTo: view1.centerXAnchor),
rt.centerYAnchor.constraint(equalTo: view1.centerYAnchor),
at.widthAnchor.constraint(equalToConstant: 100.0),
at.heightAnchor.constraint(equalToConstant: 100.0),
at.centerXAnchor.constraint(equalTo: view2.centerXAnchor),
at.centerYAnchor.constraint(equalTo: view2.centerYAnchor),
])
}
}
// on button tap, change the text in the label(s)
#objc func didTap(_ sender: Any?) -> Void {
if let rt = rythmTimer,
let at = adrenalineTimer {
rt.removeCircle()
at.removeCircle()
rt.animateCircle(duration: 2.0)
at.animateCircle(duration: 2.0)
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

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:
I want to animate its background color so that it gradually changes to a red color. This would be the end result:
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
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
}
You're on the right track with CABasicAnimation.
The keyPath: in let anim = CABasicAnimation(keyPath: "squareColor") should be backgroundColor.
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.
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()
}
}
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()
}
}
Note: I have edited the answer so that the shape will always fit the parent view added from storyboard
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
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,
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
}

Animate CAShapeLayer & Shadow independently

I have a 'UIView' Subclass called 'HexagonView' in which I create a layer to display a Hexagon. I then add another layer with the same path as shadow layer. I had to do it this way since I could not display a shadow outside of the 'layer.mask' Shape.
My goal is to animate a 360° rotation of the shape, while the shadow stays on it's position instead of moving around with the rotation.
See example image
This is the code I have so far:
#IBDesignable class HexagonView: UIView {
override func prepareForInterfaceBuilder()
{
super.prepareForInterfaceBuilder()
configureLayer()
addShadow()
}
override init(frame: CGRect)
{
super.init(frame: frame)
configureLayer()
addShadow()
}
required init?(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)
configureLayer()
addShadow()
}
func configureLayer()
{
let maskLayer = CAShapeLayer()
maskLayer.fillRule = kCAFillRuleEvenOdd
maskLayer.frame = bounds
UIGraphicsBeginImageContext(frame.size)
maskLayer.path = getHexagonPath().cgPath
UIGraphicsEndImageContext()
layer.addSublayer(maskLayer)
}
private func getHexagonPath() -> UIBezierPath
{
let width = frame.size.width
let height = frame.size.height
let padding = width * 1 / 8 / 2
let path = UIBezierPath()
path.move(to: CGPoint(x: width / 2, y: 0))
path.addLine(to: CGPoint(x: width - padding, y: height / 4))
path.addLine(to: CGPoint(x: width - padding, y: height * 3 / 4))
path.addLine(to: CGPoint(x: width / 2, y: height))
path.addLine(to: CGPoint(x: padding, y: height * 3 / 4))
path.addLine(to: CGPoint(x: padding, y: height / 4))
path.close()
return path
}
func addShadow()
{
let s = CAShapeLayer()
s.fillColor = backgroundColor?.cgColor
s.frame = layer.bounds
s.path = getHexagonPath().cgPath
layer.backgroundColor = UIColor.clear.cgColor
layer.addSublayer(s)
layer.masksToBounds = false
layer.shadowColor = AppAppearance.Colors.labelBackground.cgColor
layer.shadowOffset = CGSize(width: 5, height: 10)
layer.shadowOpacity = 0.5
layer.shadowPath = getHexagonPath().cgPath
layer.shadowRadius = 0
}
func rotate(duration: CFTimeInterval = 1.0, completionDelegate: AnyObject? = nil)
{
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = CGFloat(M_PI * 2.0)
rotateAnimation.duration = duration
rotateAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
if let delegate = completionDelegate as? CAAnimationDelegate {
rotateAnimation.delegate = delegate
}
layer.add(rotateAnimation, forKey: nil)
}
}
How can I achieve the shadow rotation on its starting position rather than having it attached to the white layer.

Resources