CoreGraphics系列一:path

Core Graphics 框架也称为 Quartz 2D,是基于 Quartz 的高级渲染引擎,它提供了底层轻量级 2D 渲染引擎,可以进行高保真输出。

Quartz 2D 简单易用,提供了强大的功能。例如,基于路径的绘制,变换,颜色管理,离屏渲染,模式(pattern),渐变,阴影,图像数据管理、图像创建和mask,还可以处理 PDF 文档的创建、渲染和解析。

这篇文章将创建一个demo,用来记录每日喝了几杯水,并且会用折线图汇总显示本周饮水量。

1. 在视图上添加自定义绘制

创建一个名称为CoreGraphics-1的 iOS app。添加一个UIView,并在其中绘制自定义内容,步骤如下:

  1. 创建UIView子类。
  2. 重写draw(_:)方法,添加 Core Graphics 绘制代码。

先绘制一个如下的按钮:

CoreGraphics1-AddButtonFinal.png

创建名称为PushButton的类,其继承自UIButton

这篇文章只介绍CoreGraphics部分,将忽略与此无关的部分。如果在此过程中遇到问题,可以在文章底部获取源码查看。

2. 绘制 Button

要在 Core Graphics 中绘制图形,需定义一条路径(path),用以告诉 Core Graphics 如何跟踪绘制。例如,两条垂直的线画加号,填充圆画圆形。

path有以下三个基础知识:

  • path可以被描边(stroke)和填充(fill)。
  • stroke使用当前的颜色勾勒出path。
  • fill使用当前颜色填充闭合path。

使用UIBezierPath可以很方便的创建 Core Graphics path。UIBezierPath提供的 API 简单易用,可以基于直线、曲线、矩形,或一系列点绘制 path。

首先,使用UIBezierPath创建一个椭圆,并用绿色填充。打开PushButton.swift文件,添加以下代码:

    override func draw(_ rect: CGRect) {
        let path = UIBezierPath(ovalIn: rect)
        UIColor.green.setFill()
        path.fill()
    }

UIBezierPath(ovalIn:)根据参数矩形的大小创建椭圆。由于 path 不会自动绘制任何内容,因此可以在没有绘制上下文(drawing context)的地方定义 path。想要绘制 path,在当前 context 设置 fill color 并填充 path。

运行后如下:

CoreGraphics1-OvalRect.png

每个UIView都有 graphics context。在传输到屏幕中之前,视图所有绘制操作都在 graphics 上下文中进行。

当视图需要重绘时,系统调用draw(_:)方法。例如:

  • 将视图添加到屏幕中。
  • 移动视图上子视图位置。
  • 视图的isHidden属性发生改变。
  • 视图滑动出屏幕后,再次滑动到屏幕上。
  • 手动调用了setNeedsDisplay()setNeedsDisplayInRect()

系统提供的视图会自动进行重绘。自定义的视图需重写draw(_:)方法,在该方法中执行所有绘制代码。视图首次显示时,传递给draw(_:)方法的rect参数为视图的所有可见区域。后续调用draw(_:)方法时,只传递需重绘的rect。为优化性能,应只重绘受影响区域。

调用draw(_:)方法后,view会被标记为已更新,等待新的修改操作,然后触发下一个更新循环。如果想更新视图内容,需调用setNeedsDisplay()setNeedsDisplay(_:)方法触发更新循环。draw(_:)方法只能在重绘时,由系统调用。其它时间,graphics context 是不存在的。因此,不能手动调用draw(_:)方法。

draw(_:)中的所有绘制操作都会进入视图的绘制上下文中。在draw(_:)之外绘制,需单独创建 graphics context。

UIKit对 Core Graphics 的部分 API 进行了封装。例如,UIBezierPath是对CGMutablePath的封装。因此,到目前为止还没有涉及到 Core Graphics 相关 API。

3. 画家模型 The Painter's Model

Core Graphics 使用一种被称之为「画家模型」的方式工作。在画家模型中,每个连续的绘图操作将一个图层绘制应用到画布,通常被称为 page。通过添加额外的绘制可以覆盖原来绘制的内容,达到修改原来绘制内容的目的。通过使用画家模型,基于少量的基础操作可以构建复杂的图像。

下图显示了画家模型如何工作。图片中的上半部分,先绘制左侧图形、后绘制右侧实心区域。右侧图形会覆盖左侧图形,遮挡了左侧图形周边以外的区域。下半部分以相反顺序绘制,最终结果有所不同。在画家模型中,绘制顺序很重要。

CoreGraphics1-PaintersModel.png

