How can I apply a shadow to each slice while maintaining the current animation? I have tried putting it on the layer itself, but the shadow cannot be seen. I also tried creating another layer with a clear background, but with a clear background, the shadow does not work.
override func draw(_ rect: CGRect) {
super.draw(rect)
layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
let totalValue = data.reduce(0.0) { $0 + $1.value }
let center = CGPoint(x: rect.midX, y: rect.midY)
var startAngle: CGFloat = -CGFloat.pi / 2
var cumulativeDelay: CFTimeInterval = 0
gapSizeDegrees = data.count == 1 ? 0 : gapSizeDegrees
data.forEach { value in
let segmentValue = CGFloat(value.value)
let color = value.color
let endAngle = startAngle + (segmentValue / CGFloat(totalValue)) * 2 * .pi
let outerRadius = rect.width / 2
let outerStartAngle = startAngle + gapSizeDegrees.toRadians()
let outerEndAngle = endAngle - gapSizeDegrees.toRadians()
let path = UIBezierPath(arcCenter: center, radius: outerRadius, startAngle: outerStartAngle, endAngle: outerEndAngle, clockwise: true)
let innerRadius = outerRadius - strokeWidth
path.addArc(withCenter: center, radius: innerRadius, startAngle: outerEndAngle - gapSizeDegrees / rect.width, endAngle: outerStartAngle + gapSizeDegrees / rect.width , clockwise: false)
path.close()
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.fillColor = color.cgColor
shapeLayer.strokeColor = color.cgColor
layer.addSublayer(shapeLayer)
animateSliceFilling(sliceLayer: shapeLayer, center: center, radius: outerRadius, innerRadius: innerRadius, startAngle: startAngle, endAngle: endAngle, delay: cumulativeDelay)
cumulativeDelay += Consts.Appearence.animationDuration / Double(data.count)
startAngle = endAngle
}
}
private func animateSliceFilling(sliceLayer: CAShapeLayer, center: CGPoint, radius: CGFloat, innerRadius: CGFloat, startAngle: CGFloat, endAngle: CGFloat, delay: CFTimeInterval) {
let maskLayer = CAShapeLayer()
let maskPath = UIBezierPath(arcCenter: center, radius: (radius + innerRadius) / 2, startAngle: startAngle, endAngle: endAngle, clockwise: true)
maskLayer.path = maskPath.cgPath
maskLayer.fillColor = UIColor.clear.cgColor
maskLayer.strokeColor = UIColor.black.cgColor
maskLayer.lineWidth = strokeWidth
maskLayer.strokeEnd = 0.0
sliceLayer.mask = maskLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.toValue = 1
animation.duration = Consts.Appearence.animationDuration / Double(data.count)
animation.beginTime = CACurrentMediaTime() + delay
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
maskLayer.add(animation, forKey: "strokeEndAnimation")
}
A few observations:
I would put the shadow on its own layer (in case the shadow is greater than the gap angle; you want to make sure that one layer’s shadow does not overlay another layer’s data).
I would create shape layers for the shadow layer; and each of the data layers.
I would then animate the mask on the whole parent layer.
We should not add layers or perform animation from draw(rect:)
. (This is used when rending your own animation and is for rendering a single frame; but we are relying on CoreGraphics to do the animation for us, so draw(rect:)
is simply not the right place to do this animation.
Using layoutSubviews
would be more logical.
As an aside, whenever you do implement draw(rect:)
(which we will not do here), never assume that rect
represents the full view. It represents what portion of the view that is being drawn. Use bounds
, instead.
Personally, I would calculate the delta for each arc, and then inset the start and end angles for each by one half of the gap radius.
I would be inclined to render each arc as a single arc (not an inner in one direction and an outer arc in the other) unless you absolutely needed that behavior (e.g., you wanted to do some corner rounding of the arcs, themselves). It is not critical to this question, but simplifies the code a bit.
I would calculate the radius from the minimum of the width and height.
That yields:
class AnimatedDataView: UIView {
let strokeWidth: CGFloat = 30
var gapSizeDegrees: CGFloat = 5
let shadowRadius: CGFloat = 5
var data: [Value] = [
Value(value: 0.25, color: .red),
Value(value: 0.33, color: .green),
Value(value: 1 - 0.25 - 0.33, color: .blue)
]
override func layoutSubviews() {
super.layoutSubviews()
start()
}
func start() {
let rect = bounds
layer.mask = nil
layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
let totalValue = data.reduce(0) { $0 + $1.value }
let center = CGPoint(x: rect.midX, y: rect.midY)
var angle: CGFloat = -.pi / 2
var cumulativeDelay: CFTimeInterval = 0
gapSizeDegrees = data.count == 1 ? 0 : gapSizeDegrees
let radius = min(rect.width, rect.height) / 2 - strokeWidth / 2
let shadowPath = UIBezierPath()
let shadowLayer = CAShapeLayer()
shadowLayer.fillColor = UIColor.clear.cgColor
shadowLayer.strokeColor = UIColor.black.cgColor
shadowLayer.lineWidth = strokeWidth
shadowLayer.shadowOpacity = 1
shadowLayer.shadowRadius = shadowRadius
layer.addSublayer(shadowLayer)
data.forEach { value in
let segmentValue = CGFloat(value.value)
let color = value.color
let delta = (segmentValue / CGFloat(totalValue)) * 2 * .pi
let endAngle = angle + delta - gapSizeDegrees.toRadians() / 2
let startAngle = angle + gapSizeDegrees.toRadians() / 2
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
shadowPath.append(path)
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = color.cgColor
shapeLayer.lineWidth = strokeWidth
layer.addSublayer(shapeLayer)
cumulativeDelay += Consts.Appearence.animationDuration / Double(data.count)
angle += delta
}
shadowLayer.path = shadowPath.cgPath
let maskLayer = CAShapeLayer()
let maskPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: 3 * .pi / 2, clockwise: true)
maskLayer.path = maskPath.cgPath
maskLayer.fillColor = UIColor.clear.cgColor
maskLayer.strokeColor = UIColor.black.cgColor
maskLayer.lineWidth = strokeWidth + shadowRadius
layer.mask = maskLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.toValue = 1
animation.duration = Consts.Appearence.animationDuration / Double(data.count)
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
maskLayer.add(animation, forKey: "strokeEndAnimation")
}
}
If you want the gaps between the arcs to be parallel and you want the corners rounded, you could do something like:
class AnimatedDataView: UIView {
let arcWidth: CGFloat = 150
let cornerRadius: CGFloat = 20
var gapSize: CGFloat = 8
let shadowRadius: CGFloat = 4
var data: [Value] = [
Value(value: 0.25, color: .red),
Value(value: 0.33, color: .green),
Value(value: 1 - 0.25 - 0.33, color: .blue)
]
override func layoutSubviews() {
super.layoutSubviews()
start()
}
func start() {
layer.mask = nil
layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
let totalValue = data.reduce(0) { $0 + $1.value }
let center = CGPoint(x: bounds.midX, y: bounds.midY)
var angle: CGFloat = -.pi / 2
let gapSize = data.count == 1 ? 0 : self.gapSize
let radius = (min(bounds.width, bounds.height) - arcWidth) / 2
let shadowPath = UIBezierPath()
let shadowLayer = CAShapeLayer()
shadowLayer.fillColor = UIColor.black.cgColor
shadowLayer.strokeColor = UIColor.black.cgColor
shadowLayer.lineWidth = cornerRadius * 2
shadowLayer.lineJoin = .round
shadowLayer.shadowOpacity = 1
shadowLayer.shadowRadius = shadowRadius
shadowLayer.shadowOffset = .zero
layer.addSublayer(shadowLayer)
data.forEach { value in
let segmentValue = CGFloat(value.value)
let color = value.color
let delta = (segmentValue / CGFloat(totalValue)) * 2 * .pi
let endAngle = angle + delta
let path = UIBezierPath(center: center, startAngle: angle, endAngle: endAngle, radius: radius, arcWidth: arcWidth, spacing: gapSize, cornerRadius: cornerRadius)
shadowPath.append(path)
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.fillColor = color.cgColor
shapeLayer.strokeColor = color.cgColor
shapeLayer.lineWidth = cornerRadius * 2
shapeLayer.lineJoin = .round
layer.addSublayer(shapeLayer)
angle = endAngle
}
shadowLayer.path = shadowPath.cgPath
let maskStrokeWidth = sqrt(bounds.width * bounds.width + bounds.height * bounds.height)
let maskLayer = CAShapeLayer()
maskLayer.path = UIBezierPath(arcCenter: center, radius: maskStrokeWidth / 2, startAngle: -.pi / 2, endAngle: 3 * .pi / 2, clockwise: true).cgPath
maskLayer.fillColor = UIColor.clear.cgColor
maskLayer.strokeColor = UIColor.black.cgColor
maskLayer.lineWidth = maskStrokeWidth
layer.mask = maskLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.toValue = 1
animation.duration = Consts.Appearance.animationDuration
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
maskLayer.add(animation, forKey: "strokeEndAnimation")
}
}
See first bullet point under Set specific value to round corners of UIBezierPath for a visualization of how I am rendering the rounded corners.
Anyway, that uses this UIBezierPath
extension:
extension UIBezierPath {
/// Create arc path with corner rounding and spacing between arcs.
///
/// Note, this path assumes that the “corner radius” will be achieved by setting the `lineWidth` to
/// two times the “corner radius”. We're setting that lineWidth here, but if using in a `CAShapeLayer`
/// you will need to explicitly set that `lineWidth` for that shape layer, too.
///
/// - Parameters:
/// - center: Center of the arc.
/// - startAngle: The start angle of the arc. Note, this will be inset by 1/2 of the `spacing` value.
/// - endAngle: The end angle of the arc. Note, this will be inset by 1/2 of the `spacing` value.
/// - clockwise: Should this be rendered clockwise or counter clockwise. Defaults to `true`.
/// - radius: The radius of the arc (the midpoint between the outer edge of the arc and the inner edge).
/// - arcWidth: The width of this arc.
/// - spacing: The spacing between this arc and the next. This arc is inset by half of this spacing at the start and end of the arc. Defaults to zero.
/// - cornerRadius: The corner radius of this thick arc. Defaults to zero.
convenience init(
center: CGPoint,
startAngle: CGFloat,
endAngle: CGFloat,
clockwise: Bool = true,
radius: CGFloat,
arcWidth: CGFloat,
spacing: CGFloat = 0,
cornerRadius: CGFloat = 0
) {
let innerRadius = radius - arcWidth / 2 + cornerRadius
let innerAngularDelta = asin((cornerRadius + spacing / 2) / innerRadius) * (clockwise ? 1 : -1)
let outerRadius = radius + arcWidth / 2 - cornerRadius
let outerAngularDelta = asin((cornerRadius + spacing / 2) / outerRadius) * (clockwise ? 1 : -1)
self.init(arcCenter: center, radius: innerRadius, startAngle: startAngle + innerAngularDelta, endAngle: endAngle - innerAngularDelta, clockwise: clockwise)
addArc(withCenter: center, radius: outerRadius, startAngle: endAngle - outerAngularDelta, endAngle: startAngle + outerAngularDelta, clockwise: !clockwise)
lineWidth = cornerRadius * 2
close()
}
}
That yields:
(Note, I exaggerated the width of these arcs so that the parallel spacing can clearly be seen.)