iosswiftuiviewcashapelayer

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


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.


Solution

  • 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:

    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.