Page 会根据输出设备而变。如果输出设备是打印机,则page是真正的纸;如果输出设备是 PDF 文件,则 page 是虚拟的纸;如果显示到屏幕中,则 page 可能是位图。page 随当前的 context 而变。

我们需要绘制的图形加号位于蓝色圆之上。因此,需要先绘制蓝色圆,后绘制加号。

PushButton中添加以下常量:

    private struct Constants {
        static let plusLineWidth: CGFloat = 3.0
        static let plusButtonScale: CGFloat = 0.6
        static let halfPointShift: CGFloat = 0.5
    }
    
    private var halfWidth: CGFloat {
        return bounds.width / 2
    }
    
    private var halfHeight: CGFloat {
        return bounds.height / 2
    }

draw(_:)添加以下代码,绘制加号中的横线:

    override func draw(_ rect: CGRect) {
        ...
        
        // Set up the width and height variables for the horizontal stroke
        let plusWidth = min(bounds.width, bounds.height) * Constants.plusButtonScale
        let halfPlusWidth = plusWidth / 2
        
        // Create the path
        let plusPath = UIBezierPath()
        
        // Set the path's line width to the height of the stroke
        plusPath.lineWidth = Constants.plusLineWidth
        
        // Move the initial point of the path to the start of the horizontal stroke
        plusPath.move(to: CGPoint(x: halfWidth - halfPlusWidth, y: halfHeight))
        
        // Add a point to the path at the end of the stroke
        plusPath.addLine(to: CGPoint(x: halfWidth + halfPlusWidth, y: halfHeight))
        
        // Set the stroke color
        UIColor.white.setStroke()
        
        // Draw the stroke
        plusPath.stroke()
    }

上述代码创建了一个UIBezierPath,设置其在圆上的起点、终点,最后使用白色描边。效果如下:

CoreGraphics1-Dash.png

在iPad 2、iPhone 8 Plus模拟器中运行demo,可以看到该横线不是很清晰,有一条淡蓝色的线围绕它。如下所示:

CoreGraphics1-PixelledLine.png

4. Points VS Pixels

初代iPhone发布时,points和pixels占据同样空间,大小一致。Retain 屏iPhone发布后,一个point不再占据一pixels。

下图是12*12像素,point使用灰色、白色显示的表格,iPad 2使用1x图,即1point占据1pixel;iPhone 8使用2x图,即1point占据2pixel;iPhone 8 Plus使用3x图,即1point占据3pixel。

绘制线时从path的中心开始。绘制线高度为3point时,则每侧有1.5point。可以看到,1x、3x显示屏渲染时,会有半像素需要渲染的情况。显然,屏幕无法将一个像素渲染为两种颜色。iOS 的抗锯齿化会将该像素渲染为两种颜色的中间值。最终,颜色边界变得模糊。

CoreGraphics1-PixelLineDemonstrated.png

开发过程中,retain 显示屏3x拥有超高分辨率,不太容易注意到抗锯齿产生的模糊。但如果app需支持1x屏,抗锯齿会很明显,需格外注意。

path位置需为整数加减0.5,以防止抗锯齿。正如上图中看到的,0.5point在1x屏幕中向上移动0.5pixel,在2x屏幕中移动1.0pixel,在3x屏幕中移动1.5pixel。

更新draw(_:)中的move(to:)addLine(to:)如下:

        // Move the initial point of the path to the start of the horizontal stroke
        plusPath.move(to: CGPoint(x: halfWidth - halfPlusWidth + Constants.halfPointShift, y: halfHeight + Constants.halfPointShift))
        
        // Add a point to the path at the end of the stroke
        plusPath.addLine(to: CGPoint(x: halfWidth + halfPlusWidth + Constants.halfPointShift, y: halfHeight + Constants.halfPointShift))

因为path偏移了0.5point,其在三种不同屏幕上都不会产生抗锯齿。

在上述代码后,stroke前添加以下代码,绘制竖线:

        // Vertical line
        plusPath.move(to: CGPoint(x: halfWidth + Constants.halfPointShift, y: halfHeight - halfPlusWidth + Constants.halfPointShift))
        plusPath.addLine(to: CGPoint(x: halfWidth + Constants.halfPointShift, y: halfHeight + halfPlusWidth + Constants.halfPointShift))

用户有时可能误操作,点击两次加号。做为开发者,应该提供减少次数的功能。 你可以复用PushButton,绘制一个减号按钮。遇到问题可以下载源码查看。最终效果如下图:

[图片上传失败...(image-5433f7-1636042273902)]

5. 弧线

这一部分将绘制如下的图像:

CoreGraphics1-completed-counter.png

创建CounterView,其继承自UIView。添加以下代码,稍后用于绘制:

class CounterView: UIView {
    
    private struct Constants {
        static let numberOfGlasses = 8
        static let lineWidth: CGFloat = 5.0
        static let arcWidth: CGFloat = 76
        
        static var halfOfLineWidth: CGFloat {
            lineWidth / 2
        }
    }
    
    var counter = 5
    var outlineColor = UIColor.blue
    var counterColor = UIColor.orange
}

使用Auto Layout布局CounterView,设置其宽高230point,中点位于水平中心,底部距离pushButton顶部40point。添加约束后,运行如下:

CoreGraphics1-ArcArea.png

CounterView.swiftdraw(_:)方法中,添加以下代码:

    override func draw(_ rect: CGRect) {
        // 弧线的center
        let center = CGPoint(x: bounds.width / 2, y: bounds.height / 2)
        
        // 根据视图最大尺寸计算半径
        let radius = max(bounds.width, bounds.height)
        
        // 弧线起始弧度
        let startAngle: CGFloat = 3 * .pi / 4
        let endAngle: CGFloat = .pi / 4
        
        // 根据center、radius、angle创建贝塞尔曲线
        let path = UIBezierPath(arcCenter: center, radius: radius / 2 - Constants.arcWidth / 2, startAngle: startAngle, endAngle: endAngle, clockwise: true)
        
        // 设置path宽度、颜色,最后stroke path
        path.lineWidth = Constants.arcWidth
        counterColor.setStroke()
        path.stroke()
    }

可以将上面绘制圆弧的方式想象成圆规画圆。将带有钢芯的脚放到center,两只脚的距离为半径,旋转绘制图形即可。使用 Core Graphics 绘制时,圆规的钢芯是 center,圆规两脚的距离减去笔宽的一半是 radius。笔的宽度是圆弧的宽度。

运行后效果如下:

CoreGraphics1-ArcPath.png

6. 勾画弧线

使用弧线标志喝了几杯水。弧线包含一条外线,一条内线,以及连接它们的线。

CounterView.swift文件的draw(_:)方法中添加以下代码:

    override func draw(_ rect: CGRect) {
        ...
        
        // 绘制外边缘
        
        // 计算弧度,确保其为正值。
        let angleDifference: CGFloat = 2 * .pi - startAngle + endAngle
        // 每杯水对应弧度
        let arcLengthPerGlass = angleDifference / CGFloat(Constants.numberOfGlasses)
        // 弧线终点弧度
        let outlineEndAngle = arcLengthPerGlass * CGFloat(counter) + startAngle
        
        //  绘制外边缘
        let outerArcRadius = bounds.width / 2 - Constants.halfOfLineWidth
        let outlinePath = UIBezierPath(arcCenter: center, radius: outerArcRadius, startAngle: startAngle, endAngle: outlineEndAngle, clockwise: true)
        
        // 绘制内边缘
        let innerArcRadius = bounds.width / 2 - Constants.arcWidth + Constants.halfOfLineWidth
        outlinePath.addArc(withCenter: center, radius: innerArcRadius, startAngle: outlineEndAngle, endAngle: startAngle, clockwise: false)
        
        // 关闭path
        outlinePath.close()
        
        outlineColor.setStroke()
        outlinePath.lineWidth = Constants.lineWidth
        outlinePath.stroke()
    }

CounterViewcounter设置为5时,效果如下:

CoreGraphics1-counter5.png

最后,在CounterView中心添加UILabel,显示当前喝了几杯水。为加减按钮添加点击事件。因为,只有在自身isHidden变化、子视图移动,首次添加到屏幕中,才会调用draw(_:)。因此,需要重绘时,需调用setNeedsDisplay()setNeedsDisplayInRect()方法。

如果遇到问题,可以下载源码查看。运行后效果如下:

CoreGraphics1-FinalEffect.gif

总结

这篇文章介绍了基础的绘图操作,可以用来绘制各种形状的图案。

下一篇文章CoreGraphics系列二:gradient和context将进一步介绍 Core Graphics 的 context,绘制一个折线图。

Demo名称:CoreGraphics1
源码地址:https://github.com/pro648/BasicDemos-iOS/tree/master/CoreGraphics-1

参考资料:

  1. Core Graphics Tutorial: Getting Started
  2. iOS Drawing Concepts

欢迎更多指正:https://github.com/pro648/tips

本文地址:https://github.com/pro648/tips/blob/master/sources/CoreGraphics系列一:path.md

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

推荐阅读更多精彩内容