iosswiftcashapelayer

How do I add a custom-styled dashed border of an UIView?


I try to add circular dashed border on an UIView.

I wanna make each dash with white color and stroked with black color, such as the line in the image: Styled dash line

I had tried to use 2 CAShapeLayers to draw the border, but they are always not matched with each other.

    let rect = CGRect(origin: .zero, size: .init(width:180, height:180))
    let path = UIBezierPath(ovalIn: rect).cgPath
    let dashedBorderLayer = CAShapeLayer()
    dashedBorderLayer.strokeColor = UIColor.black.cgColor
    dashedBorderLayer.fillColor = nil
    dashedBorderLayer.lineDashPattern = [4, 2]
    dashedBorderLayer.lineWidth = 3
    dashedBorderLayer.path = path
    self.view.layer.addSublayer(dashedBorderLayer)

    let dashedBorderLayer2 = CAShapeLayer()
    dashedBorderLayer2.strokeColor = UIColor.white.cgColor
    dashedBorderLayer2.fillColor = nil
    dashedBorderLayer2.lineDashPattern = [2, 4]
    dashedBorderLayer2.lineWidth = 2
    dashedBorderLayer2.path = path
    self.view.layer.addSublayer(dashedBorderLayer2)

Solution

  • Suppose the border you want is represented by a path p, the idea is to create a path q such that when it is filled, the pixels drawn will be the same as if p were stroked with dashes. Then you can use q as the path of a shape layer.

    // suppose our border is a 150x150 square
    let p = CGPath(rect: .init(x: 0, y: 0, width: 150, height: 150), transform: nil)
    let q = dashStrokedPath(p)
    let shapeLayer = CAShapeLayer()
    shapeLayer.path = q
    shapeLayer.strokeColor = UIColor.black.cgColor
    shapeLayer.fillColor = UIColor.white.cgColor
    shapeLayer.lineWidth = 1
    

    The dashStrokedPath function converts p into q. This can be implemented using a combination of copy(dashingWithPhase:lengths:) and copy(strokingWithWidth:lineCap:lineJoin:miterLimit:):

    func dashStrokedPath(_ path: CGPath) -> CGPath {
        // adjust the parameters here as you wish...
        let strokedAndDashed = path.copy(dashingWithPhase: 5, lengths: [10, 20])
            .copy(strokingWithWidth: 5, lineCap: .round, lineJoin: .round, miterLimit: 10)
        return CGPath(rect: strokedAndDashed.boundingBox, transform: nil).intersection(
            strokedAndDashed
        )
    }
    

    Note that as the last step, I intersected the path with its own bounding box. This is to remove what looks like self-intersecting lines. Without the intersection, it looks like this:

    enter image description here

    With the intersection, it looks like this:

    enter image description here

    Before iOS 16, intersection is not available. You can instead hide the self-intersection by putting a masked layer on top of the layer with the stroke., though this will also hide half of the stroke width, i.e. the stroke will only be visible on the "outside" of the path.

    let p = CGPath(rect: .init(x: 20, y: 20, width: 150, height: 150), transform: nil)
    // dashStrokedPath will just return the path returned by the two 'copy' calls, without 'intersection'
    let q = dashStrokedPath(p)
    let shapeLayer = CAShapeLayer()
    shapeLayer.path = q
    shapeLayer.strokeColor = UIColor.black.cgColor
    shapeLayer.lineWidth = 2
    
    // 'v' is a UIView
    v.layer.addSublayer(shapeLayer)
    
    let layerMask = CAShapeLayer()
    layerMask.path = q
    
    let maskedLayer = CAShapeLayer()
    maskedLayer.path = CGPath(rect: q.boundingBox, transform: nil)
    maskedLayer.fillColor = UIColor.white.cgColor
    maskedLayer.mask = layerMask
    v.layer.addSublayer(maskedLayer)