How to add a labels as markings in a custom view with CAShapeLayer

148 Views Asked by At

I have a custom UIView class which I use to draw an arc. What I want to achieve is to add labels around this arc as gauge reading. Here is the output I am trying to achieve

enter image description here

I am able to draw the arc but I am not sure how can I add labels to it. This is the code I am using to create the arc.

Custom UIView class

open class Gauge: UIView {
  var gaugeLayer: CALayer!
  var ringLayer: CAShapeLayer!

  private let uLabel = UILabel()

  func updateLayerProperties() {
      backgroundColor = UIColor.clear
      
      if (ringLayer != nil) {
          ringLayer.strokeEnd = 0.75
      }
  }

  required public init?(coder aDecoder: NSCoder) {
      super.init(coder: aDecoder)
      updateLayerProperties()
      addLabel()
  }

  public override init(frame: CGRect) {
      super.init(frame: frame)
      updateLayerProperties()
      addLabel()
  }

  open override func draw(_ rect: CGRect) {
      super.draw(rect)
      updateLayerProperties()
  }

  func resetLayers() {
      layer.sublayers = nil
      ringLayer = nil
  }

  open override func layoutSubviews() {
      resetLayers()
      gaugeLayer = getCircleGauge(rotateAngle)
      layer.addSublayer(gaugeLayer)
      updateLayerProperties()
  }

  func addLabel() {
      uLabel.font = //font
      uLabel.textColor = //color
      addSubview(uLabel)
  }
}

Code for arc

func getCircleGauge() -> CAShapeLayer {
  let gaugeLayer = CAShapeLayer()
  if ringLayer == nil {
      ringLayer = CAShapeLayer.getOval(lineWidth, strokeStart: 0, strokeEnd: 0.75, strokeColor: UIColor.clear, fillColor: UIColor.clear, shadowRadius: shadowRadius, shadowOpacity: shadowOpacity, shadowOffsset: CGSize.zero, bounds: bounds)
      ringLayer.frame = layer.bounds
      gaugeLayer.addSublayer(ringLayer)
  }
  gaugeLayer.frame = layer.bounds
  gaugeLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
  gaugeLayer.transform = CATransform3DRotate(gaugeLayer.transform, CGFloat(rotateAngle * 2 - pi_2 * 5 / 2), 0, 0, 1)
  ringLayer.lineCap = .round
  return gaugeLayer
}

When I tried adding UILabel in the custom view in its init the app crashed

view.superview is nil during traversal after it has appeared in superview subviews

I am not sure what is the issue? Do I add UILabels or is there any other way to do it.

1

There are 1 best solutions below

0
DonMag On BEST ANSWER

There are a number of different ways to do this... you could add UILabels, or you could use CATextLayer... depending on what else you might want to do with this could affect how you want to go about it.

First, your code is doing a few things it shouldn't do, so let's start with cleaning that up a bit.

Instead of using an oval with .strokeEnd: 0.75, let's use an arc that is 75% of a full circle (which is 270º), with the "gap" at the bottom. 0º (zero) is at "3 o'clock", so we define a clockwise arc from 135º (90º + 45º) to 405º (135º + 270º):

enter image description here

This will also come in handy later when positioning the labels.

Most people find it easier to think in terms of degrees rather than .pi, and "circle math" uses radians, we'll use this common extension to convert between them:

extension FloatingPoint {
    var degreesToRadians: Self { self * .pi / 180 }
    var radiansToDegrees: Self { self * 180 / .pi }
}

So our starting point is to write a "basic" UIView subclass:

class BasicGuage: UIView {

