Creating custom shapes using Bezier Paths and animate them by CABasicAnimation in iOS

Soheil Novinfard
6 min readFeb 11, 2019

--

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:

Figure 1 — Modified coordinates used in Quartz ^1

The custom shape we want to draw in this example is an arrow shape which is shown in figure 2.

Figure 2 — Arrow shape

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:

Figure 3 — Arrow shape annotated by 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:

  1. 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:

Figure 4 — Animation of arrow’s edge

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:

  1. Apple Quartz 2D Programming Guide

--

--

Soheil Novinfard

Accomplished software engineer with +11 years of experience in collaborating with the product and design teams, helping the travellers at @Skyscanner