Move UIview Per UIbezierpath

What's the best way to move a UIBezierPath stroke in Swift 4?

Draw the cursor as CAShapeLayer object. This lets you move the cursor without having to redraw it.

class MyView: UIView {
let cursor = CAShapeLayer()

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}

func setup() {
// Draw cursor and add it to this view's layer
let path = UIBezierPath()
path.move(to: .zero)
path.addLine(to: CGPoint(x: 100, y: 0))

cursor.path = path.cgPath
cursor.strokeColor = UIColor.black.cgColor
cursor.lineWidth = 2

layer.addSublayer(cursor)

changeCursorLocation(location: CGPoint(x: 50, y: 100))
}

func changeCursorLocation(location: CGPoint) {
cursor.position = location
}
}

Sample Image

Drawing UIBezierPath on code generated UIView

It wasn't long ago that I didn't even know how to pronounce Bézier, let alone know how to use Bézier paths to make a custom shape. The following is what I have learned. It turns out that they aren't as scary as they seem at first.

How to draw a Bézier path in a custom view

These are the main steps:

  1. Design the outline of the shape you want.
  2. Divide the outline path into segments of lines, arcs, and curves.
  3. Build that path programmatically.
  4. Draw the path either in drawRect or using a CAShapeLayer.

Design shape outline

You could do anything, but as an example I have chosen the shape below. It could be a popup key on a keyboard.

Sample Image

Divide the path into segments

Look back at your shape design and break it down into simpler elements of lines (for straight lines), arcs (for circles and round corners), and curves (for anything else).

Here is what our example design would look like:

Sample Image

  • Black are line segments
  • Light blue are arc segments
  • Red are curves
  • Orange dots are the control points for the curves
  • Green dots are the points between path segments
  • Dotted lines show the bounding rectangle
  • Dark blue numbers are the segments in the order that they will be added programmatically

Build the path programmatically

We'll arbitrarily start in the bottom left corner and work clockwise. I'll use the grid in the image to get the x and y values for the points. I'll hardcode everything here, but of course you wouldn't do that in a real project.

The basic process is:

  1. Create a new UIBezierPath
  2. Choose a starting point on the path with moveToPoint
  3. Add segments to the path

    • line: addLineToPoint
    • arc: addArcWithCenter
    • curve: addCurveToPoint
  4. Close the path with closePath

Here is the code to make the path in the image above.

func createBezierPath() -> UIBezierPath {

// create a new path
let path = UIBezierPath()

// starting point for the path (bottom left)
path.move(to: CGPoint(x: 2, y: 26))

// *********************
// ***** Left side *****
// *********************

// segment 1: line
path.addLine(to: CGPoint(x: 2, y: 15))

// segment 2: curve
path.addCurve(to: CGPoint(x: 0, y: 12), // ending point
controlPoint1: CGPoint(x: 2, y: 14),
controlPoint2: CGPoint(x: 0, y: 14))

// segment 3: line
path.addLine(to: CGPoint(x: 0, y: 2))

// *********************
// ****** Top side *****
// *********************

// segment 4: arc
path.addArc(withCenter: CGPoint(x: 2, y: 2), // center point of circle
radius: 2, // this will make it meet our path line
startAngle: CGFloat(M_PI), // π radians = 180 degrees = straight left
endAngle: CGFloat(3*M_PI_2), // 3π/2 radians = 270 degrees = straight up
clockwise: true) // startAngle to endAngle goes in a clockwise direction

// segment 5: line
path.addLine(to: CGPoint(x: 8, y: 0))

// segment 6: arc
path.addArc(withCenter: CGPoint(x: 8, y: 2),
radius: 2,
startAngle: CGFloat(3*M_PI_2), // straight up
endAngle: CGFloat(0), // 0 radians = straight right
clockwise: true)

// *********************
// ***** Right side ****
// *********************

// segment 7: line
path.addLine(to: CGPoint(x: 10, y: 12))

// segment 8: curve
path.addCurve(to: CGPoint(x: 8, y: 15), // ending point
controlPoint1: CGPoint(x: 10, y: 14),
controlPoint2: CGPoint(x: 8, y: 14))

// segment 9: line
path.addLine(to: CGPoint(x: 8, y: 26))

// *********************
// **** Bottom side ****
// *********************

// segment 10: line
path.close() // draws the final line to close the path

return path
}

