iOS:将圆形切片动画化为更宽的切片

iOS: Animating a circle slice into a wider one

Core-Animation 处理此图像中描述的角度: (图片来自http://btk.tillnagel.com/tutorials/rotation-translation-matrix.html

编辑:添加动画 gif 以更好地解释我的需要:

我需要设置切片动画以使其变宽,从 300:315 度开始,到 300:060 结束。

要创建每个切片,我正在使用此函数:

extension CGFloat {
    func toRadians() -> CGFloat {
        return self * CGFloat(Double.pi) / 180.0
    }
}
func createSlice(angle1:CGFloat, angle2:CGFloat) -> UIBezierPath! {
    let path: UIBezierPath = UIBezierPath()
    let width: CGFloat = self.frame.size.width/2
    let height: CGFloat = self.frame.size.height/2
    let centerToOrigin: CGFloat = sqrt((height)*(height)+(width)*(width));
    let ctr: CGPoint = CGPoint(x: width, y: height)
    path.move(to: ctr)
    path.addArc( withCenter: ctr,
                 radius: centerToOrigin,
                 startAngle: CGFloat(angle1).toRadians(),
                 endAngle: CGFloat(angle2).toRadians(),
                 clockwise: true
    )
    path.close()
    return path
}

我现在可以创建两个切片和一个较小的子层,但我找不到如何从这一点开始:

func doStuff() {
    path1 = self.createSlice(angle1: 300,angle2: 315)
    path2 = self.createSlice(angle1: 300,angle2: 60)

    let shapeLayer = CAShapeLayer()
    shapeLayer.path = path1.cgPath
    shapeLayer.fillColor = UIColor.cyan.cgColor
    self.layer.addSublayer(shapeLayer)

如有任何帮助,我将不胜感激!

所以我终于找到了解决办法。我花了一些时间才明白 确实没有办法为形状的填充 设置动画,但我们可以通过制作 笔画创建一个实心圆来欺骗 CA 引擎(即圆弧的边界)极宽,以至于填满了整个圆!

extension CGFloat {
    func toRadians() -> CGFloat {
        return self * CGFloat(Double.pi) / 180.0
    }
}


import UIKit

class SliceView: UIView {

    let circleLayer = CAShapeLayer()
    var fromAngle:CGFloat = 30
    var toAngle:CGFloat = 150
    var color:UIColor = UIColor.magenta

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    convenience init(frame:CGRect, fromAngle:CGFloat, toAngle:CGFloat, color:UIColor) {
        self.init(frame:frame)
        self.fromAngle = fromAngle
        self.toAngle = toAngle
        self.color = color
    }

    func setup() {
        circleLayer.strokeColor = color.cgColor
        circleLayer.fillColor = UIColor.clear.cgColor
        layer.addSublayer(circleLayer)
        layer.backgroundColor = UIColor.brown.cgColor
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        let startAngle:CGFloat = (fromAngle-90).toRadians()
        let endAngle:CGFloat = (toAngle-90).toRadians()
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let radius = min(bounds.width, bounds.height) / 4
        let path = UIBezierPath(arcCenter: CGPoint(x: 0,y :0), radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)

        circleLayer.position = center
        circleLayer.lineWidth = radius*2
        circleLayer.path = path.cgPath
    }

    public func animate() {
        let pathAnimation = CABasicAnimation(keyPath: "strokeEnd")
        pathAnimation.duration = 3.0;
        pathAnimation.fromValue = 0.0;
        pathAnimation.toValue = 1.0;
        circleLayer.add(pathAnimation, forKey: "strokeEndAnimation")
    }
}

所以,现在我们可以将它添加到我们的视图控制器和 运行 动画中。在我的例子中——我将它桥接到 Objecive-C,但你可以轻松地将它调整为 swift.

我简直不敢相信,在 2017 年仍然无法为这个简单的任务找到现成的解决方案。我花了 才完成。我真的希望它能帮助别人!

下面是我如何使用 class:

@implementation ViewController
{
    SliceView *sv_;
}

- (void)viewDidLoad {
    [super viewDidLoad];

    self.view.backgroundColor = UIColor.grayColor;
    CGFloat width = 240.0;
    CGFloat height = 160.0;

    CGRect r = CGRectMake(
                          self.view.frame.size.width/2 - width/2,
                          self.view.frame.size.height/2 - height/2,
                          width, height);
    sv_ = [[SliceView alloc] initWithFrame:r fromAngle:150 toAngle:30 color:[UIColor yellowColor] ];
    [self.view addSubview:sv_];
}

- (IBAction)pressedGo:(id)sender {
    [sv_ animate];
}

只有一种颜色

如果您想像您问题中的那样为纯色填充饼图段的角度设置动画,那么您可以通过为 CAShapeLayerstrokeEnd 设置动画来实现。

这里的"trick"就是做一个很宽的线。更具体地说,您可以在预期半径的一半处创建一条弧形路径(下面动画中的虚线),然后将其全半径作为线宽。当您动画化抚摸那条线时,它看起来像下面的橙色部分:

根据您的用例,您可以:

  • 创建一条从一个角度到另一个角度的路径,并使笔划末端从 0 变为 1
  • 为完整的圆创建路径,将笔划起点和笔划终点设置为圆的某个部分,并为笔划终点设置从起点到终点的动画。

如果您的绘图只是像这样的单一颜色,那么这将是解决您问题的最小方法。

但是,如果您的绘图更复杂(例如,还要绘制饼图部分),那么此解决方案根本行不通,您将不得不做一些更复杂的事情。


自定义绘图/自定义动画

如果您绘制的饼图更加复杂,那么您很快就会发现自己必须创建一个具有自定义动画属性的图层子类。这样做需要更多的代码——其中一些可能看起来有点不寻常1——但并不像听起来那么可怕。

  1. 这可能是在 Objective-C 中做起来更方便的事情之一。

动态属性

首先,使用您需要的属性创建一个图层子类。用 Objective-C 的说法,这些属性应该是 @dynamic,即不合成。这与 Swift 中的 dynamic 不同。相反,我们必须使用 @NSManaged.

class PieSegmentLayer : CALayer {
    @NSManaged var startAngle, endAngle, strokeWidth: CGFloat
    @NSManaged var fillColor, strokeColor: UIColor?

    // More to come here ...
}

这允许 Core Animation 动态处理这些属性,允许它跟踪变化并将它们集成到动画系统中。

注意一个好的经验法则是这些属性应该全部与绘图/视觉呈现相关层的。如果不是,那么很可能它们不属于该层。相反,它们可以添加到一个视图中,该视图又使用图层进行绘图。

正在复制图层

在自定义动画期间,Core Animation 将要为不同的帧创建和渲染不同的图层配置。与 Apple 的大多数其他框架不同,这是使用复制构造函数 init(layer:) 发生的。对于要复制的上述五个属性,我们需要覆盖 init(layer:) 并复制它们的值。

在 Swift 中,我们还必须覆盖普通的 init()init?(coder)

override init(layer: Any) {
    super.init(layer: layer)
    guard let other = layer as? PieSegmentLayer else { return }

    fillColor   = other.fillColor
    strokeColor = other.strokeColor
    startAngle  = other.startAngle
    endAngle    = other.endAngle
    strokeWidth = other.strokeWidth
}

override init() {
    super.init()
}

required init?(coder aDecoder: NSCoder) {
    return nil
}

对变化做出反应

核心动画在很多方面都是为了性能而构建的。它实现这一目标的方法之一是避免不必要的工作。默认情况下,图层不会在 属性 更改时自行重绘。但是这些属性是用于绘图的,我们希望图层在其中任何一个发生变化时重新绘制。为此,如果键是这些属性之一,我们需要覆盖 needsDisplay(forKey:) 和 return true

override class func needsDisplay(forKey key: String) -> Bool {
    switch key {
    case #keyPath(startAngle), #keyPath(endAngle),
         #keyPath(strokeWidth),
         #keyPath(fillColor), #keyPath(strokeColor):
        return true

    default:
        return super.needsDisplay(forKey: key)
    }
}

此外,如果我们希望图层为这些属性默认隐式动画,我们需要覆盖 action(forKey:) 到 return 部分配置的动画对象。如果我们只希望某些属性(例如角度)隐式设置动画,那么我们只需要 return 这些属性的动画。除非我们需要非常自定义的东西,否则最好只 return 一个基本动画,其中 fromValue 设置为当前表示值:

override func action(forKey key: String) -> CAAction? {
    switch key {
    case #keyPath(startAngle), #keyPath(endAngle):
        let anim = CABasicAnimation(keyPath: key)
        anim.fromValue = presentation()?.value(forKeyPath: key)
        return anim

    default:
        return super.action(forKey: key)
    }
}

绘图

自定义动画的最后一段是自定义绘图。这是通过覆盖 draw(in:) 并使用提供的上下文绘制图层来完成的:

override func draw(in ctx: CGContext) {
    let center = CGPoint(x: bounds.midX, y: bounds.midY)
    // subtract half the stroke width to avoid clipping the stroke
    let radius = min(center.x, center.y) - strokeWidth / 2

    // The two angle properties are in degrees but CG wants them in radians.
    let start = startAngle * .pi / 180
    let end   = endAngle   * .pi / 180

    ctx.beginPath()
    ctx.move(to: center)

    ctx.addLine(to: CGPoint(x: center.x + radius * cos(start),
                            y: center.y + radius * sin(start)))

    ctx.addArc(center: center, radius: radius,
               startAngle: start, endAngle: end,
               clockwise: start > end)

    ctx.closePath()

    // Configure the graphics context
    if let fillCGColor = fillColor?.cgColor {
        ctx.setFillColor(fillCGColor)
    }
    if let strokeCGColor = strokeColor?.cgColor {
        ctx.setStrokeColor(strokeCGColor)
    }
    ctx.setLineWidth(strokeWidth)

    ctx.setLineCap(.round)
    ctx.setLineJoin(.round)

    // Draw
    ctx.drawPath(using: .fillStroke)
}

在这里,我填充并描边了一个从图层中心延伸到最近边缘的饼图段。您应该将其替换为您的自定义绘图。

正在运行的自定义动画

有了所有这些代码,我们现在有了一个自定义图层子类,其属性可以隐式(仅通过更改它们)和显式(通过为其键添加 CAAnimation)进行动画处理。结果看起来像这样:

最后的话

这些动画的帧速率可能并不明显,但在这两种解决方案中(以不同方式)利用 Core Animation 的一个强大好处是它将单个状态的绘制与动画的时间分离.

这意味着该层不知道并且不必知道持续时间、延迟、时序曲线等。这些都可以在外部配置和控制.

我为 David 的 class 添加了一个小改进。 (大卫 - 欢迎您复制到您的书籍质量答案中!)

您可以添加以下初始化函数:

convenience init(frame:CGRect, startAngle:CGFloat, endAngle:CGFloat, fillColor:UIColor,
                 strokeColor:UIColor, strokeWidth:CGFloat) {
    self.init()
    self.frame = frame
    self.startAngle = startAngle
    self.endAngle = endAngle
    self.fillColor = fillColor
    self.strokeColor = strokeColor
    self.strokeWidth = strokeWidth
}

然后这样称呼它(Objective-C 在我的例子中):

PieSegmentLayer *sliceLayer = [[PieSegmentLayer alloc] initWithFrame:r startAngle:30 endAngle:180 fillColor:[UIColor cyanColor] strokeColor:[UIColor redColor] strokeWidth:4];
[self.view.layer addSublayer:sliceLayer];