贝塞尔曲线是指可以通过一些控制点去控制曲线的形状并且保持曲线的平滑特性,不会让人感觉到突兀。在iOS开发中,贝塞尔曲线的使用主要通过UIKit中的UIBezierPath类,这个类可以使用贝塞尔曲线和直线实现一些复杂的图形结构。本文会介绍贝塞尔曲线的原理和使用UIBezierPath绘制贝塞尔曲线,文末会给两个使用UIBezierPath实现的Demo。
贝塞尔曲线简介
历史沿革 贝塞尔曲线(Bézier curve)又称曲线或贝济埃曲线,是计算机图形学中相当重要的参数曲线,它包含了大量的数学证明几何推理,经过近半个世纪发展之后,终于在1962年于经就职于雷诺的法国工程师皮埃尔·贝塞尔(Pierre Bézier)在汽车车体工业设计中的成功实践后广泛宣传推广,之后被大家称之为贝塞尔曲线,贝塞尔可以说是理论联系实际的践行者。当然,贝塞尔曲线最初是由Paul de Casteljau于1959年运用de Casteljau算法开发,以稳定数值的方法求出贝兹曲线。
贝塞尔曲线的一个简的定义即:通过某些控制点,去生成的复杂平滑曲线
数学依据 没有复杂的数学公式,毕竟我们不做数学研究(o)/~,这里只列举了一下几何推导过程。
①首先想象有两条直线AB、BC相交于B点,AB、BC长度不做限定
②分别在两条直线上任取一个点D、F,满足条件AD/AB = BF/BC
③在DF上任取一点G,满足满足条件DG/DF = AD/AB = BF/BC
④从A到C画一条曲线(也可以反过来),以B为控制点,则刚刚的G点一定处于A到C点的贝塞尔曲线之上。不断变化DF位置,递归刚才的过程①-③的步骤,尝试完所有点并连接起来,我们可以得到如下一条平滑的曲线,此曲线即为贝塞尔曲线
如果将整个递推过程行测GIF动画,将会是如下效果
如果不易理解,我们将GIF动画停止到某帧(25)的时候停一下,可得到如下图,此时下图如同我们第④个步骤中结果图,它同样满足条件 Q0 B / Q0 Q1 = P0Q0 / P0P1 = P1Q1/P1P2
N次贝塞尔曲线 在上述在贝塞尔曲线中,将P1称为控制点,而P0和P2称为起点和终点,由此可以推出,起点终点位置不变的情况下,调整P1的位置将导致曲线形状发生变化,这也是称P1为控制点的由来。如上一个控制点的曲线我们称之为二次贝塞尔曲线。当然,该过程可以拓展至N次,使用迭代即可完成,如下图情况是三次贝塞尔曲线
控制点为0(没有控制点)的曲线也就是一条直线,下图是一次贝塞尔和四次贝塞尔的动画示例
UIBezierPath
UIBezierPath提供了实现二次和三次贝塞尔曲线的方法,同时该类还提供了绘制矩形、圆弧以及椭圆等其它功能,我们可以通过它实现某些UIKit不能提供的多边形图层,实现一些曲线动画等。由于绘制图形需要一个上下文环境,通常可以放到自定义UIView的draw(_ rect: CGRect)
中或者CALayer的draw(in ctx: CGContext)
,这两个方法中系统已经自动配置了绘图环境。如果不做任何操作,建议不要保留空方法,会导致无意义的性能开销,因为系统要配置绘图上下文环境。
绘图上下文 可以理解为一块画布图层,一个应用中可能有多个上下文环境即多块画布,存在一个画布图层栈stack中维护,通常使用
UIGraphicsGetCurrentContext()
即可取得。在UIView的draw(_ rect: CGRect)
方法中UIKit其实已经为我们维护好了一个context,无需我们创建既可以在这里绘制图形。而如果我们需要在某个地方临时时使用也可以自己创建,例如可以通过如下方法绘制一张图片。CGContextRef ctx = UIGraphicsGetCurrentContext(); // 获取当前画布 // draw in here... // 这里默认ctx就是当前的绘制上下文 UIImage *image = > > > UIGraphicsGetImageFromCurrentImageContext(); // 取得图片 UIGraphicsEndImageContext(); // 销毁画布 ```
上下文状态栈 在一块画布上画图可能有多种状态,比如使用的笔的颜色、粗细等,画出的线应该是虚线、实线又或者其他特性等等,这些笔或者线等的状态需要记录,也就记录在我们的图形上下文环境中,这个环境中有一个状态栈stack专门用来保存当前状态。栈顶中保存的状态即为当前状态(当前绘制使用的状态),亦可称为当前状态。
二次贝塞尔曲线Demo 自定义UIView,并覆盖draw(_ rect: CGRect)
,使用UIBezierPath绘制的二次贝塞尔曲线,如下面图例和对应的原理图。
由于现在所处的的是UIKit配置好的context环境下,所以设置颜色、线宽以及路劲形状等代码只要在绘制提交(stroke方法调用)之前即可。
override func draw(_ rect: CGRect) {
UIColor.green.set() // 设置颜色
let path = UIBezierPath()
path.lineWidth = 5 // 线宽
path.move(to: CGPoint(x: 100, y: 200)) // 起点
// 设置终点和控制点
path.addQuadCurve(to: CGPoint(x: 300, y: 200), controlPoint: CGPoint(x: 100, y: 0))
// 绘制路径
path.stroke()
}
CAShapeLayer绘制图形 除了上述使用draw(_ rect: CGRect)
方法绘制外,我们还可以将路径计算完成后赋值给CAShapeLayer的属性,让CAShapeLayer去执行绘制过程。由于draw(_ rect: CGRect)
方法中绘制由CPU计算处理,而CALayer的属性是由GPU计算处理,因此使用CAShapeLayer方式会加快绘制速度,不过也要是CPU个GPU整体负载情况均衡拿捏,通常可能触发离屏渲染的操作(比如同时设置cornerRadius和maskToBounds)更推荐使用CPU提前处理,然后再交给GPU。
三次贝塞尔曲线Demo 如果使用CAShapeLayer绘制贝塞尔曲线,则无需自定义View。使用UIBezierPath绘制的三次贝塞尔曲线,如下面图例和对应的原理图。
使用shapeLayer后,发现设置线宽、颜色等属性对path,而shapeLayer有效,即使用shapeLayer来后更多的渲染计算工作交由CALayer来控制,而这部分控制更多的话转嫁给GPU,这样的好处可以释放更多的CPU资源
override func viewDidLoad() {
super.viewDidLoad()
let path = UIBezierPath()
path.move(to: CGPoint(x: 100, y: 250)) // 起点
// 设置终点和控制点
path.addCurve(to: CGPoint(x: 400, y: 200), controlPoint1: CGPoint(x: 240, y: 120), controlPoint2: CGPoint(x: 260, y: 280))
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.lineWidth = 5 // 线宽
shapeLayer.strokeColor = UIColor.green.cgColor // 线颜色
shapeLayer.fillColor = nil
view.layer.addSublayer(shapeLayer)
}
使用UIBezierPath实现一个进度指示器
实现这个指示器主要考虑几点:圆心、半径,开始角度、终点角度。使用UIBezierPath绘扇形,绘制扇形只需要
一个圆弧
+一条圆心到终点的直线
+填充模式
即可。该案例完整的代码如下:
class ProgressLayerView: UIView {
var progress: CGFloat
override func draw(_ rect: CGRect) {
let pathRadius = bounds.size.width / 2 - 10
let arcCenter = CGPoint(x: bounds.size.width * 0.5, y: bounds.size.height * 0.5)
let startAngle: CGFloat = CGFloat(-Double.pi * 0.5)
let endAngle: CGFloat = CGFloat(Double.pi * 2) * progress + startAngle
let placeholderPath = UIBezierPath(arcCenter: arcCenter, radius: pathRadius, startAngle: 0, endAngle: CGFloat(Double.pi * 2), clockwise: true)
UIColor.gray.set()
placeholderPath.fill()
// 扇形
let path = UIBezierPath(arcCenter: arcCenter, radius: pathRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
// 圆心到终点的直线
path.addLine(to: arcCenter)
UIColor.white.set()
// 填充模式绘图
path.fill()
}
override init(frame: CGRect) {
progress = 0.01
super.init(frame: frame)
backgroundColor = UIColor.black.withAlphaComponent(0.8)
layer.cornerRadius = 5
layer.masksToBounds = true
// For test
refreshProgress()
}
func refreshProgress() {
progress += 0.01
setNeedsDisplay()
if progress >= 1.0 {
progress = 0.0
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now().advanced(by: .milliseconds(100))) {
self.refreshProgress()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
UIView实现透明孔洞
下图红线是UIBezierPath路径,箭头表示路径方向,通常情况下路径方向没有什么作用,不过在做路劲绘制动画或者实现以下这种透明孔洞时显得很有用,只需要控制一下填充规则就可以实现这种效果。
填充规则 CAShapeLayer有一个成员fullRule
指定填充规则,只适用哪一种算法判断画布上的某一个区域是否属于该图形路径的内部
,对一个没有交叉的线框如矩形,要判断哪块区域是内部
比较直观。但对于一个复查交叉路径(比如自交或包含路径关系)就不太明确了,此时就需要一定的规则才行。
fullRule
有两个值,nonZero:非零
和 evenOdd:奇偶
非零
按该规则,要判断一个点是否在图形内,从该点作任意方向的一条射线,然后检测射线与图形路径的交点情况。从0开始计数,路径从左向右穿过射线则计数加1,从右向左穿过射线则计数减1。得出计数结果后,如果结果是0,则认为点在图形外部,否则认为在内部。如下图示例:*
奇偶
按该规则,要判断一个点是否在图形内,从该点作任意方向的一条射线,然后检测射线与图形路径的交点的数量。如果结果是奇数则认为点在内部,是偶数则认为点在外部。如下图示例:*
明确以上规则后,就容易控制图层某些地方区域透明和不透明。由于该成员默认值是nonZero:非零
,除了通过修改fullRule的值为evenOdd:奇偶
实现中心透明外,还可以使用UIBezierPath的reversing()
方法改变路径曲线的默认方向来到达到相同的效果。该案例的完整代码如下:
@IBOutlet weak var imageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
// 背景图片
if let path = Bundle.main.path(forResource: "1404103479048.jpg", ofType: nil) {
let image = UIImage(contentsOfFile: path)
imageView.image = image
}
// 覆盖图层
let maskView = UIView(frame: self.view.bounds)
maskView.backgroundColor = UIColor.black.withAlphaComponent(0.8)
view.addSubview(maskView)
// 孔洞位置(alpha透明的位置)
let width: CGFloat = 300
let height: CGFloat = width
let cornerRadius: CGFloat = width * 0.5
let pathRect = CGRect(x: (maskView.bounds.size.width - width)/2,
y: (maskView.bounds.size.height - height)/2 - 100,
width: width,
height: height)
// 先创建一个路径放和原来的覆盖图层maskView一样大
let path = UIBezierPath(roundedRect: maskView.frame, cornerRadius: 0)
// 然后再创建一个路径时需要alpha透明的位置
path.append(UIBezierPath(roundedRect: pathRect, cornerRadius: cornerRadius))
// 创建shapeLayer,并将path给到shapeLayer
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
// 设置填充规则
shapeLayer.fillRule = .evenOdd
// 将shapeLayer赋值给maskView.layer.mask
// 这个mask是求两个layer(shapeLayer和maskView.layer)的交集
// 这个交集可以理解为面积交集
maskView.layer.mask = shapeLayer
}
参考资料
维基百科
百度百科
GPU vs CPU in iOS
iOS 图形绘制框架
必须要理解掌握的贝塞尔曲线(原创)
iOS UIBezierPath贝塞尔曲线常用方法
iOS 利用CAShapeLayer的FillRule属性生成一个空心遮罩的layer