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
}
}
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

--

--

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