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

Figure 1 — Modified coordinates used in Quartz ^1
Figure 2 — Arrow shape
Figure 3 — Arrow shape annotated by its main points
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
}
}
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
}
}
  1. Freezing the animated path:
animation.fillMode = CAMediaTimingFillMode.forwards animation.isRemovedOnCompletion = false
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()
}
}
}
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)
}
}
Figure 4 — Animation of arrow’s edge
  1. Apple Quartz 2D Programming Guide

--

--

--

I’m an iOS developer with 6+ years of expertise building mobile apps, working among a team of talented developers to create some sports apps at Pulselive.

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

Getting started with Google Colab

Benefits of Prototyping without Assets

Is Custom Software Development Right For Your Business?

Is Custom Software Development Right For Your Business?

JSON Web Tokens (JWT) as OAuth 2.0 Bearer Access Tokens

C as the first programming language instead of Java

RSA cryptography in Scala

How to create cool figures with Matplotlib

Debian 9 Kernel Version

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Soheil Novinfard

Soheil Novinfard

I’m an iOS developer with 6+ years of expertise building mobile apps, working among a team of talented developers to create some sports apps at Pulselive.

More from Medium

Drawing Vectors using UIBezierPath in Swift

SwiftUI + Combine with MVVM design pattern by designing a login page

Link to App Store specific app from iOS app by URL scheme

Create Your Own UIActivityIndicatorView in iOS using Swift