Note: Some of the above code can be reduced by adding a line and an arc in a single command (since the arc has an implied starting point). See here for more details.

Draw the path

We can draw the path either in a layer or in drawRect.

Method 1: Draw path in a layer

Our custom class looks like this. We add our Bezier path to a new CAShapeLayer when the view is initialized.

import UIKit
class MyCustomView: UIView {

override init(frame: CGRect) {
super.init(frame: frame)
setup()
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}

func setup() {

// Create a CAShapeLayer
let shapeLayer = CAShapeLayer()

// The Bezier path that we made needs to be converted to
// a CGPath before it can be used on a layer.
shapeLayer.path = createBezierPath().cgPath

// apply other properties related to the path
shapeLayer.strokeColor = UIColor.blue.cgColor
shapeLayer.fillColor = UIColor.white.cgColor
shapeLayer.lineWidth = 1.0
shapeLayer.position = CGPoint(x: 10, y: 10)

// add the new layer to our custom view
self.layer.addSublayer(shapeLayer)
}

func createBezierPath() -> UIBezierPath {

// see previous code for creating the Bezier path
}
}

And creating our view in the View Controller like this

override func viewDidLoad() {
super.viewDidLoad()

// create a new UIView and add it to the view controller
let myView = MyCustomView()
myView.frame = CGRect(x: 100, y: 100, width: 50, height: 50)
myView.backgroundColor = UIColor.yellow
view.addSubview(myView)

}

We get...

Sample Image

Hmm, that's a little small because I hardcoded all the numbers in. I can scale the path size up, though, like this:

let path = createBezierPath()
let scale = CGAffineTransform(scaleX: 2, y: 2)
path.apply(scale)
shapeLayer.path = path.cgPath

Sample Image

Method 2: Draw path in draw

Using draw is slower than drawing to the layer, so this is not the recommended method if you don't need it.

Here is the revised code for our custom view:

import UIKit
class MyCustomView: UIView {

override func draw(_ rect: CGRect) {

// create path (see previous code)
let path = createBezierPath()

// fill
let fillColor = UIColor.white
fillColor.setFill()

// stroke
path.lineWidth = 1.0
let strokeColor = UIColor.blue
strokeColor.setStroke()

// Move the path to a new location
path.apply(CGAffineTransform(translationX: 10, y: 10))

// fill and stroke the path (always do these last)
path.fill()
path.stroke()

}

func createBezierPath() -> UIBezierPath {

// see previous code for creating the Bezier path
}
}

which gives us the same result...

Sample Image

Further study

I really recommend looking at the following materials. They are what finally made Bézier paths understandable for me. (And taught me how to pronounce it: /ˈbɛ zi eɪ/.)

  • Thinking like a Bézier path (Everything I've ever read from this author is good and the inspiration for my example above came from here.)
  • Coding Math: Episode 19 - Bezier Curves (entertaining and good visual illustrations)
  • Bezier Curves (how they are used in graphics applications)
  • Bezier Curves (good description of how the mathematical formulas are derived)

How do I animate a UIView along a circular path?

Updated: Update code to Swift 5.6

Use UIBezierPath and CAKeyFrameAnimation.

Here is the code:

let circlePath = UIBezierPath(arcCenter: view.center, radius: 20, startAngle: 0, endAngle: .pi*2, clockwise: true)

let animation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.position))
animation.path = circlePath.cgPath
animation.duration = 1
animation.repeatCount = .infinity

let squareView = UIView()
// whatever the value of origin for squareView will not affect the animation
squareView.frame = CGRect(x: 0, y: 0, width: 50, height: 50)
squareView.backgroundColor = .lightGray
view.addSubview(squareView)
// you can also pass any unique string value for key
squareView.layer.add(animation, forKey: nil)

// circleLayer is only used to show the circle animation path
let circleLayer = CAShapeLayer()
circleLayer.path = circlePath.cgPath
circleLayer.strokeColor = UIColor.black.cgColor
circleLayer.fillColor = UIColor.clear.cgColor
view.layer.addSublayer(circleLayer)

Here is the screenshot:

Sample Image

Moreover, you can set the anchorPoint property of squareView.layer to make the view not animate anchored in it's center. The default value of anchorPoint is (0.5, 0.5) which means the center point.

Animate view borders with custom UIBezierPath

The reasons why the animation happens immediately is that the same path is assigned to maskLayer and is used as the toValue of the animation.

let maskLayer = CAShapeLayer()
maskLayer.path = path.cgPath // same path used here...

