I am trying to draw and animate a circular button using CAShapeLayer but just the drawing gives me a lot of headache - I can't seem to figure out how to pass data into my class.
This is my setup: - a class of type UIView which will draw the CAShapeLayer - the view is rendered in my view controller and built using auto layout constraints
I have tried using layoutIfNeeded but seem to be passing the data too late for the view to be drawn. I have also tried redrawing the view in vieWillLayoutSubviews() but nothing. Example code below. What am I doing wrong?
Am I passing the data too early/too late? Am I drawing the bezierPath too late?
I'd highly appreciate pointers.
And maybe a second follow up question: is there a simpler way to draw a circular path that is bound to it's views size?
In my View Controller:
import UIKit
class ViewController: UIViewController {
let buttonView: CircleButton = {
let view = CircleButton()
view.backgroundColor = .black
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
override func viewWillLayoutSubviews() {
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(buttonView)
buttonView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
buttonView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
buttonView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.75).isActive = true
buttonView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.25).isActive = true
buttonView.layoutIfNeeded()
buttonView.arcCenter = buttonView.center
buttonView.radius = buttonView.frame.width/2
}
override func viewDidAppear(_ animated: Bool) {
print(buttonView.arcCenter)
print(buttonView.radius)
}
}
And the class for the buttonView:
class CircleButton: UIView {
//Casting outer circular layers
let trackLayer = CAShapeLayer()
var arcCenter = CGPoint()
var radius = CGFloat()
//UIView Init
override init(frame: CGRect) {
super.init(frame: frame)
}
//UIView post init
override func layoutSubviews() {
super.layoutSubviews()
print("StudyButtonView arcCenter \(arcCenter)")
print("StudyButtonView radius \(radius)")
layer.addSublayer(trackLayer)
let outerCircularPath = UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
trackLayer.path = outerCircularPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor
trackLayer.lineWidth = 5
trackLayer.strokeStart = 0
trackLayer.strokeEnd = 1
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.transform = CATransform3DMakeRotation(-CGFloat.pi/2, 0, 0, 1)
}
//Required for subclass
required init?(coder aDecoder: NSCoder) {
fatalError("has not been implemented")
}
}
There really isn't any correlation between auto-layout and the proper implementation of your CircleButton
class. Your CircleButton
class doesn't know or care whether it's being configured via auto-layout or whether it has some fixed size.
Your auto-layout code looks OK (other than points 5 and 6 below). Most of the issues in your code snippet rest in your CircleButton
class. A couple of observations:
If you're going to rotate the shape layer, you have to set its frame
, too, otherwise the size is .zero
and it's going to end up rotating it about the origin
of the view (and rotate outside of the bounds
of the view, especially problematic if you're clipping subviews). Make sure to set the frame
of the CAShapeLayer
to be the bounds
of the view before trying to rotate it. Frankly, I'd remove the transform
, but given that you're playing around with strokeStart
and strokeEnd
, I'm guessing you may want to change these values later and have it start at 12 o'clock, in which case the transform makes sense.
Bottom line, if rotating, set the frame
first. If not, setting the layer's frame
is optional.
If you're going to change the properties of the view in order to update the shape layer, you'll want to make sure that the didSet
observers do the appropriate updating of the shape layer (or call setNeedsLayout
). You don't want your view controller from having to mess around with the internals of the shape layer, but you also want to make sure that these changes do get reflected in the shape layer.
It's a minor observation, but I'd suggest adding the shape layer during init
and only configuring and adding it to the view hierarchy once. This is more efficient. So, have the various init
methods call your own configure
method. Then, do size-related stuff (like updating the path) in layoutSubviews
. Finally, have properties observers that update the shape layer directly. This division of labor is more efficient.
If you want, you can make this @IBDesignable
and put it in its own target in your project. Then you can add it right in IB and see what it will look like. You can also make all the various properties @IBInspectable
, and you'll be able to set them right in IB, too. You then don't have to do anything in the code of your view controller if you don't want to. (But if you want to, feel free.)
A minor issue, but when you add your view programmatically, you don't need to call buttonView.layoutIfNeeded()
. You only need to do that if you're animating constraints, which you're not doing here. Once you add the constraints (and fix the above issues), the button will be laid out correctly, with no explicit layoutIfNeeded
required.
Your view controller has a line of code that says:
buttonView.arcCenter = buttonView.center
That is conflating arcCenter
(which is a coordinate within the buttonView
's coordinate space) and buttonView.center
(which is the coordinate for the button's center within the view controller's root view's coordinate space). One has nothing to do with the other. Personally, I'd get rid of this manual setting of arcCenter
, and instead have layoutSubviews
in ButtonView
take care of this dynamically, using bounds.midX
and bounds.midY
.
Pulling that all together, you get something like:
@IBDesignable
class CircleButton: UIView {
private let trackLayer = CAShapeLayer()
@IBInspectable var lineWidth: CGFloat = 5 { didSet { updatePath() } }
@IBInspectable var fillColor: UIColor = .clear { didSet { trackLayer.fillColor = fillColor.cgColor } }
@IBInspectable var strokeColor: UIColor = .lightGray { didSet { trackLayer.strokeColor = strokeColor.cgColor } }
@IBInspectable var strokeStart: CGFloat = 0 { didSet { trackLayer.strokeStart = strokeStart } }
@IBInspectable var strokeEnd: CGFloat = 1 { didSet { trackLayer.strokeEnd = strokeEnd } }
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configure()
}
private func configure() {
trackLayer.fillColor = fillColor.cgColor
trackLayer.strokeColor = strokeColor.cgColor
trackLayer.strokeStart = strokeStart
trackLayer.strokeEnd = strokeEnd
layer.addSublayer(trackLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
updatePath()
}
private func updatePath() {
let arcCenter = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = (min(bounds.width, bounds.height) - lineWidth) / 2
trackLayer.lineWidth = lineWidth
trackLayer.path = UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true).cgPath
// There's no need to rotate it if you're drawing a complete circle.
// But if you're going to transform, set the `frame`, too.
trackLayer.transform = CATransform3DIdentity
trackLayer.frame = bounds
trackLayer.transform = CATransform3DMakeRotation(-.pi / 2, 0, 0, 1)
}
}
That yields:
Or you can tweak the settings right in IB, and you'll see it take effect:
And having made sure that all of the didSet
observers for the properties of ButtonView
either update the path or directly update some shape layer, the view controller can now update these properties and they'll automatically be rendered in the ButtonView
.
The main issue that I see in your code is that you are adding the layer inside -layoutSubviews
, this method is called multiple times during a view lifecycle.
If you don't want to make the view hosted layer a CAShapeLayer
by using the layerClass
property, you need to override the 2 init methods (frame and coder) and call a commonInit where you instantiate and add your CAShape layer as a sublayer.
In -layoutSubviews
just set the frame property of it and the path according to the new view size.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With