    private let guageLayer: CAShapeLayer = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder aDecoder: NSCoder) {
        super.init(coder:aDecoder)
        commonInit()
    }
    private func commonInit() {
        
        guageLayer.fillColor = UIColor.clear.cgColor
        guageLayer.strokeColor = UIColor.systemBlue.cgColor
        guageLayer.lineCap = .round
        guageLayer.lineWidth = 16.0

        layer.addSublayer(guageLayer)
        
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // radius is 1/2 of the width
        let radius: CGFloat = bounds.width * 0.5
        
        // center point
        let cntr: CGPoint = .init(x: bounds.midX, y: bounds.midY)
        
        let bez = UIBezierPath()
        
        // clockwise arc leaving a 25% (90º) "gap" at the bottom
        //  start at 135º (90.0º + 45.0º)
        let startAngle: Double = 135.0
        //  end at 3/4 of a full circle
        let endAngle: Double = startAngle + 270.0
        
        bez.addArc(withCenter: cntr, radius: radius, startAngle: startAngle.degreesToRadians, endAngle: endAngle.degreesToRadians, clockwise: true)
        
        // set the guage path
        guageLayer.path = bez.cgPath
    }
    
}

and a simple controller to show it:

class BasicGuageTestVC: UIViewController {
    
    let guage = BasicGuage()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        guage.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(guage)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            guage.widthAnchor.constraint(equalToConstant: 300.0),
            guage.heightAnchor.constraint(equalTo: guage.widthAnchor),
            guage.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            guage.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])

        guage.backgroundColor = .yellow
    }
    
}

That gives us this output:

enter image description here

You'll notice the arc extends outside the bounds of the view... and, we'll be adding labels around the outside, so we'll need to make the radius smaller than one-half of the view width. But we'll get to that shortly.

The next step is to add labels. We'll start with just one.

Very little difference from BasicGuage class:

class BasicGuageWithLabel: UIView {
    
    private let guageLayer: CAShapeLayer = CAShapeLayer()
    
    private let label: UILabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder aDecoder: NSCoder) {
        super.init(coder:aDecoder)
        commonInit()
    }
    private func commonInit() {
        
        guageLayer.fillColor = UIColor.clear.cgColor
        guageLayer.strokeColor = UIColor.systemBlue.cgColor
        guageLayer.lineCap = .round
        guageLayer.lineWidth = 16.0
        
        layer.addSublayer(guageLayer)
        
        // add the label
        label.text = "Test"
        addSubview(label)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // radius is 1/2 of the width
        let radius: CGFloat = bounds.width * 0.5
        
        // center point
        let cntr: CGPoint = .init(x: bounds.midX, y: bounds.midY)
        
        let bez = UIBezierPath()
        
        // clockwise arc leaving a 25% (90º) "gap" at the bottom
        //  start at 135º (90.0º + 45.0º)
        let startAngle: Double = 135.0
        //  end at 3/4 of a full circle
        let endAngle: Double = startAngle + 270.0
        
        bez.addArc(withCenter: cntr, radius: radius, startAngle: startAngle.degreesToRadians, endAngle: endAngle.degreesToRadians, clockwise: true)
        
        // set the guage path
        guageLayer.path = bez.cgPath
        
        // position the label
        label.sizeToFit()
        label.center = cntr
    }
    
}

We've added a UILabel and centered it in the view, so (using that same view controller class), it now looks like this:

enter image description here

We will, of course, need more than one label, so we'll eventually use an array of [UILabel].

To position the labels around the outside of the arc, we can define (but not draw) a circle with a larger radius, and then use a common extension for finding the point on the circle:

extension CGPoint {
    static func pointOnCircle(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
        let x = center.x + radius * cos(angle)
        let y = center.y + radius * sin(angle)
        return CGPoint(x: x, y: y)
    }
}

The process will be:

  • add a label with the specified 0-100 value
  • calculate the degree increment based on that value's percentage of the total "range"
  • find the point on the "label circle" using that degree
  • center the label at that point

Here's the idea:

enter image description here

We're adding two values - 20 and 30. The degree-range is 270º.

20 is 20% of 100, so we use the starting angle of 135 plus 20% of 270 (54):

