如何解决iOS - 绘制带有渐变背景的视图
我附上了一个粗略的草图。 手动绘制草图时,线条会变形,只是为了说明概念。
如草图所示,我有一个必须在视图上自动绘制的点列表(它是不规则形状),顺时针方向有一些延迟(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: { $1.radius > $0.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: $0) }
path.close()
return path
}
这里的点数预计在 0.0 左右。它们是分布的,因此它们试图根据给定的框架填充最大空间,并且它们以它为中心。没什么特别的,只是基本的数学。
生成路径后,您可以使用形状层方法或绘制矩形方法。我将使用 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 { $0.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,endRadius: min(bounds.width,bounds.height),options: [])
context.restoreGState()
}
绘制径向渐变时,您需要使用路径对其进行剪辑。这是通过调用 path.addClip()
来完成的,它在您的路径上使用“填充”方法并将其应用于当前上下文。这意味着您在此调用之后绘制的所有内容都将被剪切到此路径,并且在该路径之外不会绘制任何内容。但是您确实想稍后在它之外绘制(该线确实如此)并且您需要重置剪辑。这是通过在调用 saveGState
和 restoreGState
的当前上下文中保存和恢复状态来完成的。这些调用是push-pop,所以每次“保存”都应该有一个“恢复”。你可以嵌套这个过程(因为它会在应用进度时完成)。
仅使用此代码,您应该已经能够绘制完整的形状(如 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,dy: lineWidth)) else { return }
if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}
drawGradient(path: path,context: context)
drawLine(path: path,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,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,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,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,dy: lineWidth)) else { return }
if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}
drawGradient(path: path,context: context)
drawLine(path: path,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,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(),1.0])!
context.drawRadialGradient(gradient,options: [])
context.restoreGState()
}
private func drawLine(path: UIBezierPath,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: { $1.radius > $0.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: $0) }
path.close()
return path
}
}
}
,
Matic 使用 drawRect 为您的问题写了一篇战争与和平长度的答案,以创建您想要的动画。虽然令人印象深刻,但我不推荐这种方法。当您实现 drawRect 时,您在单个内核上完成所有绘图,使用主线程上的主处理器。您根本没有利用 iOS 中的硬件加速渲染。
相反,我建议使用 Core Animation 和 CALayer。
在我看来,您的动画是一个经典的“时钟擦除”动画,您可以将图像以动画形式呈现在视野中,就像用时钟的秒针进行绘画一样。
我在几年前用 Objective-C 创建了这样一个动画,并且已经将它更新为 Swift。有关说明和 Github 项目的链接,请参阅 this link。效果如下:
然后您需要创建您想要的图像并将其安装为视图的内容,然后使用时钟擦除动画代码来显示它。我没有仔细看 Matic 的回答,但它似乎解释了如何绘制看起来像你的图像。
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。