使用自动布局绘制 CAShapeLayer

5
我正在尝试使用CAShapeLayer绘制和动画一个圆形按钮,但仅仅是绘图就让我很头疼——我似乎无法弄清楚如何将数据传递给我的类。
这是我的设置: - 一个UIView类型的类,它将绘制CAShapeLayer - 视图在我的视图控制器中呈现,并使用自动布局约束构建
我已经尝试使用layoutIfNeeded,但好像把数据传得太晚了,以至于视图无法被绘制。我还尝试在vieWillLayoutSubviews()中重新绘制视图,但是没有效果。下面是示例代码。我错在哪里了?
我是不是把数据传得太早或太晚了? 我是不是在太晚的时候绘制bezierPath?
我非常感谢指导。
也许还有一个后续问题:是否有更简单的方法来绘制一个与其视图大小绑定的圆形路径?
在我的视图控制器中:
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)
    }
}

按钮视图的类为:

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")
    }
}
2个回答

7

自动布局和正确实现你的CircleButton类之间并没有任何关联。你的CircleButton类不知道也不关心它是通过自动布局配置还是具有固定大小。

你的自动布局代码看起来还不错(除了下面的5和6点)。你的代码片段中大部分问题都在你的CircleButton类中。以下是一些观察结果:

  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.

将所有内容综合起来,您会得到类似以下的东西:
@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)
    }
}

这将产生以下结果:
您也可以在IB中调整设置,然后您会看到它生效:
并且确保ButtonView的所有属性的didSet观察器都更新路径或直接更新某些形状层,视图控制器现在可以更新这些属性,它们将自动呈现在ButtonView中。

哇,非常感谢。这正是我想要实现的,但一直缺乏正确的方法。我刚刚运行了您的代码(因为我正在以编程方式完成所有操作,所以删除了IB部分),它完美地工作!bounds.midX 是我不知道能够获取的东西之一 - 非常有帮助!两个后续问题:1)为什么您在 required init 中第二次调用 configure()?2)我不确定我是否完全理解了 didSet 观察器 - 它到底是做什么的?您提到的另一种选择是要求 layoutIfNeeded - 您会如何做到这一点?非常感谢!! - rantanplan
哦,如果你感兴趣的话:我旋转圆形路径的原因是为了让它从12点钟开始动画,而不是默认的3点钟位置。 - rantanplan
@moopoints - “为什么在required init期间你又调用了configure()?”...我没有第二次调用它。当通过storyboards或状态恢复创建视图时,会调用init(coder:)。当您以编程方式创建它(或在IB中进行实时查看)时,会调用init(frame:)。这是两种完全不同的视图创建方式,我们希望无论您使用哪种方式都能正常工作。 - Rob
@moopoints - “我不确定我完全理解了didSet观察器 - 它到底是做什么的?”...请参阅The Swift Programming Language: Property Observers。总之,它是一段代码,每当您更改(但不是在首次初始化时)属性时都会运行。您正在“观察”视图控制器或其他内容何时更改属性。 - Rob
@moopoints - "而且你提到的另一种方法是要求 layoutIfNeeded - 你会怎样做?"...我不确定你指的是什么。我只在定义了约束的视图上动画时使用 layoutIfNeeded。但这里你没有这样做,所以我们不需要它。我谈论的唯一事情就是在 didSet 观察器中可选地调用 setNeedsLayout。例如,在我的 lineWidth 观察器中,我可以调用 setNeedsLayout,这将导致调用 layoutSubviews。在这种情况下,调用 updatePath 更清晰,但它是一个替代方案。 - Rob
抱歉回复晚了,Rob。再次感谢您的澄清,非常有帮助! - rantanplan

1
我在你的代码中看到的主要问题是你将图层添加到了-layoutSubviews中,而这个方法在视图生命周期中会被多次调用。
如果你不想使用layerClass属性使视图托管层成为一个CAShapeLayer,你需要重写2个初始化方法(frame和coder)并调用commonInit,在其中实例化和添加CAShape层作为子层。
-layoutSubviews中只需设置它的frame属性和路径,以根据新的视图大小进行更新。

layoutSubviews中重新添加图层从技术上讲并不是问题,但它会带来微不足道的低效,并表明对该过程的误解。因此,我同意您的建议,但这并不是OP问题的根源。 - Rob
谢谢,Andrea!我不确定我完全理解你的建议。你在 VC 和类中会处理什么? - rantanplan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接