Make dynamic dashed border in Swift: avoid lumping and watch original cat memes

Let's imagine you need to add a dashed border to a view. Normally, you would write code like that.

First iteration

private func addUglyDashedBorder() {
    borderLayer = CAShapeLayer()
    let viewRect = CGRect(x: 0, y: 0, width: frame.size.width, height: frame.size.height)

    borderLayer.bounds = viewRect
    borderLayer.position = CGPoint(x: frame.size.width / 2, y: frame.size.height / 2)
    borderLayer.fillColor = UIColor.clear.cgColor
    borderLayer.strokeColor = UIColor.black.cgColor
    borderLayer.lineWidth = 1 
    borderLayer.lineJoin = .round

    borderLayer.lineDashPattern = [5, 5]
    borderLayer.path = UIBezierPath(roundedRect: viewRect, cornerRadius: Constant.Dimens.cornerRadius).cgPath // Constant.Dimens.cornerRadius == 12

    layer.addSublayer(borderLayer)
}

Result

Screenshot 2022-07-07 at 14.32.36.png

Looks ok. But what is this ugly artefact in the top left corner? Looks like the dashes are too close.

Cat meme #1

Screenshot 2022-07-07 at 14.56.40.png

The reason

The reason for that is plain. iOS creates a path and then follows the pattern: draw a line for 5 points, leave 5 more points empty. It goes on till it fills the entire path. But what happens if the path length is for example 106 points? Then we will see 10 complete segments – meaning they have 5 filled and 5 empty points. It gives us 100 points. Then iOS draws another dash – we are now at 105 points. For the last empty space, there is only 1 point left! It looks like two dashes almost merged. Rather ugly.

One could get lucky. Such a not dynamic approach can work. But it does not mean it will always work – we have phones of different sizes. On one of them, there might be a configuration that looks ugly. So we have to fix it. And I have a feeling that we cannot find a magic number that fits all ;)

Cat meme #2

Screenshot 2022-07-07 at 14.58.32.png

Better approach

private func addDashedBorder() {
    // all the same
    borderLayer.lineDashPattern = [dashLength(), dashLength()]
    // still the same code
}

private func dashLength() -> NSNumber {
    let perimeterWithoutRoundedCorners = frame.size.width * 2 + frame.size.height * 2 - 8 * Constant.Dimens.cornerRadius
    let cornersPerimeter = (2 * CGFloat.pi * Constant.Dimens.cornerRadius)
    let perimeter = perimeterWithoutRoundedCorners + cornersPerimeter // 1

    let desiredLengthPerDashAndSpacing: CGFloat = 10
    let amountOfSegments = perimeter / desiredLengthPerDashAndSpacing // 2
    let roundedAmountOfSegments = round(amountOfSegments) // 3

    let segmentLength = perimeter / roundedAmountOfSegments // 4
    return NSNumber(value: segmentLength / 2) // 5
}

In human-readable words:

  1. Get the view's perimeter. My view has rounded corners, thus extra calculus;
  2. Calculate how many segments of dash + spacing there are. It is a fractional number;
  3. Round the count of segments: we want to have the count of segments whole;
  4. Divide perimeter by new count of segments to learn how long a segment should be;
  5. Remember, a segment is a dash and spacing sequence. If they have the same length we have to divide them in half.

Screenshot 2022-07-07 at 14.43.02.png

🎉

Cat meme #3

6m1r7e.jpg

Thank you for watching this cat frenzy – and checking out the article. I wish your house to always be full of moderately fat cats