iOS - 绘制带有渐变背景的视图

问题描述

我附上了一个粗略的草图。 手动绘制草图时,线条会变形,只是为了说明概念。

如草图所示,我有一个必须在视图上自动绘制的点列表(它是不规则形状),顺时针方向有一些延迟(0.1 秒)以直观地查看进度。

草图可以说明形状的 70% 大致完成度。

enter image description here

随着视图的绘制,我必须保持背景渐变。如草图所示,起点和当前点从未直接连接。颜色必须只在起点 -> 中心点 -> 当前点之间填充。

来到渐变部分,有两种颜色。绿松石色集中在中心,随着远离中心点,颜色逐渐变浅至白色。

我将如何在 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() 来完成的,它在您的路径上使用“填充”方法并将其应用于当前上下文。这意味着您在此调用之后绘制的所有内容都将被剪切到此路径,并且在该路径之外不会绘制任何内容。但是您确实想稍后在它之外绘制(该线确实如此)并且您需要重置剪辑。这是通过在调用 saveGStaterestoreGState 的当前上下文中保存和恢复状态来完成的。这些调用是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() } }

我使用进度作为 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,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。效果如下:

enter image description here

然后您需要创建您想要的图像并将其安装为视图的内容,然后使用时钟擦除动画代码来显示它。我没有仔细看 Matic 的回答,但它似乎解释了如何绘制看起来像你的图像。

相关问答

Selenium Web驱动程序和Java。元素在(x,y)点处不可单击。其...
Python-如何使用点“。” 访问字典成员?
Java 字符串是不可变的。到底是什么意思?
Java中的“ final”关键字如何工作?(我仍然可以修改对象。...
“loop:”在Java代码中。这是什么,为什么要编译?
java.lang.ClassNotFoundException:sun.jdbc.odbc.JdbcOdbc...