This is an intermediate product from one of my projects. The feature was not in use, but the view itself is a fun little thing: a caution tape-style border (yellow and black stripes) drawn around a rounded rect. Several interesting attempts were made. Rather than discarding them outright, I have documented the attempts here.

First attempt: mask a striped fill with the stroke

My first attempt was much clumsier: draw a pure yellow rounded rect, then copy a rotated black rectangle over it again and again:

then use the border stroke as a mask to cut out the ring:

It did render, but the stripes do not follow the path. Look at the top left and bottom right corners: the bars run almost parallel to the arc there, and the rhythm falls apart. So I threw it away.

Second attempt: rotate a bar around the center

The yellow rounded rect stays as the background. This time the black rectangle is as long as the long side, rotated around the center and copied n times. The angles are not uniform, they are picked so the bars land evenly on the border:

then mask again:

The gaps are even this time, but the widths are not: every bar meets the border at a different angle, so some black segments stay slim while others smear into long streaks. And the angle list has to be recomputed for every size. Threw it away again.

The final version: two strokes on one path

The final idea is simple: stack two CAShapeLayer with the same rounded rect path. The bottom one strokes solid yellow, the top one strokes black with a lineDashPattern, so the dash gaps reveal the yellow underneath.

This is what it looks like:

Note the dashes bend around the corners by themselves. That is the whole appeal of stroking a path instead of tiling a striped image: no slicing, no resizableImage, and any corner radius just works.

The tricky part: making the stripes meet

If you hardcode a dash pattern like [14, 14], the last segment almost never lands where the first one starts, leaving an ugly seam at the path’s starting point:

The fix is to measure the real perimeter, then scale the dash so it fits a whole number of times. For a rounded rect the perimeter has a closed form: four straight edges shortened by the corners, plus four quarter circles.

// 2 * (w + h) - 8 * r  straight parts
//     + 2 * .pi * r    four quarter arcs
let perimeter = 2 * (width + height) + radius * (2 * .pi - 8)

let segmentCount = max(1, Int((perimeter / (2 * 14)).rounded()))
let segmentLength = perimeter / CGFloat(2 * segmentCount)
lineDashPattern = [segmentLength as NSNumber, segmentLength as NSNumber]

14 is the target segment length. The actual length is stretched or squeezed a little so that the pattern repeats a whole number of times and closes the loop perfectly:

Note that the path is stroked at insetBy(dx: lineWidth / 2, dy: lineWidth / 2), so the perimeter must be calculated from the inset size and the reduced corner radius as well, otherwise the last segment still drifts.

Interruptible animation

The border can appear, disappear, and change frame at any time. So update captures presentation()?.opacity and presentation()?.path first, then animates from those values. If an animation is interrupted halfway, the new one starts from where the layer visually is, not from the stale model value.

Full code

import UIKit

final class CautionTapeBorder {
    let layer = CALayer()
    private let yellowLayer = CAShapeLayer()
    private let blackLayer = CAShapeLayer()

    init() {
        layer.opacity = 0
        yellowLayer.fillColor = nil
        yellowLayer.strokeColor = UIColor(red: 1, green: 0.84, blue: 0, alpha: 1).cgColor
        blackLayer.fillColor = nil
        blackLayer.strokeColor = UIColor.black.cgColor
        layer.addSublayer(yellowLayer)
        layer.addSublayer(blackLayer)
    }

    func update(
        frame: CGRect,
        cornerRadius: CGFloat,
        lineWidth: CGFloat,
        isVisible: Bool,
        animated: Bool
    ) {
        let previousOpacity = layer.presentation()?.opacity ?? layer.opacity
        let previousPath = yellowLayer.presentation()?.path ?? yellowLayer.path
        let path: CGPath = {
            guard lineWidth > 0, frame.width > 0, frame.height > 0 else {
                return CGMutablePath()
            }

            let inset = lineWidth / 2
            let strokeRect = CGRect(origin: .zero, size: frame.size).insetBy(dx: inset, dy: inset)
            guard strokeRect.width > 0, strokeRect.height > 0 else { return CGMutablePath() }
            return UIBezierPath(
                roundedRect: strokeRect,
                cornerRadius: max(0, cornerRadius - inset)
            ).cgPath
        }()
        let width = max(0, frame.width - lineWidth)
        let height = max(0, frame.height - lineWidth)
        let radius = min(max(0, cornerRadius - lineWidth / 2), min(width, height) / 2)
        let perimeter = 2 * (width + height) + radius * (2 * .pi - 8)
        let lineDashPattern: [NSNumber]?
        if perimeter > 0 {
            let segmentCount = max(1, Int((perimeter / (2 * 14)).rounded()))
            let segmentLength = perimeter / CGFloat(2 * segmentCount)
            lineDashPattern = [segmentLength as NSNumber, segmentLength as NSNumber]
        } else {
            lineDashPattern = nil
        }
        let newOpacity: Float = isVisible && lineWidth > 0 ? 1 : 0

        CATransaction.begin()
        CATransaction.setDisableActions(true)
        layer.frame = frame
        layer.opacity = newOpacity
        yellowLayer.frame = layer.bounds
        yellowLayer.path = path
        yellowLayer.lineWidth = lineWidth
        blackLayer.frame = layer.bounds
        blackLayer.path = path
        blackLayer.lineWidth = lineWidth
        blackLayer.lineDashPattern = lineDashPattern
        CATransaction.commit()

        guard animated else { return }

        if previousOpacity != newOpacity {
            let opacityAnimation = CABasicAnimation(keyPath: "opacity")
            opacityAnimation.fromValue = previousOpacity
            opacityAnimation.toValue = newOpacity
            opacityAnimation.duration = 0.2
            opacityAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
            layer.add(opacityAnimation, forKey: "cautionOpacityTransition")
        }

        if previousOpacity > 0 || newOpacity > 0, previousPath != path {
            let pathAnimation = CABasicAnimation(keyPath: "path")
            pathAnimation.fromValue = previousPath
            pathAnimation.toValue = path
            pathAnimation.duration = 0.2
            pathAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
            yellowLayer.add(pathAnimation, forKey: "cautionPathTransition")
            blackLayer.add(pathAnimation, forKey: "cautionPathTransition")
        }
    }
}