let animation = CABasicAnimation(keyPath: "path")
animation.toValue = path.cgPath // ... as here
animation.duration = 1

maskLayer.add(animation, forKey: "makeRoundedCorners")
self.layer.mask = maskLayer

Since that path is the expected end value of your animation, you need to provide a value to animate from.

Note: the path animation is "undefined"
if the two paths have a different number of control points or segment.

UIView pauses during animation along UIBezierPath

The path you are using consists of 5 segments (two arcs and three lines (including the one when you close the path)) and the animation spends the same time on each of the segments. If this path if too narrow, these lines segments will have no length and the square will appear still for 12 seconds in each of them.

5 segments of the path

You probably want to use a "paced" calculation mode to achieve a constant velocity

animation.calculationMode = kCAAnimationPaced

This way, the red square will move at a constant pace – no matter how long each segment of the shape is.

This alone will give you the result you are after, but there's more you can do to simplify the path.

The addArc(...) method is rather smart and will add a line from the current point to the start of the arc itself, so the two explicit lines aren't needed.

removing the two lines

Additionally, if you change the initial point you're moving the path to to have the same x component as the center of the second arc, then the path will close itself. Here, all you need are the two arcs.

only the two arcs

That said, if the shape you're looking to create is a rounded rectangle like this, then you can use the UIBezierPath(roundedRect:cornerRadius:) convenience initializer:

let radius = min(layerView.bounds.width, layerView.bounds.height) / 2.0
let path = UIBezierPath(roundedRect: layerView.bounds, cornerRadius: radius)

UIBezierPath Rotation around a UIView's center

As a demonstration of @DuncanC's answer (up voted), here is the drawing of a gear using CGAffineTransforms to rotate the gear tooth around the center of the circle:

class Gear: UIView {
var lineWidth: CGFloat = 16
let boxWidth: CGFloat = 20
let toothAngle: CGFloat = 45

override func draw(_ rect: CGRect) {

let radius = (min(bounds.width, bounds.height) - lineWidth) / 4.0

var path = UIBezierPath()
path.lineWidth = lineWidth
UIColor.white.set()

// Use the center of the bounds not the center of the frame to ensure
// this draws correctly no matter the location of the view
// (thanks @dulgan for pointing this out)
let center = CGPoint(x: bounds.maxX / 2, y: bounds.maxY / 2)

// Draw circle
path.move(to: CGPoint(x: center.x + radius, y: center.y))
path.addArc(withCenter: CGPoint(x: center.x, y: center.y), radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
path.stroke()

// Box for gear tooth
path = UIBezierPath()
let point = CGPoint(x: center.x - boxWidth / 2.0, y: center.y - radius)
path.move(to: point)
path.addLine(to: CGPoint(x: point.x, y: point.y - boxWidth))
path.addLine(to: CGPoint(x: point.x + boxWidth, y: point.y - boxWidth))
path.addLine(to: CGPoint(x: point.x + boxWidth, y: point.y))
path.close()
UIColor.red.set()

// Draw a tooth every toothAngle degrees
for _ in stride(from: toothAngle, through: 360, by: toothAngle) {
// Move origin to center of the circle
path.apply(CGAffineTransform(translationX: -center.x, y: -center.y))

// Rotate
path.apply(CGAffineTransform(rotationAngle: toothAngle * .pi / 180))

// Move origin back to original location
path.apply(CGAffineTransform(translationX: center.x, y: center.y))

// Draw the tooth
path.fill()
}
}
}

let view = Gear(frame: CGRect(x: 0, y: 0, width: 200, height: 200))

Here it is running in a Playground:

Demo of gear running in a Playground

Draw UIBezierPaths in UIView without subclassing?

Another way to draw bezier paths without using drawRect is to use CAShapeLayers. Set the path property of a shape layer to a CGPath created from the bezier path. Add the shape layer as a sublayer to your view's layer.

    UIBezierPath *shape = [UIBezierPath bezierPathWithOvalInRect:self.view.bounds];
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
shapeLayer.path = shape.CGPath;
shapeLayer.fillColor = [UIColor colorWithRed:.5 green:1 blue:.5 alpha:1].CGColor;
shapeLayer.strokeColor = [UIColor blackColor].CGColor;
shapeLayer.lineWidth = 2;

[self.view.layer addSublayer:shapeLayer];


Related Topics



Leave a reply



Submit