平滑地动画 CAShapeLayer 路径
Animating CAShapeLayer path smoothly
我正在尝试制作一个 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)
}
这会在 playground 中生成以下内容,这与我的目标非常接近:
当尝试为仪表值的变化设置动画时出现问题。我可以通过修改 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 设置动画以获得更好的性能?任何帮助将不胜感激!
您不能使用 Bezier 路径 addArc()
函数为圆弧设置动画并更改圆弧距离。
问题是控制点。为了使动画顺利运行,开始和结束形状必须具有相同数量和类型的控制点。在幕后,UIBezierPath(和 CGPath)对象通过组合贝塞尔曲线(我不记得它使用二次贝塞尔曲线还是立方贝塞尔曲线。)创建近似圆的弧。整个圆由多条连接的贝塞尔曲线组成(“贝塞尔曲线“数学样条函数,而不是 UIBeizerPath,它是一个 UIKit 函数,它创建可以包含贝塞尔路径的形状。)我似乎记得一个圆的贝塞尔近似是由 4 条连接的三次贝塞尔曲线组成的。 (如果您有兴趣,请参阅 this SO answer 讨论它的外观。)
这是我对其工作原理的理解。 (我可能有错误的细节,但它在任何情况下都说明了问题。)当你从 <= 1/4 的完整圆移动到 > 1/4 的完整圆时,弧函数将使用第一个 1 立方贝塞尔曲线部分,然后是 2。在从 <= 1/2 圆到 > 1/2 圆的过渡处,它将移动到 3 条贝塞尔曲线,并且在从 <= 3/4 圆到 > 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 设置动画,如我的示例项目所示。我的时钟擦除动画显示图像就像时钟指针从图层中心扫过一样。在您的情况下,您会将圆弧居中放置在图层的底部中心,并且仅在形状图层中使用 half-circle 圆弧。以这种方式使用蒙版会给你一个 sharp-edged 裁剪到你的合成层。您会丢失红色弧形上的圆形端盖。要解决此问题,您必须将红色圆弧设置为动画,因为它是自己的形状层(使用 strokeEnd)并分别设置渐变填充的圆弧 strokeEnd 的动画。
我正在尝试制作一个 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)
}
这会在 playground 中生成以下内容,这与我的目标非常接近:
当尝试为仪表值的变化设置动画时出现问题。我可以通过修改 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 设置动画以获得更好的性能?任何帮助将不胜感激!
您不能使用 Bezier 路径 addArc()
函数为圆弧设置动画并更改圆弧距离。
问题是控制点。为了使动画顺利运行,开始和结束形状必须具有相同数量和类型的控制点。在幕后,UIBezierPath(和 CGPath)对象通过组合贝塞尔曲线(我不记得它使用二次贝塞尔曲线还是立方贝塞尔曲线。)创建近似圆的弧。整个圆由多条连接的贝塞尔曲线组成(“贝塞尔曲线“数学样条函数,而不是 UIBeizerPath,它是一个 UIKit 函数,它创建可以包含贝塞尔路径的形状。)我似乎记得一个圆的贝塞尔近似是由 4 条连接的三次贝塞尔曲线组成的。 (如果您有兴趣,请参阅 this SO answer 讨论它的外观。)
这是我对其工作原理的理解。 (我可能有错误的细节,但它在任何情况下都说明了问题。)当你从 <= 1/4 的完整圆移动到 > 1/4 的完整圆时,弧函数将使用第一个 1 立方贝塞尔曲线部分,然后是 2。在从 <= 1/2 圆到 > 1/2 圆的过渡处,它将移动到 3 条贝塞尔曲线,并且在从 <= 3/4 圆到 > 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 设置动画,如我的示例项目所示。我的时钟擦除动画显示图像就像时钟指针从图层中心扫过一样。在您的情况下,您会将圆弧居中放置在图层的底部中心,并且仅在形状图层中使用 half-circle 圆弧。以这种方式使用蒙版会给你一个 sharp-edged 裁剪到你的合成层。您会丢失红色弧形上的圆形端盖。要解决此问题,您必须将红色圆弧设置为动画,因为它是自己的形状层(使用 strokeEnd)并分别设置渐变填充的圆弧 strokeEnd 的动画。