I try to combine multiple layers using .round
lineCap
and a semicircle path:
let layer = CAShapeLayer()
layer.lineWidth = 12
layer.lineCap = .round
layer.strokeColor = color.withAlphaComponent(0.32).cgColor
layer.fillColor = UIColor.clear.cgColor
// angles calculation
let path = UIBezierPath(arcCenter: arcCenter,
radius: radius,
startAngle: startAngle,
endAngle: engAngle,
clockwise: true)
layer.path = path.cgPath
trying to achieve the following:
but my layers are overlapped by each other. Is it possible to fix it somehow fast or do I need to implement path calculating with rounded corners manually?
When .lineCap = .round
we get a "circle" centered at the endpoint with a radius of 1/2 the line width:
So, to get the circle-round end to sit at the endpoint, we can adjust the endpoint by asin(lineWidth * 0.5 / radius)
:
Assuming we're going in a clockwise direction:
let delta: CGFloat = lineCap == .round ? asin(lineWidth * 0.5 / radius) : 0.0
let path = UIBezierPath(arcCenter: center,
radius: radius,
startAngle: startDegrees.radians + delta,
endAngle: endDegrees.radians - delta,
clockwise: true)
So, if we have a series of degrees forming this image with .lineEnd = .butt
(the default):
we can get this by offsetting the start and end angles:
Here's a complete example class:
class ConnectedArcsView: UIView {
public var segmentDegrees: [CGFloat] = [] {
didSet {
var n: Int = 0
if let subs = layer.sublayers {
n = subs.count
if n > segmentDegrees.count {
// if we already have sublayers,
// remove any extras
for _ in 0..<n - segmentDegrees.count {
subs.last?.removeFromSuperlayer()
}
}
}
// add sublayers if needed
while n < segmentDegrees.count {
let l = CAShapeLayer()
l.fillColor = UIColor.clear.cgColor
layer.addSublayer(l)
n += 1
}
setNeedsLayout()
}
}
// segment colors default: [.red, .green, .blue]
public var segmentColors: [UIColor] = [.red, .green, .blue] { didSet { setNeedsLayout() } }
// line width default: 12
public var lineWidth: CGFloat = 12 { didSet { setNeedsLayout() } }
// line cap default: .round
public var lineCap: CAShapeLayerLineCap = .round { didSet { setNeedsLayout() } }
override func layoutSubviews() {
super.layoutSubviews()
guard let subs = layer.sublayers else { return }
let radius = (bounds.size.width - lineWidth) * 0.5
let center = CGPoint(x: bounds.midX, y: bounds.midY)
// if lineCap == .round
// calculate delta for start and end angles
let delta: CGFloat = lineCap == .round ? asin(lineWidth * 0.5 / radius) : 0.0
// calculate start angle so the "gap" is centered at the bottom
let totalDegrees: CGFloat = segmentDegrees.reduce(0.0, +)
var startDegrees: CGFloat = 90.0 + (360.0 - totalDegrees) * 0.5
for i in 0..<segmentDegrees.count {
let endDegrees = startDegrees + segmentDegrees[i]
guard let shape = subs[i] as? CAShapeLayer else { continue }
shape.lineWidth = lineWidth
shape.lineCap = lineCap
shape.strokeColor = segmentColors[i % segmentColors.count].cgColor
let path = UIBezierPath(arcCenter: center,
radius: radius,
startAngle: startDegrees.radians + delta,
endAngle: endDegrees.radians - delta,
clockwise: true)
shape.path = path.cgPath
startDegrees += segmentDegrees[i]
}
}
}
and an example view controller showing its usage:
class ExampleViewController: UIViewController {
let testView = ConnectedArcsView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBlue
let degrees: [CGFloat] = [
40, 25, 140, 25, 40
]
let colors: [UIColor] = [
.systemRed, .systemYellow, .systemGreen, .systemYellow, .systemRed
] //.map { ($0 as UIColor).withAlphaComponent(0.5) }
testView.segmentDegrees = degrees
testView.segmentColors = colors
testView.lineWidth = 12
testView.backgroundColor = .black
testView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(testView)
// add an info label
let v = UILabel()
v.textAlignment = .center
v.numberOfLines = 0
v.text = "Tap anywhere to toggle between\n\".round\" and \".butt\""
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
testView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
testView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
testView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
testView.heightAnchor.constraint(equalTo: testView.widthAnchor),
v.topAnchor.constraint(equalTo: testView.bottomAnchor, constant: 20.0),
v.widthAnchor.constraint(equalTo: testView.widthAnchor),
v.centerXAnchor.constraint(equalTo: testView.centerXAnchor),
])
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
testView.lineCap = testView.lineCap == .round ? .butt : .round
}
}
When running, tapping anywhere will toggle between .round
and .butt
.
Edit - forgot to include the helper extension:
extension CGFloat {
var degrees: CGFloat {
return self * CGFloat(180) / .pi
}
var radians: CGFloat {
return self * .pi / 180.0
}
}
Edit 2 - in response to comment on 11/8/2022...
This custom ConnectedArcsView
class is not yet finished. For example, it's easy to "exceed the bounds" as it were.
Suppose we use [120, 20, 120]
for our degrees, and we have a view width which gives us a circumference of 360-points
for our circle (approx 115 points wide).
With .lineCap = .butt
, the 20-degree arc-segment will be 20-points long. Because it's the "top" arc, it will start at 260-degrees and end at 280-degrees.
With .lineCap = .round
, one-half the .lineWidth
will be added onto each end of that arc-segment. Suppose the .lineWidth
is 8
... the arc-segment will now be 28-points long, which is no longer 20-degrees of the circle.
So, we adjust the endpoints. The actual arc-segment is now 12-points long, the rounded ends are 4-points each bringing the full length of the rounded segment to 20-points. So, still 20-degrees, and the calculated start/end degrees are 264 and 276.
Change the .lineWidth
to 12
, and we get arc-segment length: 8 + 6 on each end = 20-points, or 20-degrees, with calculated start/end degrees of 266 and 274.
So --- what happens if we set .lineWidth = 24
?
Our 20-degree / 20-point arc-segment must have 12-points on each end! So the actual arc-segment is now MINUS 4-points!
And, the calculated start/end degrees are 272 and 268 -- in other words, it will go almost all the way around the circle.
To put this class into a production environment, one would likely modify it to account for that, and probably throw an exception (which would need to be handled).
In addition, note that if we use these values for our degree-segments:
let degrees: [CGFloat] = [
120, 60, 100, 70, 90
]
we end up with a total of 440-degrees and the segments will overlap - which is almost certainly not desired.
So, the values would need to be "normalized," along with (probably) a "gap" value for the bottom.