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() 来完成的,它在您的路径上使用“填充”方法并将其应用于当前上下文。这意味着您在此调用后绘制的所有内容都将被裁剪到此路径,并且不会绘制任何内容。但是你确实想稍后在它外面画画(线确实如此)并且你需要重置剪辑。这是通过保存和恢复当前上下文调用 saveGStaterestoreGState 的状态来完成的。这些调用是弹出式的,因此对于每个“保存”都应该有一个“恢复”。您可以嵌套此过程(因为它会在应用进度时完成)。

仅使用此代码,您应该已经能够绘制完整的形状(如进度为 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() } }

我使用进度作为 01 之间的值,其中 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 的回答,但它似乎解释了如何绘制看起来像您的图像。