Creating custom shapes using Bezier Paths and animate them by CABasicAnimation in iOS
You can create custom shapes in iOS with using the power of UIBezierPaths
. In this tutorial, we are going to create custom arrow shape with bezier paths and then animate the degree of its edge by CABasicAnimation
.
You can use shape layers for creating UI elements. The shape layers are vector-based, so the quality doesn’t change with changing resolution. It is possible to animate the shape layers with Core Animation libraries which is another advantage of shaper layers. Additionally, the process of rendering the CAShapeLayer
optimises by hardware.
Before starting to create a shape, let’s see the axis of drawing. Because UIBezierPaths
uses Quartz coordinate systems, the y-axis is flipped vertically as follows:
The custom shape we want to draw in this example is an arrow shape which is shown in figure 2.
To simplifies drawing this shape using bezier paths, we can think of edge points of the shape as the main points and then our job is to draw lines between these points. Figure 3 shows this shape according to its main points:
The “Leading edge width” and “Trailing edge width” are two points in x-axis which indicates the shape of our arrow. If we consider the custom shape inside a rectangle as our view, leading and trailing edge width can be calculated a the percentage of the view’s width.
We want to create the whole arrow shape inside a UIView
. The following code could draw our custom shape inside an instance of UIView
:
import UIKitclass ArrowView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
self.initialConfig()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.initialConfig()
}
let shapeLayer = CAShapeLayer()
private var fillColor: UIColor = .black
/// width percentage of space between view leading and edge leading
///
/// The value should be between 0 and 100
private var leadingEdgeWidthPercentage: Int8 = 20
/// width percentage of space between view trailing and edge trailing
///
/// The value should be between 0 and 100
private var trailingEdgeWidthPercentage: Int8 = 20
func initialConfig() {
self.backgroundColor = .clear
self.layer.addSublayer(self.shapeLayer)
self.setup()
}
func setup(fillColor: UIColor? = nil,
leadingPercentage: Int8? = nil,
trailingPercentage: Int8? = nil) {
if let fillColor = fillColor {
self.fillColor = fillColor
}
if let leading = leadingPercentage,
isValidPercentageRange(leading) {
self.leadingEdgeWidthPercentage = leading
}
if let trailing = trailingPercentage,
isValidPercentageRange(trailing) {
self.trailingEdgeWidthPercentage = trailing
}
}
private func changeShape() {
self.shapeLayer.path = arrowShapePath().cgPath
self.shapeLayer.fillColor = self.fillColor.cgColor
}
private func isValidPercentageRange(_ percentage: Int8) -> Bool {
return 0 ... 100 ~= percentage
}
override func layoutSubviews() {
super.layoutSubviews()
self.changeShape()
}
private func arrowShapePath() -> UIBezierPath {
let size = self.bounds.size
let leadingEdgeWidth = size.width * CGFloat(self.leadingEdgeWidthPercentage) / 100
let trailingEdgeWidth = size.width * (1 - CGFloat(self.trailingEdgeWidthPercentage) / 100)
let path = UIBezierPath()
// move to zero point (top-right corner)
path.move(to: CGPoint(x: 0, y: 0))
// move to right inner edge point
path.addLine(to: CGPoint(x: leadingEdgeWidth, y: size.height/2))
// move to bottom-left corner
path.addLine(to: CGPoint(x: 0, y: size.height))
// move to bottom-right side
path.addLine(to: CGPoint(x: trailingEdgeWidth, y: size.height))
// move to left outer edge point
path.addLine(to: CGPoint(x: size.width, y: size.height/2))
// move to top-right side
path.addLine(to: CGPoint(x: trailingEdgeWidth, y: 0))
// close the path. This will create the last line automatically.
path.close()
return path
}
}
As you can see, we have a shape layer which our custom shape is drawing on it. The FillColor
indicates the color of the shape.
The arrowShapePath
is responsible for creating the bezier path. As you can see, by using the close
function of UIBezierPath
it draws the last line between the first and last drawn point.
Now it’s time for the animation part. We want to change the path of the arrow every time we change the edge percentage in setup
of the view. To do this, we cannot use default UIView
animations. Instead, we can animate the shape with CABasicAnimation
. We modify our initial code to add the animation:
import UIKitclass ArrowView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
self.initialConfig()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.initialConfig()
}
let shapeLayer = CAShapeLayer()
private var fillColor: UIColor = .black
/// width percentage of space between view leading and edge leading
///
/// The value should be between 0 and 100
private var leadingEdgeWidthPercentage: Int8 = 20
/// width percentage of space between view trailing and edge trailing
///
/// The value should be between 0 and 100
private var trailingEdgeWidthPercentage: Int8 = 20
func initialConfig() {
self.backgroundColor = .clear
self.layer.addSublayer(self.shapeLayer)
self.setup()
}
func setup(fillColor: UIColor? = nil,
leadingPercentage: Int8? = nil,
trailingPercentage: Int8? = nil,
animate: Bool = false) {
if let fillColor = fillColor {
self.fillColor = fillColor
}
if let leading = leadingPercentage,
isValidPercentageRange(leading) {
self.leadingEdgeWidthPercentage = leading
}
if let trailing = trailingPercentage,
isValidPercentageRange(trailing) {
self.trailingEdgeWidthPercentage = trailing
}
if animate {
self.animateShape()
} else {
self.changeShape()
}
}
private func changeShape() {
self.shapeLayer.path = arrowShapePath().cgPath
self.shapeLayer.fillColor = self.fillColor.cgColor
}
private func isValidPercentageRange(_ percentage: Int8) -> Bool {
return 0 ... 100 ~= percentage
}
override func layoutSubviews() {
super.layoutSubviews()
self.shapeLayer.removeAllAnimations()
self.changeShape()
}private func animateShape() {
let newShapePath = arrowShapePath().cgPath
let animation = CABasicAnimation(keyPath: "path")
animation.duration = 2
animation.toValue = newShapePath
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
self.shapeLayer.add(animation, forKey: "path")
}
private func arrowShapePath() -> UIBezierPath {
let size = self.bounds.size
let leadingEdgeWidth = size.width * CGFloat(self.leadingEdgeWidthPercentage) / 100
let trailingEdgeWidth = size.width * (1 - CGFloat(self.trailingEdgeWidthPercentage) / 100)
let path = UIBezierPath()
// move to zero point (top-right corner)
path.move(to: CGPoint(x: 0, y: 0))
// move to right inner edge point
path.addLine(to: CGPoint(x: leadingEdgeWidth, y: size.height/2))
// move to bottom-left corner
path.addLine(to: CGPoint(x: 0, y: size.height))
// move to bottom-right side
path.addLine(to: CGPoint(x: trailingEdgeWidth, y: size.height))
// move to left outer edge point
path.addLine(to: CGPoint(x: size.width, y: size.height/2))
// move to top-right side
path.addLine(to: CGPoint(x: trailingEdgeWidth, y: 0))
// close the path. This will create the last line automatically.
path.close()
return path
}
}
After animation happened, the shape does not change its path. To apply our changes we have two options in animateShape
method:
- Freezing the animated path:
animation.fillMode = CAMediaTimingFillMode.forwards animation.isRemovedOnCompletion = false
This solution leads to further problems. For instance, it disables the auto-layout for redrawing the shape layer which in turn results in incorrect shape size after device rotation.
2. Using the animation ending delegate:
By using the CAAnimationDelegate
, we could change the shape’s path when the animation has ended. The below code demonstrates how we can handle it:
private func animateShape() {
let newShapePath = arrowShapePath().cgPath
let animation = CABasicAnimation(keyPath: "path")
animation.duration = 2
animation.toValue = newShapePath
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
animation.delegate = self
self.shapeLayer.add(animation, forKey: "path")
}extension ArrowView: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if flag {
self.changeShape()
}
}
}
To wrap-up the solution, we are going to use our arrow in a view controller with a button to trigger the animation action as follows:
import UIKitclass ViewController: UIViewController {
@IBOutlet weak var arrowView: ArrowView!
private var reverseAnimation = true
override func viewDidLoad() {
super.viewDidLoad()
}
@IBAction func AnimatePressed(_ sender: AnyObject) {
self.reverseAnimation.toggle()
let (leadingPercentage, trailingPercentage) = self.reverseAnimation ? (Int8(0), Int8(0)) : (Int8(50), Int8(50))
self.arrowView.setup(leadingPercentage: leadingPercentage,
trailingPercentage: trailingPercentage,
animate: true)
}
}
In this example controller, we animate the edge path percentage between 0 and 50. The result is shown in the below demo:
If you tend to create complex custom shapes, you can use some tools such as PaintCode.
You can check the source code and fork it in the following Github repository:
References: