Search code examples
iosswiftcore-graphicsuibezierpath

Rendering text in the middle of a Dashed Line created with UIBezierPath in Swift


I’m doing some tests with rendering dashed / dotted lines like so:

enter image description here

For the dotted lines, as an example; I created them with this function:

func drawDottedLines() -> UIImage { let path = UIBezierPath() path.move(to: CGPoint(x: 10,y: 10)) path.addLine(to: CGPoint(x: 290,y: 10)) path.lineWidth = 8

let dots: [CGFloat] = [
    0.01, // dot
    path.lineWidth * 2, // gap
]

path.setLineDash(dots, count: dots.count, phase: 0)
path.lineCapStyle = CGLineCap.round

UIGraphicsBeginImageContextWithOptions(CGSize(width:300, height:20), false, 2)
UIColor.white.setFill()
UIGraphicsGetCurrentContext()!.fill(.infinite)
UIColor.black.setStroke()
path.stroke()

let image = UIGraphicsGetImageFromCurrentImageContext() ?? nil
UIGraphicsEndImageContext()
return image!

}

What I need to figure out how to do next, render some text in the middle of the lines, so they render like:

 * * * * TEST * * * *

How would I do that?


Solution

  • Not sure why you're generating a UIImage of the dashed-line, as you could easily use a CAShapeLayer ... but anyway ...

    The most straight-forward way to do this would be to overlay a label - slightly wider than the text:

    enter image description here

    and give it the same background color:

    enter image description here

    However, as you can see, depending on where your dots/dashes fall, you can get sharp edges.

    If that's not acceptable, we can create a UIView subclass to draw the dashed-line-pattern on the left-side, then mirror it horizontally and draw it on the right side.

    Here's a quick example to get you started:

    class DashedLineLabelView: UIView {
        
        public var text: String = "" {
            didSet {
                label.text = text
                setNeedsLayout()
            }
        }
        public var pattern: [NSNumber] = [] {
            didSet {
                setNeedsLayout()
            }
        }
        
        private let label = UILabel()
    
        private let leftDashLayer = CAShapeLayer()
        private let rightDashLayer = CAShapeLayer()
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            layer.addSublayer(leftDashLayer)
            leftDashLayer.lineWidth = 8.0
            leftDashLayer.strokeColor = UIColor.black.cgColor
            leftDashLayer.lineCap = .round
    
            layer.addSublayer(rightDashLayer)
            rightDashLayer.lineWidth = 8.0
            rightDashLayer.strokeColor = UIColor.black.cgColor
            rightDashLayer.lineCap = .round
            
            label.translatesAutoresizingMaskIntoConstraints = false
            addSubview(label)
            NSLayoutConstraint.activate([
                label.centerXAnchor.constraint(equalTo: centerXAnchor),
                label.centerYAnchor.constraint(equalTo: centerYAnchor),
            ])
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
    
            // we'll add 8-points "space" on each side of the label/text
            let paddedW: CGFloat = label.frame.width + 16.0
            
            // width of lines
            //  we need them inset by one-half the lineWidth
            //  so they don't extend outside the bounds of the view
            let pathW: CGFloat = ((bounds.width - 8.0) - paddedW) * 0.5
            
            let bez = UIBezierPath()
            var pt: CGPoint = CGPoint(x: 4.0, y: bounds.midY)
            bez.move(to: pt)
            pt.x = pathW
            bez.addLine(to: pt)
            
            // same path and dash pattern for left and right dash layers
            leftDashLayer.path = bez.cgPath
            leftDashLayer.lineDashPattern = pattern
            rightDashLayer.path = bez.cgPath
            rightDashLayer.lineDashPattern = pattern
            
            // we need to flip the right-side dash line horizontally
            //  so it is a mirror of the left-side
            //  and move it to the right side
            let tr1 = CATransform3DMakeScale(-1.0, 1.0, 0.0)
            let tr2 = CATransform3DMakeTranslation(pathW * 2.0 + paddedW + 8.0, 0.0, 0.0)
            rightDashLayer.transform = CATransform3DConcat(tr1, tr2)
    
        }
    }
    

    and an example controller:

    class SomeTestVC: UIViewController {
    
        let testViewA = DashedLineLabelView()
        let testViewB = DashedLineLabelView()
    
        let sampleStrings: [String] = [
            "Sample Text",
            "ABC",
            "Testing",
            "A Longer String",
        ]
        var strIDX: Int = -1
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // add two Dashed Line Views
            
            testViewA.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testViewA)
            testViewB.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testViewB)
    
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                testViewA.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                testViewA.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                testViewA.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                testViewA.heightAnchor.constraint(equalToConstant: 60.0),
    
                testViewB.topAnchor.constraint(equalTo: testViewA.bottomAnchor, constant: 20.0),
                testViewB.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                testViewB.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                testViewB.heightAnchor.constraint(equalToConstant: 60.0),
            ])
            
            testViewA.backgroundColor = .systemYellow
            testViewB.backgroundColor = .systemYellow
    
            testViewA.pattern = [1, 16]
            testViewB.pattern = [1, 16, 16, 16]
    
            // set initial text
            changeText()
        }
        
        func changeText() {
            strIDX += 1
            testViewA.text = sampleStrings[strIDX % sampleStrings.count]
            testViewB.text = sampleStrings[strIDX % sampleStrings.count]
        }
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            changeText()
        }
        
    }
    

    It will look like this:

    enter image description here

    and each tap anywhere will cycle through a few sample strings:

    enter image description here

    enter image description here

    enter image description here