135 + 54 == 189

30 is 30% of 100, so we use the starting angle of 135 plus 30% of 270 (81):

135 + 81 == 216

so the "20" label goes on the dashed-circle radius at 189º and the "30" label goes at 216º.

Here's an example Gauge view class that has the sizing and label placement:

// get the point on a circle at specific radian
extension CGPoint {
    static func pointOnCircle(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
        let x = center.x + radius * cos(angle)
        let y = center.y + radius * sin(angle)
        return CGPoint(x: x, y: y)
    }
}

extension FloatingPoint {
    var degreesToRadians: Self { self * .pi / 180 }
    var radiansToDegrees: Self { self * 180 / .pi }
}

class Guage: UIView {
    
    // when the values are set, we need to
    //  - create any new labels needed
    //  - update the text and size of the labels
    //  - hide any extra labels
    public var values: [Int] = [] {
        didSet {
            for i in 0..<values.count {
                if i <= theLabels.count {
                    let v = UILabel()
                    v.font = labelFont
                    v.textColor = .red
                    addSubview(v)
                    theLabels.append(v)
                }
                theLabels[i].text = "\(values[i])"
                theLabels[i].sizeToFit()
                theLabels[i].isHidden = false
            }
            for i in values.count..<theLabels.count {
                theLabels[i].isHidden = true
            }
            setNeedsLayout()
        }
    }
    
    // you may end up making these public var preoperties,
    //  so you can change the values at run-time
    private let guageColor: UIColor = .systemBlue
    private let guageLineWidth: CGFloat = 16.0
    private let labelFont: UIFont = .systemFont(ofSize: 20.0, weight: .regular)

    // this will be set in commonInit()
    private var labelInset: CGFloat = 0

    // array to manage the labels
    private var theLabels: [UILabel] = []
    
    private let guageLayer: CAShapeLayer = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder aDecoder: NSCoder) {
        super.init(coder:aDecoder)
        commonInit()
    }
    private func commonInit() {
        
        guageLayer.fillColor = UIColor.clear.cgColor
        guageLayer.strokeColor = guageColor.cgColor
        guageLayer.lineCap = .round
        guageLayer.lineWidth = guageLineWidth
        
        layer.addSublayer(guageLayer)
        
        // we'll use this to determine how much to inset the elements
        //  so they don't extend outside the view bounds
        let sizingLabel = UILabel()
        sizingLabel.font = labelFont
        sizingLabel.text = "000"
        sizingLabel.sizeToFit()
        labelInset = sizingLabel.frame.width * 0.5
        
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // so the labels will fit inside the view bounds
        let labelRect: CGRect = bounds.insetBy(dx: labelInset, dy: labelInset)
        
        // arc needs to be smaller than the bounds,
        //  and small enough so the labels are on the outside
        let arcRect: CGRect = bounds.insetBy(dx: labelInset * 2.0 + guageLineWidth * 0.5, dy: labelInset * 2.0 + guageLineWidth * 0.5)
        
        // center point
        var cntr: CGPoint = .init(x: arcRect.midX, y: arcRect.midY)
        
        let bez = UIBezierPath()

        // clockwise arc leaving a 25% (90º) "gap" at the bottom
        //  start at 135º (90.0º + 45.0º)
        var startAngle: Double = 135.0
        //  end at 3/4 of a full circle
        var endAngle: Double = startAngle + 270.0

        bez.addArc(withCenter: cntr, radius: arcRect.width * 0.5, startAngle: startAngle.degreesToRadians, endAngle: endAngle.degreesToRadians, clockwise: true)

        // shift it down, so its centered vertically
        bez.apply(CGAffineTransform(translationX: 0.0, y: (arcRect.height - bez.bounds.height) * 0.5))
        // set the guage path
        guageLayer.path = bez.cgPath
        
        // we shifted the arc down, so we need to shift
        //  the center point down so the labels also shift down
        cntr.y += (arcRect.height - bez.bounds.height) * 0.5
        
        // radius for the label positioning circle
        let labelRadius: CGFloat = labelRect.width * 0.5
        
        // we want the first (Zero) and last (100) labels to be a bit higher
        //  than the bottom arc ends
        // so let's add 10º to the start angle
        //  and subtract 10º from the end angle
        let angleOffset: Double = 10.0
        startAngle += angleOffset
        endAngle -= angleOffset
        
        // get the total range of degrees
        let angleRange: Double = endAngle - startAngle
        
        // position the labels on the "outside" circle,
        //  based on their values as a percentage of the max number of degrees
        for (value, label) in zip(values, theLabels) {
            let pct: Double = Double(value) / 100.0
            let thisAngle: Double = startAngle + (angleRange * pct)
            let pt: CGPoint = CGPoint.pointOnCircle(center: cntr, radius: labelRadius, angle: thisAngle.degreesToRadians)
            label.center = pt
        }
        
    }
    
}

