贝塞尔曲线原理及在iOS中使用介绍

贝塞尔曲线是指可以通过一些控制点去控制曲线的形状并且保持曲线的平滑特性,不会让人感觉到突兀。在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长度不做限定

image

②分别在两条直线上任取一个点D、F,满足条件AD/AB = BF/BC
image

③在DF上任取一点G,满足满足条件DG/DF = AD/AB = BF/BC
image

④从A到C画一条曲线(也可以反过来),以B为控制点,则刚刚的G点一定处于A到C点的贝塞尔曲线之上。不断变化DF位置,递归刚才的过程①-③的步骤,尝试完所有点并连接起来,我们可以得到如下一条平滑的曲线,此曲线即为贝塞尔曲线
image

如果将整个递推过程行测GIF动画,将会是如下效果
image

如果不易理解,我们将GIF动画停止到某帧(25)的时候停一下,可得到如下图,此时下图如同我们第④个步骤中结果图,它同样满足条件 Q0 B / Q0 Q1 = P0Q0 / P0P1 = P1Q1/P1P2
image

N次贝塞尔曲线 在上述在贝塞尔曲线中,将P1称为控制点,而P0P2称为起点和终点,由此可以推出,起点终点位置不变的情况下,调整P1的位置将导致曲线形状发生变化,这也是称P1为控制点的由来。如上一个控制点的曲线我们称之为二次贝塞尔曲线。当然,该过程可以拓展至N次,使用迭代即可完成,如下图情况是三次贝塞尔曲线

image

image

控制点为0(没有控制点)的曲线也就是一条直线,下图是一次贝塞尔和四次贝塞尔的动画示例


image

image

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绘制的二次贝塞尔曲线,如下面图例和对应的原理图。

image

image

由于现在所处的的是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绘制的三次贝塞尔曲线,如下面图例和对应的原理图。

image

image

使用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实现一个进度指示器

image

实现这个指示器主要考虑几点:圆心、半径,开始角度、终点角度。使用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实现透明孔洞

image

下图红线是UIBezierPath路径,箭头表示路径方向,通常情况下路径方向没有什么作用,不过在做路劲绘制动画或者实现以下这种透明孔洞时显得很有用,只需要控制一下填充规则就可以实现这种效果。


image

填充规则 CAShapeLayer有一个成员fullRule指定填充规则,只适用哪一种算法判断画布上的某一个区域是否属于该图形路径的内部,对一个没有交叉的线框如矩形,要判断哪块区域是内部比较直观。但对于一个复查交叉路径(比如自交或包含路径关系)就不太明确了,此时就需要一定的规则才行。
fullRule 有两个值,nonZero:非零evenOdd:奇偶

非零 按该规则,要判断一个点是否在图形内,从该点作任意方向的一条射线,然后检测射线与图形路径的交点情况。从0开始计数,路径从左向右穿过射线则计数加1,从右向左穿过射线则计数减1。得出计数结果后,如果结果是0,则认为点在图形外部,否则认为在内部。如下图示例:*

image

奇偶 按该规则,要判断一个点是否在图形内,从该点作任意方向的一条射线,然后检测射线与图形路径的交点的数量。如果结果是奇数则认为点在内部,是偶数则认为点在外部。如下图示例:*
image

明确以上规则后,就容易控制图层某些地方区域透明和不透明。由于该成员默认值是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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,377评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,390评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,967评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,344评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,441评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,492评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,497评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,274评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,732评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,008评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,184评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,837评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,520评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,156评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,407评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,056评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,074评论 2 352

推荐阅读更多精彩内容