Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Drawing a CAShapeLayer using autolayout

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")
    }
}
like image 336
rantanplan Avatar asked Dec 19 '17 03:12

rantanplan


2 Answers

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.)

  5. 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.

  6. 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:

enter image description here

Or you can tweak the settings right in IB, and you'll see it take effect:

enter image description here

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.

like image 129
Rob Avatar answered Nov 08 '22 13:11

Rob


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.

like image 34
Andrea Avatar answered Nov 08 '22 13:11

Andrea