class BasicGuage: UIView {

    private let guageLayer: CAShapeLayer = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder aDecoder: NSCoder) {
        super.init(coder:aDecoder)
        commonInit()
    }
    private func commonInit() {
        
        guageLayer.fillColor = UIColor.clear.cgColor
        guageLayer.strokeColor = UIColor.systemBlue.cgColor
        guageLayer.lineCap = .round
        guageLayer.lineWidth = 16.0

        layer.addSublayer(guageLayer)
        
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // radius is 1/2 of the width
        let radius: CGFloat = bounds.width * 0.5
        
        // center point
        let cntr: CGPoint = .init(x: bounds.midX, y: bounds.midY)
        
        let bez = UIBezierPath()
        
        // clockwise arc leaving a 25% (90º) "gap" at the bottom
        //  start at 135º (90.0º + 45.0º)
        let startAngle: Double = 135.0
        //  end at 3/4 of a full circle
        let endAngle: Double = startAngle + 270.0
        
        bez.addArc(withCenter: cntr, radius: radius, startAngle: startAngle.degreesToRadians, endAngle: endAngle.degreesToRadians, clockwise: true)
        
        // set the guage path
        guageLayer.path = bez.cgPath
    }
    
}

class BasicGuageWithLabel: UIView {
    
    private let guageLayer: CAShapeLayer = CAShapeLayer()
    
    private let label: UILabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder aDecoder: NSCoder) {
        super.init(coder:aDecoder)
        commonInit()
    }
    private func commonInit() {
        
        guageLayer.fillColor = UIColor.clear.cgColor
        guageLayer.strokeColor = UIColor.systemBlue.cgColor
        guageLayer.lineCap = .round
        guageLayer.lineWidth = 16.0
        
        layer.addSublayer(guageLayer)
        
        // add the label
        label.text = "Test"
        addSubview(label)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // radius is 1/2 of the width
        let radius: CGFloat = bounds.width * 0.5
        
        // center point
        let cntr: CGPoint = .init(x: bounds.midX, y: bounds.midY)
        
        let bez = UIBezierPath()
        
        // clockwise arc leaving a 25% (90º) "gap" at the bottom
        //  start at 135º (90.0º + 45.0º)
        let startAngle: Double = 135.0
        //  end at 3/4 of a full circle
        let endAngle: Double = startAngle + 270.0
        
        bez.addArc(withCenter: cntr, radius: radius, startAngle: startAngle.degreesToRadians, endAngle: endAngle.degreesToRadians, clockwise: true)
        
        // set the guage path
        guageLayer.path = bez.cgPath
        
        // position the label
        label.sizeToFit()
        label.center = cntr
    }
    
}

class zzzGuage: UIView {
    
    private var guageLineWidth: CGFloat = 16.0
    
    private let labelFont: UIFont = .systemFont(ofSize: 20.0, weight: .regular)
    private var labelInset: CGFloat = 0

