【问题标题】:Animating CAShapeLayer path smoothly平滑动画 CAShapeLayer 路径
【发布时间】:2020-08-23 00:03:31
【问题描述】:

我正在尝试制作 Gauge UIView 以尽可能接近地模仿以下图像

    func gradientBezierPath(percent: CGFloat) ->  UIBezierPath {
        // vary this to move the start of the arc
        let startAngle = CGFloat(180).toRadians()//-CGFloat.pi / 2  // This corresponds to 12 0'clock
        // vary this to vary the size of the segment, in per cent
        let proportion = CGFloat(50 * percent)
        let centre = CGPoint (x: self.frame.size.width / 2, y: self.frame.size.height / 2)
        let radius = self.frame.size.height/4//self.frame.size.width / (CGFloat(130).toRadians())
        let arc = CGFloat.pi * 2 * proportion / 100 // i.e. the proportion of a full circle

        // Start a mutable path
        let cPath = UIBezierPath()
        // Move to the centre
        cPath.move(to: centre)
        // Draw a line to the circumference
        cPath.addLine(to: CGPoint(x: centre.x + radius * cos(startAngle), y: centre.y + radius * sin(startAngle)))
        // NOW draw the arc
        cPath.addArc(withCenter: centre, radius: radius, startAngle: startAngle, endAngle: arc + startAngle, clockwise: true)
        // Line back to the centre, where we started (or the stroke doesn't work, though the fill does)
        cPath.addLine(to: CGPoint(x: centre.x, y: centre.y))
        
        return cPath
    }
    
    override func draw(_ rect: CGRect) {
        
//        let endAngle = percent == 1.0 ? 0 : (percent * 180) + 180
        
        path = UIBezierPath(arcCenter: CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2),
                            radius: self.frame.size.height/4,
                            startAngle: CGFloat(180).toRadians(),
                            endAngle: CGFloat(0).toRadians(),
                            clockwise: true)
        
        percentPath = UIBezierPath(arcCenter: CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2),
                                   radius: self.frame.size.height/4,
                                   startAngle: CGFloat(180).toRadians(),
                                   endAngle: CGFloat(0).toRadians(),
                                   clockwise: true)
        
        let shapeLayer = CAShapeLayer()
        shapeLayer.path = self.path.cgPath
        shapeLayer.strokeColor = UIColor(red: 110 / 255, green: 78 / 255, blue: 165 / 255, alpha: 1.0).cgColor
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.lineWidth = 5.0
        shapeLayer.lineCap = .round
        self.layer.addSublayer(shapeLayer)
        
        percentLayer.path = self.percentPath.cgPath
        percentLayer.strokeColor = UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 1.0).cgColor
        percentLayer.fillColor = UIColor.clear.cgColor
        percentLayer.lineWidth = 8.0
//        percentLayer.strokeEnd = CGFloat(percent)
        percentLayer.lineCap = .round
        self.layer.addSublayer(percentLayer)
        
        // n.b. as @MartinR points out `cPath.close()` does the same!

        // circle shape
        circleShape.path = gradientBezierPath(percent: 1.0).cgPath//cPath.cgPath
        circleShape.strokeColor = UIColor.clear.cgColor
        circleShape.fillColor = UIColor.green.cgColor
        self.layer.addSublayer(circleShape)
        
        gradient.frame = frame
        gradient.mask = circleShape
        gradient.type = .radial
        gradient.colors = [UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.0).cgColor,
                           UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.0).cgColor,
                           UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.4).cgColor]
        gradient.locations = [0, 0.35, 1]
        gradient.startPoint = CGPoint(x: 0.49, y: 0.55) // increase Y adds more orange from top to bottom
        gradient.endPoint = CGPoint(x: 0.98, y: 1) // increase x pushes orange out more to edges
        self.layer.addSublayer(gradient)

        //myTextLayer.string = "\(Int(percent * 100))"
        myTextLayer.backgroundColor = UIColor.clear.cgColor
        myTextLayer.foregroundColor = UIColor.white.cgColor
        myTextLayer.fontSize = 85.0
        myTextLayer.frame = CGRect(x: (self.frame.size.width / 2) - (self.frame.size.width/8), y: (self.frame.size.height / 2) - self.frame.size.height/8, width: 120, height: 120)
        self.layer.addSublayer(myTextLayer)
        
    }

这会在一个非常接近我的目标的操场上产生以下内容:

