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
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.
There are a number of different ways to do this... you could add UILabel
s, 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º):
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:
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:
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:
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:
and so on.
Note: This is Sample Code Only ... and all examples assume a square (1:1
ratio) view.