iOS - 绘制具有渐变背景的视图
iOS - Draw a view with gradient background
附上草图。 由于草图是手动绘制的,因此线条发生了变形,只是为了说明概念。
如草图所示,我有一个必须在视图上自动绘制的点列表(它是不规则的形状),顺时针方向有一些延迟(0.1 秒)以直观地查看进度。
草图将说明形状的大约完成度的 70%。
随着视图的绘制,我必须保持背景渐变。如草图所示,起点和当前点从不直接连接。颜色只能在起始点->中心点->当前点之间填充。
来到渐变部分,有两种颜色。绿松石色集中在中心,随着远离中心点颜色逐渐变浅至白色。
我如何在 iOS 中实现它?我能够在形状中绘制黑色线条,但是,我无法填充颜色。还有渐变,我完全不懂
首先需要生成路径。您可能已经有了这个,但您没有提供任何代码,尽管您提到“我能够在形状中绘制黑线”。所以从代码开始...
private func generatePath(withPoints points: [CGPoint], inFrame frame: CGRect) -> UIBezierPath? {
guard points.count > 2 else { return nil } // At least 3 points
let pointsInPolarCoordinates: [(angle: CGFloat, radius: CGFloat)] = points.map { point in
let radius = (point.x*point.x + point.y*point.y).squareRoot()
let angle = atan2(point.y, point.x)
return (angle, radius)
}
let maximumPointRadius: CGFloat = pointsInPolarCoordinates.max(by: { .radius > [=10=].radius })!.radius
guard maximumPointRadius > 0.0 else { return nil } // Not all points may be centered
let maximumFrameRadius = min(frame.width, frame.height)*0.5
let radiusScale = maximumFrameRadius/maximumPointRadius
let normalizedPoints: [CGPoint] = pointsInPolarCoordinates.map { polarPoint in
.init(x: frame.midX + cos(polarPoint.angle)*polarPoint.radius*radiusScale,
y: frame.midY + sin(polarPoint.angle)*polarPoint.radius*radiusScale)
}
let path = UIBezierPath()
path.move(to: normalizedPoints[0])
normalizedPoints[1...].forEach { path.addLine(to: [=10=]) }
path.close()
return path
}
这里的分数预计在 0.0 左右。它们是分布式的,因此它们会根据给定的框架尝试填充最大值 space 并且它们以它为中心。没什么特别的,只是基础数学。
生成路径后,您可以使用形状层方法或绘制矩形方法。我将使用 draw-rect:
您可以继承 UIView
并覆盖方法 func draw(_ rect: CGRect)
。只要视图需要显示,就会调用此方法,您永远不要直接调用此方法。因此,为了重绘视图,您只需在视图上调用 setNeedsDisplay
即可。从代码开始:
class GradientProgressView: UIView {
var points: [CGPoint]? { didSet { setNeedsDisplay() } }
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
}
没什么特别的。抓取上下文以绘制渐变和裁剪(稍后)。除此之外,路径是使用以前的方法创建的,然后传递给两个渲染方法。
从一行开始,事情变得非常简单:
private func drawLine(path: UIBezierPath, lineWidth: CGFloat, context: CGContext) {
UIColor.black.setStroke()
path.lineWidth = lineWidth
path.stroke()
}
很可能应该有一个 属性 颜色,但我只是硬编码了它。
至于渐变确实有点可怕:
private func drawGradient(path: UIBezierPath, context: CGContext) {
context.saveGState()
path.addClip() // This will be discarded once restoreGState() is called
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [UIColor.blue, UIColor.green].map { [=13=].cgColor } as CFArray, locations: [0.0, 1.0])!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: bounds.midX, y: bounds.midY), startRadius: 0.0, endCenter: CGPoint(x: bounds.midX, y: bounds.midY), endRadius: min(bounds.width, bounds.height), options: [])
context.restoreGState()
}
绘制径向渐变时,您需要将其与您的路径一起剪裁。这是通过调用 path.addClip()
来完成的,它在您的路径上使用“填充”方法并将其应用于当前上下文。这意味着您在此调用后绘制的所有内容都将被裁剪到此路径,并且不会绘制任何内容。但是你确实想稍后在它外面画画(线确实如此)并且你需要重置剪辑。这是通过保存和恢复当前上下文调用 saveGState
和 restoreGState
的状态来完成的。这些调用是弹出式的,因此对于每个“保存”都应该有一个“恢复”。您可以嵌套此过程(因为它会在应用进度时完成)。
仅使用此代码,您应该已经能够绘制完整的形状(如进度为 100%)。为了给出我的测试示例,我在这样的代码中全部使用它:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let progressView = GradientProgressView(frame: .init(x: 30.0, y: 30.0, width: 280.0, height: 350.0))
progressView.backgroundColor = UIColor.lightGray // Just to debug
progressView.points = {
let count = 200
let minimumRadius: CGFloat = 0.9
let maximumRadius: CGFloat = 1.1
return (0...count).map { index in
let progress: CGFloat = CGFloat(index) / CGFloat(count)
let angle = CGFloat.pi * 2.0 * progress
let radius = CGFloat.random(in: minimumRadius...maximumRadius)
return .init(x: cos(angle)*radius, y: sin(angle)*radius)
}
}()
view.addSubview(progressView)
}
}
现在添加进度只需要额外的剪辑。我们只想在特定角度内绘制。现在这应该是直截了当的:
另一个 属性 已添加到视图中:
var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }
我使用进度作为 0
和 1
之间的值,其中 0
是 0% 进度,1
是 100% 进度。
然后创建剪切路径:
private func createProgressClippingPath() -> UIBezierPath {
let endAngle = CGFloat.pi*2.0*progress
let maxRadius: CGFloat = max(bounds.width, bounds.height) // we simply need one that is large enough.
let path = UIBezierPath()
let center: CGPoint = .init(x: bounds.midX, y: bounds.midY)
path.move(to: center)
path.addArc(withCenter: center, radius: maxRadius, startAngle: 0.0, endAngle: endAngle, clockwise: true)
return path
}
这只是一条从中心开始的路径,并创建了一条从零角度到前进角度的弧。
现在应用这个额外的剪裁:
override func draw(_ rect: CGRect) {
super.draw(rect)
let actualProgress = max(0.0, min(progress, 1.0))
guard actualProgress > 0.0 else { return } // Nothing to draw
let willClipAsProgress = actualProgress < 1.0
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
if willClipAsProgress {
context.restoreGState()
}
}
我们真的只是想在进度未满时应用裁剪。我们希望在进度为零时丢弃所有绘图,因为所有内容都将被剪裁。
可以看到形状的起始角度是向右的,而不是向上的。让我们应用一些转换来解决这个问题:
progressView.transform = CGAffineTransform(rotationAngle: -.pi*0.5)
此时新视图可以自行绘制和重绘。您可以在故事板中自由使用它,您可以添加可检查项并根据需要使其可设计。至于动画,您现在只想为一个简单的浮点值设置动画并将其分配给进度。有很多方法可以做到这一点,我会做最懒惰的,也就是使用计时器:
@objc private func animateProgress() {
let duration: TimeInterval = 1.0
let startDate = Date()
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let progress = Date().timeIntervalSince(startDate)/duration
if progress >= 1.0 {
timer.invalidate()
}
self.progressView?.progress = max(0.0, min(CGFloat(progress), 1.0))
}
}
差不多就这些了。我曾经玩过的完整代码:
class ViewController: UIViewController {
private var progressView: GradientProgressView?
override func viewDidLoad() {
super.viewDidLoad()
let progressView = GradientProgressView(frame: .init(x: 30.0, y: 30.0, width: 280.0, height: 350.0))
progressView.backgroundColor = UIColor.lightGray // Just to debug
progressView.transform = CGAffineTransform(rotationAngle: -.pi*0.5)
progressView.points = {
let count = 200
let minimumRadius: CGFloat = 0.9
let maximumRadius: CGFloat = 1.1
return (0...count).map { index in
let progress: CGFloat = CGFloat(index) / CGFloat(count)
let angle = CGFloat.pi * 2.0 * progress
let radius = CGFloat.random(in: minimumRadius...maximumRadius)
return .init(x: cos(angle)*radius, y: sin(angle)*radius)
}
}()
view.addSubview(progressView)
self.progressView = progressView
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(animateProgress)))
}
@objc private func animateProgress() {
let duration: TimeInterval = 1.0
let startDate = Date()
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let progress = Date().timeIntervalSince(startDate)/duration
if progress >= 1.0 {
timer.invalidate()
}
self.progressView?.progress = max(0.0, min(CGFloat(progress), 1.0))
}
}
}
private extension ViewController {
class GradientProgressView: UIView {
var points: [CGPoint]? { didSet { setNeedsDisplay() } }
var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }
override func draw(_ rect: CGRect) {
super.draw(rect)
let actualProgress = max(0.0, min(progress, 1.0))
guard actualProgress > 0.0 else { return } // Nothing to draw
let willClipAsProgress = actualProgress < 1.0
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
if willClipAsProgress {
context.restoreGState()
}
}
private func createProgressClippingPath() -> UIBezierPath {
let endAngle = CGFloat.pi*2.0*progress
let maxRadius: CGFloat = max(bounds.width, bounds.height) // we simply need one that is large enough.
let path = UIBezierPath()
let center: CGPoint = .init(x: bounds.midX, y: bounds.midY)
path.move(to: center)
path.addArc(withCenter: center, radius: maxRadius, startAngle: 0.0, endAngle: endAngle, clockwise: true)
return path
}
private func drawGradient(path: UIBezierPath, context: CGContext) {
context.saveGState()
path.addClip() // This will be discarded once restoreGState() is called
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [UIColor.blue, UIColor.green].map { [=20=].cgColor } as CFArray, locations: [0.0, 1.0])!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: bounds.midX, y: bounds.midY), startRadius: 0.0, endCenter: CGPoint(x: bounds.midX, y: bounds.midY), endRadius: min(bounds.width, bounds.height), options: [])
context.restoreGState()
}
private func drawLine(path: UIBezierPath, lineWidth: CGFloat, context: CGContext) {
UIColor.black.setStroke()
path.lineWidth = lineWidth
path.stroke()
}
private func generatePath(withPoints points: [CGPoint], inFrame frame: CGRect) -> UIBezierPath? {
guard points.count > 2 else { return nil } // At least 3 points
let pointsInPolarCoordinates: [(angle: CGFloat, radius: CGFloat)] = points.map { point in
let radius = (point.x*point.x + point.y*point.y).squareRoot()
let angle = atan2(point.y, point.x)
return (angle, radius)
}
let maximumPointRadius: CGFloat = pointsInPolarCoordinates.max(by: { .radius > [=20=].radius })!.radius
guard maximumPointRadius > 0.0 else { return nil } // Not all points may be centered
let maximumFrameRadius = min(frame.width, frame.height)*0.5
let radiusScale = maximumFrameRadius/maximumPointRadius
let normalizedPoints: [CGPoint] = pointsInPolarCoordinates.map { polarPoint in
.init(x: frame.midX + cos(polarPoint.angle)*polarPoint.radius*radiusScale,
y: frame.midY + sin(polarPoint.angle)*polarPoint.radius*radiusScale)
}
let path = UIBezierPath()
path.move(to: normalizedPoints[0])
normalizedPoints[1...].forEach { path.addLine(to: [=20=]) }
path.close()
return path
}
}
}
Matic 使用 drawRect 创建您想要的动画,为您的问题写了一个 War 和 Peace 长度的答案。虽然令人印象深刻,但我不推荐这种方法。当你实现 drawRect 时,你在一个核心上完成所有的绘图,使用主线程上的主处理器。您根本没有利用 iOS 中的 hardware-accelerated 渲染。
相反,我建议使用 Core Animation 和 CALayers。
在我看来,您的动画是经典的“时钟擦除”动画,您在其中将图像设置为动画,就像您正在用时钟的扫秒针绘画一样。
几年前我在 Objective-C 中创建了这样一个动画,并已将其更新为 Swift。参见 this link 的描述和 link 到 Github 项目。效果是这样的:
然后您需要创建您想要的图像并将其安装为视图的内容,然后使用时钟擦除动画代码来显示它。我没有仔细查看 Matic 的回答,但它似乎解释了如何绘制看起来像您的图像。
附上草图。 由于草图是手动绘制的,因此线条发生了变形,只是为了说明概念。
如草图所示,我有一个必须在视图上自动绘制的点列表(它是不规则的形状),顺时针方向有一些延迟(0.1 秒)以直观地查看进度。
草图将说明形状的大约完成度的 70%。
随着视图的绘制,我必须保持背景渐变。如草图所示,起点和当前点从不直接连接。颜色只能在起始点->中心点->当前点之间填充。
来到渐变部分,有两种颜色。绿松石色集中在中心,随着远离中心点颜色逐渐变浅至白色。
我如何在 iOS 中实现它?我能够在形状中绘制黑色线条,但是,我无法填充颜色。还有渐变,我完全不懂
首先需要生成路径。您可能已经有了这个,但您没有提供任何代码,尽管您提到“我能够在形状中绘制黑线”。所以从代码开始...
private func generatePath(withPoints points: [CGPoint], inFrame frame: CGRect) -> UIBezierPath? {
guard points.count > 2 else { return nil } // At least 3 points
let pointsInPolarCoordinates: [(angle: CGFloat, radius: CGFloat)] = points.map { point in
let radius = (point.x*point.x + point.y*point.y).squareRoot()
let angle = atan2(point.y, point.x)
return (angle, radius)
}
let maximumPointRadius: CGFloat = pointsInPolarCoordinates.max(by: { .radius > [=10=].radius })!.radius
guard maximumPointRadius > 0.0 else { return nil } // Not all points may be centered
let maximumFrameRadius = min(frame.width, frame.height)*0.5
let radiusScale = maximumFrameRadius/maximumPointRadius
let normalizedPoints: [CGPoint] = pointsInPolarCoordinates.map { polarPoint in
.init(x: frame.midX + cos(polarPoint.angle)*polarPoint.radius*radiusScale,
y: frame.midY + sin(polarPoint.angle)*polarPoint.radius*radiusScale)
}
let path = UIBezierPath()
path.move(to: normalizedPoints[0])
normalizedPoints[1...].forEach { path.addLine(to: [=10=]) }
path.close()
return path
}
这里的分数预计在 0.0 左右。它们是分布式的,因此它们会根据给定的框架尝试填充最大值 space 并且它们以它为中心。没什么特别的,只是基础数学。
生成路径后,您可以使用形状层方法或绘制矩形方法。我将使用 draw-rect:
您可以继承 UIView
并覆盖方法 func draw(_ rect: CGRect)
。只要视图需要显示,就会调用此方法,您永远不要直接调用此方法。因此,为了重绘视图,您只需在视图上调用 setNeedsDisplay
即可。从代码开始:
class GradientProgressView: UIView {
var points: [CGPoint]? { didSet { setNeedsDisplay() } }
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
}
没什么特别的。抓取上下文以绘制渐变和裁剪(稍后)。除此之外,路径是使用以前的方法创建的,然后传递给两个渲染方法。
从一行开始,事情变得非常简单:
private func drawLine(path: UIBezierPath, lineWidth: CGFloat, context: CGContext) {
UIColor.black.setStroke()
path.lineWidth = lineWidth
path.stroke()
}
很可能应该有一个 属性 颜色,但我只是硬编码了它。
至于渐变确实有点可怕:
private func drawGradient(path: UIBezierPath, context: CGContext) {
context.saveGState()
path.addClip() // This will be discarded once restoreGState() is called
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [UIColor.blue, UIColor.green].map { [=13=].cgColor } as CFArray, locations: [0.0, 1.0])!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: bounds.midX, y: bounds.midY), startRadius: 0.0, endCenter: CGPoint(x: bounds.midX, y: bounds.midY), endRadius: min(bounds.width, bounds.height), options: [])
context.restoreGState()
}
绘制径向渐变时,您需要将其与您的路径一起剪裁。这是通过调用 path.addClip()
来完成的,它在您的路径上使用“填充”方法并将其应用于当前上下文。这意味着您在此调用后绘制的所有内容都将被裁剪到此路径,并且不会绘制任何内容。但是你确实想稍后在它外面画画(线确实如此)并且你需要重置剪辑。这是通过保存和恢复当前上下文调用 saveGState
和 restoreGState
的状态来完成的。这些调用是弹出式的,因此对于每个“保存”都应该有一个“恢复”。您可以嵌套此过程(因为它会在应用进度时完成)。
仅使用此代码,您应该已经能够绘制完整的形状(如进度为 100%)。为了给出我的测试示例,我在这样的代码中全部使用它:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let progressView = GradientProgressView(frame: .init(x: 30.0, y: 30.0, width: 280.0, height: 350.0))
progressView.backgroundColor = UIColor.lightGray // Just to debug
progressView.points = {
let count = 200
let minimumRadius: CGFloat = 0.9
let maximumRadius: CGFloat = 1.1
return (0...count).map { index in
let progress: CGFloat = CGFloat(index) / CGFloat(count)
let angle = CGFloat.pi * 2.0 * progress
let radius = CGFloat.random(in: minimumRadius...maximumRadius)
return .init(x: cos(angle)*radius, y: sin(angle)*radius)
}
}()
view.addSubview(progressView)
}
}
现在添加进度只需要额外的剪辑。我们只想在特定角度内绘制。现在这应该是直截了当的:
另一个 属性 已添加到视图中:
var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }
我使用进度作为 0
和 1
之间的值,其中 0
是 0% 进度,1
是 100% 进度。
然后创建剪切路径:
private func createProgressClippingPath() -> UIBezierPath {
let endAngle = CGFloat.pi*2.0*progress
let maxRadius: CGFloat = max(bounds.width, bounds.height) // we simply need one that is large enough.
let path = UIBezierPath()
let center: CGPoint = .init(x: bounds.midX, y: bounds.midY)
path.move(to: center)
path.addArc(withCenter: center, radius: maxRadius, startAngle: 0.0, endAngle: endAngle, clockwise: true)
return path
}
这只是一条从中心开始的路径,并创建了一条从零角度到前进角度的弧。
现在应用这个额外的剪裁:
override func draw(_ rect: CGRect) {
super.draw(rect)
let actualProgress = max(0.0, min(progress, 1.0))
guard actualProgress > 0.0 else { return } // Nothing to draw
let willClipAsProgress = actualProgress < 1.0
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
if willClipAsProgress {
context.restoreGState()
}
}
我们真的只是想在进度未满时应用裁剪。我们希望在进度为零时丢弃所有绘图,因为所有内容都将被剪裁。
可以看到形状的起始角度是向右的,而不是向上的。让我们应用一些转换来解决这个问题:
progressView.transform = CGAffineTransform(rotationAngle: -.pi*0.5)
此时新视图可以自行绘制和重绘。您可以在故事板中自由使用它,您可以添加可检查项并根据需要使其可设计。至于动画,您现在只想为一个简单的浮点值设置动画并将其分配给进度。有很多方法可以做到这一点,我会做最懒惰的,也就是使用计时器:
@objc private func animateProgress() {
let duration: TimeInterval = 1.0
let startDate = Date()
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let progress = Date().timeIntervalSince(startDate)/duration
if progress >= 1.0 {
timer.invalidate()
}
self.progressView?.progress = max(0.0, min(CGFloat(progress), 1.0))
}
}
差不多就这些了。我曾经玩过的完整代码:
class ViewController: UIViewController {
private var progressView: GradientProgressView?
override func viewDidLoad() {
super.viewDidLoad()
let progressView = GradientProgressView(frame: .init(x: 30.0, y: 30.0, width: 280.0, height: 350.0))
progressView.backgroundColor = UIColor.lightGray // Just to debug
progressView.transform = CGAffineTransform(rotationAngle: -.pi*0.5)
progressView.points = {
let count = 200
let minimumRadius: CGFloat = 0.9
let maximumRadius: CGFloat = 1.1
return (0...count).map { index in
let progress: CGFloat = CGFloat(index) / CGFloat(count)
let angle = CGFloat.pi * 2.0 * progress
let radius = CGFloat.random(in: minimumRadius...maximumRadius)
return .init(x: cos(angle)*radius, y: sin(angle)*radius)
}
}()
view.addSubview(progressView)
self.progressView = progressView
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(animateProgress)))
}
@objc private func animateProgress() {
let duration: TimeInterval = 1.0
let startDate = Date()
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let progress = Date().timeIntervalSince(startDate)/duration
if progress >= 1.0 {
timer.invalidate()
}
self.progressView?.progress = max(0.0, min(CGFloat(progress), 1.0))
}
}
}
private extension ViewController {
class GradientProgressView: UIView {
var points: [CGPoint]? { didSet { setNeedsDisplay() } }
var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }
override func draw(_ rect: CGRect) {
super.draw(rect)
let actualProgress = max(0.0, min(progress, 1.0))
guard actualProgress > 0.0 else { return } // Nothing to draw
let willClipAsProgress = actualProgress < 1.0
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
if willClipAsProgress {
context.restoreGState()
}
}
private func createProgressClippingPath() -> UIBezierPath {
let endAngle = CGFloat.pi*2.0*progress
let maxRadius: CGFloat = max(bounds.width, bounds.height) // we simply need one that is large enough.
let path = UIBezierPath()
let center: CGPoint = .init(x: bounds.midX, y: bounds.midY)
path.move(to: center)
path.addArc(withCenter: center, radius: maxRadius, startAngle: 0.0, endAngle: endAngle, clockwise: true)
return path
}
private func drawGradient(path: UIBezierPath, context: CGContext) {
context.saveGState()
path.addClip() // This will be discarded once restoreGState() is called
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [UIColor.blue, UIColor.green].map { [=20=].cgColor } as CFArray, locations: [0.0, 1.0])!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: bounds.midX, y: bounds.midY), startRadius: 0.0, endCenter: CGPoint(x: bounds.midX, y: bounds.midY), endRadius: min(bounds.width, bounds.height), options: [])
context.restoreGState()
}
private func drawLine(path: UIBezierPath, lineWidth: CGFloat, context: CGContext) {
UIColor.black.setStroke()
path.lineWidth = lineWidth
path.stroke()
}
private func generatePath(withPoints points: [CGPoint], inFrame frame: CGRect) -> UIBezierPath? {
guard points.count > 2 else { return nil } // At least 3 points
let pointsInPolarCoordinates: [(angle: CGFloat, radius: CGFloat)] = points.map { point in
let radius = (point.x*point.x + point.y*point.y).squareRoot()
let angle = atan2(point.y, point.x)
return (angle, radius)
}
let maximumPointRadius: CGFloat = pointsInPolarCoordinates.max(by: { .radius > [=20=].radius })!.radius
guard maximumPointRadius > 0.0 else { return nil } // Not all points may be centered
let maximumFrameRadius = min(frame.width, frame.height)*0.5
let radiusScale = maximumFrameRadius/maximumPointRadius
let normalizedPoints: [CGPoint] = pointsInPolarCoordinates.map { polarPoint in
.init(x: frame.midX + cos(polarPoint.angle)*polarPoint.radius*radiusScale,
y: frame.midY + sin(polarPoint.angle)*polarPoint.radius*radiusScale)
}
let path = UIBezierPath()
path.move(to: normalizedPoints[0])
normalizedPoints[1...].forEach { path.addLine(to: [=20=]) }
path.close()
return path
}
}
}
Matic 使用 drawRect 创建您想要的动画,为您的问题写了一个 War 和 Peace 长度的答案。虽然令人印象深刻,但我不推荐这种方法。当你实现 drawRect 时,你在一个核心上完成所有的绘图,使用主线程上的主处理器。您根本没有利用 iOS 中的 hardware-accelerated 渲染。
相反,我建议使用 Core Animation 和 CALayers。
在我看来,您的动画是经典的“时钟擦除”动画,您在其中将图像设置为动画,就像您正在用时钟的扫秒针绘画一样。
几年前我在 Objective-C 中创建了这样一个动画,并已将其更新为 Swift。参见 this link 的描述和 link 到 Github 项目。效果是这样的:
然后您需要创建您想要的图像并将其安装为视图的内容,然后使用时钟擦除动画代码来显示它。我没有仔细查看 Matic 的回答,但它似乎解释了如何绘制看起来像您的图像。