    // this allows us to use the "base" layer as a shape layer
    //  instead of adding a sublayer
    lazy var guageLayer: CAShapeLayer = self.layer as! CAShapeLayer
    override class var layerClass: AnyClass {
        return CAShapeLayer.self
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder aDecoder: NSCoder) {
        super.init(coder:aDecoder)
        commonInit()
    }
    
    private func commonInit() {
        
        guageLayer.fillColor = UIColor.clear.cgColor
        guageLayer.strokeColor = UIColor.systemBlue.cgColor
        guageLayer.lineCap = .round
        guageLayer.lineWidth = guageLineWidth
        
        // we'll use this to determine how much to inset the elements
        //  so they don't extend outside the view bounds
        let sizingLabel = UILabel()
        sizingLabel.font = labelFont
        sizingLabel.text = "000"
        sizingLabel.sizeToFit()
        labelInset = sizingLabel.frame.width * 0.5
        
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // arc needs to be smaller than the bounds,
        //  and small enough so the labels are on the outside
        let arcRect: CGRect = bounds.insetBy(dx: labelInset * 2.0 + guageLineWidth * 0.5, dy: labelInset * 2.0 + guageLineWidth * 0.5)

        // center point
        var cntr: CGPoint = .init(x: arcRect.midX, y: arcRect.midY)
        
        let bez = UIBezierPath()
        // clockwise arc leaving a 25% "gap" at the bottom
        bez.addArc(withCenter: cntr, radius: arcRect.width * 0.5, startAngle: .pi * 0.75, endAngle: .pi * 0.25, clockwise: true)
        // set the guage path
        guageLayer.path = bez.cgPath
    }
    
}


class BasicGuageTestVC: UIViewController {
    
    //let guage = BasicGuage()
    let guage = BasicGuageWithLabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        guage.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(guage)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            guage.widthAnchor.constraint(equalToConstant: 300.0),
            guage.heightAnchor.constraint(equalTo: guage.widthAnchor),
            guage.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            guage.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])

        guage.backgroundColor = .yellow
    }
    
}

and an example view controller class:

class GuageTestVC: UIViewController {
    
    let guage = Guage()
    
    var testValues: [[Int]] = [
        [0, 10, 50, 90, 100],
        [0, 20, 40, 60, 80, 100],
        [0, 10, 20, 30, 40, 100],
        [0, 25, 50, 75, 100],
        [0, 15, 30, 50, 70, 85, 100],
    ]
    
    let infoLabel: UILabel = {
        let v = UILabel()
        v.font = .monospacedSystemFont(ofSize: 14.0, weight: .light)
        v.numberOfLines = 0
        v.backgroundColor = .cyan
        return v
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        guage.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(guage)
        
        infoLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(infoLabel)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            
            guage.widthAnchor.constraint(equalToConstant: 300.0),
            guage.heightAnchor.constraint(equalTo: guage.widthAnchor),
            guage.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            guage.centerYAnchor.constraint(equalTo: g.centerYAnchor),
            
            infoLabel.topAnchor.constraint(equalTo: guage.bottomAnchor, constant: 20.0),
            infoLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            infoLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            
        ])
        
        guage.backgroundColor = .yellow
        
        nextTestValues()
    }
    
    func nextTestValues() {

        var s: String = "\n"
        testValues.forEach { vals in
            if vals == testValues.first {
                s += "   --> "
            } else {
                s += "       "
            }
            s += "\(vals)\n"
        }
        infoLabel.text = s

        let theseValues = testValues.removeFirst()
        testValues.append(theseValues)
        guage.values = theseValues
        
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        nextTestValues()
    }
    
}

Tapping anywhere cycles through some sample value sets:

enter image description here

enter image description here

enter image description here

and so on.

Note: This is Sample Code Only ... and all examples assume a square (1:1 ratio) view.