在尝试为仪表值的变化设置动画时出现问题。我可以通过修改strokeEnd 轻松地为percentLayer 设置动画,但是如果仪表的百分比值发生较大变化,则为渐变设置circleShape.path 的动画会导致一些不平滑的动画。这是我用来为两个图层设置动画的函数(现在每 2 秒在计时器上调用一次以模拟仪表值的变化)。

    func randomPercent() {
        let random = CGFloat.random(in: 0.0...1.0)
        
        // Animate the percent layer
        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = percentLayer.strokeEnd
        animation.toValue = random
        animation.duration = 1.5
        percentLayer.strokeEnd = random
        percentLayer.add(animation, forKey: nil)
        
        // Animate the gradient layer
        let newShapePath = gradientBezierPath(percent: random)
        let gradientAnimation = CABasicAnimation(keyPath: "path")
        gradientAnimation.duration = 1.5
        gradientAnimation.toValue = newShapePath
        gradientAnimation.fromValue = circleShape.path
        circleShape.path = newShapePath.cgPath
        self.circleShape.add(gradientAnimation, forKey: nil)
        
        myTextLayer.string = "\(Int(random * 100))"
    }

请注意,当动画完成时值的微小变化时,动画看起来不错。但是,当发生较大变化时,渐变动画看起来一点也不自然。关于如何改进这一点的任何想法?或者是否可以为不同的 keyPath 设置动画以获得更好的性能?任何帮助将不胜感激!

【问题讨论】:

  • 我喜欢你的仪表的外观。渐变填充看起来非常好。不确定我是否喜欢圆形端盖。它会导致渐变与圆形端盖的尖端不对齐,看起来有点奇怪。

标签: ios swift animation uibezierpath cabasicanimation


【解决方案1】:

您不能使用 Bezier 路径 addArc() 函数来为圆弧设置动画并更改圆弧距离。

问题是控制点。为了使动画顺利进行,开始和结束形状必须具有相同数量和类型的控制点。在幕后,UIBezierPath(和 CGPath)对象通过组合贝塞尔曲线创建近似圆的弧(我不记得它是使用二次贝塞尔曲线还是三次贝塞尔曲线。)整个圆由多条连接的贝塞尔曲线组成(“Bezier ” 数学样条函数,而不是 UIBeizerPath,它是一个 UIKit 函数,它创建可以包含贝塞尔路径的形状。)我似乎记得圆的贝塞尔近似是由 4 条链接的三次贝塞尔曲线组成的。 (如果您有兴趣,请参阅 this SO answer 以讨论其外观。)

这是我对其工作原理的理解。 (我的细节可能有误,但无论如何它说明了问题。)当您从 1/4 整圆时,弧函数将使用前 1 个三次贝塞尔曲线部分,然后是 2。在从 1/2 圆的过渡处,它将移动到 3 条贝塞尔曲线,并且在从 3 的过渡处/4 的圆,它会切换到 4 条贝塞尔曲线。

解决办法:

使用 strokeEnd,您走在了正确的轨道上。始终将您的形状创建为完整的圆圈,并将 strokeEnd 设置为小于 1 的值。这将为您提供一个圆圈的一部分,但您可以流畅地制作动画。 (您也可以为 strokeStart 设置动画。)

我已经使用 CAShapeLayer 和 strokeEnd 为您描述的圆圈设置动画(这是几年前的事了,所以它是在 Objective-C 中的。)我写了an article here on OS on using the approach to animate a mask on a UIImageView and create a "clock wipe" animation。如果您有完整阴影圆圈的图像,则可以在此处使用该精确方法。 (您应该能够将遮罩层添加到任何 UIView 的内容层或其他层,并像在我的时钟擦除演示中那样为该层设置动画。如果您在解读 Objective-C 时需要帮助,请告诉我。

这是我创建的示例时钟擦除动画:

请注意,您可以使用此效果来遮盖任何层,而不仅仅是图像视图。

编辑:我用 Swift 版本的项目发布了我的时钟擦除动画问题和答案的更新。

您可以通过https://github.com/DuncanMC/ClockWipeSwift 直接访问新的仓库。

对于您的应用程序,我会将您需要动画化的仪表部分设置为图层的组合。然后,您将基于 CAShapeLayer 的蒙版图层附加到该复合图层,并向该形状图层添加圆弧路径,并为 strokeEnd 设置动画,如我的示例项目所示。我的时钟擦除动画显示图像,就像从图层中心扫过时钟指针一样。在您的情况下,您会将弧线放在图层的底部中心,并且只在形状图层中使用半圆弧。以这种方式使用蒙版将为您的合成图层提供锐利的裁剪。你会失去红色弧上的圆形端盖。要解决这个问题,您必须将红色弧线设置为它自己的形状图层(使用 strokeEnd)并分别为渐变填充的弧线 strokeEnd 设置动画。

【讨论】:

  • 请注意,我刚刚在我的线程中发布了有关时钟擦除动画的更新,其中包括 Swift 版本的时钟擦除动画。您可以在这里找到该项目:github.com/DuncanMC/ClockWipeSwift
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-08-22
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多