版本记录
版本号 | 时间 |
---|---|
V1.0 | 2018.10.21 星期日 |
前言
quartz
是一个通用的术语,用于描述在iOS
和MAC OS X
中整个媒体层用到的多种技术 包括图形、动画、音频、适配。Quart 2D
是一组二维绘图和渲染API
,Core Graphic
会使用到这组API
,Quartz Core
专指Core Animation
用到的动画相关的库、API
和类。CoreGraphics
是UIKit
下的主要绘图系统,频繁的用于绘制自定义视图。Core Graphics
是高度集成于UIView
和其他UIKit
部分的。Core Graphics
数据结构和函数可以通过前缀CG
来识别。在app中很多时候绘图等操作我们要利用CoreGraphic
框架,它能绘制字符串、图形、渐变色等等,是一个很强大的工具。感兴趣的可以看我另外几篇。
1. CoreGraphic框架解析(一)—— 基本概览
2. CoreGraphic框架解析(二)—— 基本使用
3. CoreGraphic框架解析(三)—— 类波浪线的实现
4. CoreGraphic框架解析(四)—— 基本架构补充
5. CoreGraphic框架解析 (五)—— 基于CoreGraphic的一个简单绘制示例 (一)
开始
在第二部分中,您将深入研究Core Graphics,了解绘制渐变和使用CGContexts
进行变换操作。
你现在要离开UIKit的舒适世界,进入Core Graphics的黑社会。
Apple的这张图片从概念上描述了相关的框架:
UIKit是最顶层的,也是和开发者最容易接触的的。 您已经使用了UIBezierPath
,它是Core Graphics
的CGPath
的UIKit包装器。
Core Graphics框架基于Quartz
高级绘图引擎。 它提供低级,轻量级的2D渲染。 您可以使用此框架来处理基于路径的绘图,转换,颜色管理等等。
关于下层Core Graphics
对象和函数的一件事是它们总是有前缀CG
,所以它们很容易识别。
当你到本教程结束时,你将创建一个如下所示的图形视图:
在绘制图表视图之前,您将在故事板中进行设置,并创建动画转换的代码以显示图表视图。
完整的视图层次结构如下所示:
前一篇我们已经做到了下面这个程度。 唯一的区别是在Main.storyboard
中,CounterView
位于另一个视图(带黄色背景)内。 构建并运行,这将是您将看到的:
转到File \ New \ File ...
,选择iOS \ Source \ Cocoa Touch Class
模板,然后单击Next
。输入名称GraphView
作为类名,选择子类UIView并将语言设置为Swift。单击Next
,然后单击Create
。
现在,在Main.storyboard
中,单击Document Outline
中黄色视图的名称两次以重命名,然后将其命名为Container View
。将新UIView从对象库拖动到Counter View
下面的Container View
内部。
在Identity Inspector
中将新视图的类更改为GraphView
。剩下的唯一事情就是为新的GraphView添加约束,类似于在本教程前一部分中的操作:
- 选中
GraphView
后,按住Control键从中心稍微左侧(仍在视图中)进行拖动,然后从弹出菜单中选择Width
。 - 同样,按住Control键在选中GraphView的情况下,从中心稍微向上(仍然在视图中)进行控制 - 拖动,然后从弹出菜单中选择
Height
。 - 按住Control键从视图内部向左拖动到视图外部,然后选择
Center Horizontally in Container
。 - 按住Control键从视图内部向上拖动到视图外部,然后选择
Center Vertically in Container
。
在Size Inspector
中编辑约束常量以匹配以下内容:
你的Document Outline
应该像下面这样
您需要容器视图的原因是在Counter View
和Graph View
之间进行动画过渡。
转到ViewController.swift
并为Container
和Graph Views
添加属性outlets
:
@IBOutlet weak var containerView: UIView!
@IBOutlet weak var graphView: GraphView!
这为容器视图和图形视图创建了一个outlet
。 现在将它们连接到您在故事板中创建的视图。
返回Main.storyboard
并将Graph View
和Container View
连接到outlet
:
Seting up the Animated Transition - 设置动画转场
仍然在Main.storyboard
中,将Tap Gesture Recognizer
从对象库拖到Document Outline
中的Container View
:
转到ViewController.swift
并将此属性添加到类的顶部:
var isGraphViewShowing = false
这只是标记当前是否显示图表视图。
现在添加tap
方法来进行转场:
@IBAction func counterViewTap(_ gesture: UITapGestureRecognizer?) {
if (isGraphViewShowing) {
//hide Graph
UIView.transition(from: graphView,
to: counterView,
duration: 1.0,
options: [.transitionFlipFromLeft, .showHideTransitionViews],
completion:nil)
} else {
//show Graph
UIView.transition(from: counterView,
to: graphView,
duration: 1.0,
options: [.transitionFlipFromRight, .showHideTransitionViews],
completion: nil)
}
isGraphViewShowing = !isGraphViewShowing
}
UIView.transition(from:to:duration:options:completion :)
执行水平翻转过渡。 其他过渡是交叉溶解,垂直翻转和向上或向下卷曲。 转换使用.showHideTransitionViews
常量,这意味着您不必删除视图以防止它在转换中hidden
后显示。
在pushButtonPressed(_ :)
的末尾添加此代码:
if isGraphViewShowing {
counterViewTap(nil)
}
如果用户在显示图表时按下加号按钮,显示屏将向后摆动以显示计数器。
最后,要使此转换工作,请返回Main.storyboard
并将您的点击手势连接到新添加的counterViewTap(gesture:)
方法:
构建并运行应用程序。 目前,您在启动应用时会看到图表视图。 稍后,您将隐藏图表视图,因此计数器视图将首先出现。 点按它,您将看到转换翻转。
Analysis of the Graph View - 图表分析
还记得第1部分中的Painter’s Model
吗? 它解释了使用Core Graphics绘图是从图像背面到前面完成的,因此在编码之前需要记住顺序。 对于Flo
的图,那将是:
- 渐变背景视图
- 图下的剪裁渐变
- 图线
- 图表的圆圈指向
- 水平图线
- 图表标签
Drawing a Gradient - 绘制梯度
您现在将在图表视图中绘制渐变。
转到GraphView.swift
并将代码替换为:
import UIKit
@IBDesignable class GraphView: UIView {
// 1
@IBInspectable var startColor: UIColor = .red
@IBInspectable var endColor: UIColor = .green
override func draw(_ rect: CGRect) {
// 2
let context = UIGraphicsGetCurrentContext()!
let colors = [startColor.cgColor, endColor.cgColor]
// 3
let colorSpace = CGColorSpaceCreateDeviceRGB()
// 4
let colorLocations: [CGFloat] = [0.0, 1.0]
// 5
let gradient = CGGradient(colorsSpace: colorSpace,
colors: colors as CFArray,
locations: colorLocations)!
// 6
let startPoint = CGPoint.zero
let endPoint = CGPoint(x: 0, y: bounds.height)
context.drawLinearGradient(gradient,
start: startPoint,
end: endPoint,
options: [])
}
}
这里有几件事要做:
- 1) 您可以将渐变的开始和结束颜色设置为
@IBInspectable
属性,以便您可以在故事板中更改它们。 - 2)
CG
绘图函数需要知道它们将绘制的上下文,因此您使用UIKit方法UIGraphicsGetCurrentContext()
来获取当前上下文。这是draw(_:)
绘制的地方。 - 3) 所有上下文都有颜色空间。这可能是
CMYK
或灰度,但在这里你使用的是RGB
色彩空间。 - 4) 颜色停止描述渐变中的颜色变换的位置。在这个例子中,你只有两种颜色,红色变为绿色,但你可以有一个三个数的数组,并且红色变为蓝色变为绿色。停止点位于0和1之间,其中0.33是通过渐变的三分之一。
- 5) 创建实际渐变,定义颜色空间,颜色和颜色停止点。
- 6) 最后,绘制渐变。
CGContextDrawLinearGradient()
采用以下参数:- 要绘制的
CGContext
-
CGGradient
具有色彩空间,颜色和停止 - 起点
- 终点
- 用于扩展渐变的选项标志
- 要绘制的
渐变将填充整个draw(_:)
的rect
。
设置Xcode,以便使用Assistant Editor (Show Assistant Editor…\Counterparts\Main.storyboard)
对您的代码和故事板进行并排查看,您将看到渐变显示在图表视图上。
在故事板中,选择Graph View
。 然后在Attributes Inspector
中,将Start Color
更改为RGB(250,233,222)
,将End Color
更改为RGB(252,79,8)
(单击颜色,然后单击Other\Color Sliders
):
现在做一些清理工作。 在Main.storyboard
中,依次选择每个视图(ViewController主视图除外),并将Background Color
设置为Clear Color
。 您不再需要黄色,按钮视图也应该具有透明背景。
构建并运行应用程序,您会发现图形看起来更好,或者至少是它的背景。
Clipping Areas - 剪裁区域
刚刚使用渐变时,您填充了整个视图的上下文区域。 但是,您可以创建用作剪切区域的路径,而不是用于绘制。 剪切区域允许您定义要填充的区域,而不是整个上下文。
转到GraphView.swift
。
首先,在GraphView
的顶部添加这些常量,我们稍后将使用它们进行绘制:
private struct Constants {
static let cornerRadiusSize = CGSize(width: 8.0, height: 8.0)
static let margin: CGFloat = 20.0
static let topBorder: CGFloat = 60
static let bottomBorder: CGFloat = 50
static let colorAlpha: CGFloat = 0.3
static let circleDiameter: CGFloat = 5.0
}
在draw(_:)
的顶部添加下面代码
let path = UIBezierPath(roundedRect: rect,
byRoundingCorners: .allCorners,
cornerRadii: Constants.cornerRadiusSize)
path.addClip()
这将创建一个约束渐变的剪切区域。 您将很快使用相同的技巧在图线下绘制第二个渐变。
构建并运行应用程序,看看你的图表视图有漂亮的圆角:
注意:使用Core Graphics绘制静态视图通常足够快,但如果您的视图移动或需要频繁重绘,则应使用
Core Animation
层。Core Animation
经过优化,因此GPU(而不是CPU)可以处理大部分处理。相反,CPU处理Core Graphics
在draw(_ :)
中执行的视图绘制。您可以使用CALayer的
cornerRadius
属性创建圆角,而不是使用剪切路径,但您应该针对您的情况进行优化。
Tricky Calculations for Graph Points - 图形点的棘手计算
现在,您将从绘图中稍作休息来制作图表。你会绘制7个点,x轴将是“星期几”,y轴将是“喝的杯水的数量”。
首先,设置本周的样本数据。
仍然在GraphView.swift
中,在类的顶部添加以下属性:
//Weekly sample data
var graphPoints = [4, 2, 6, 4, 5, 8, 3]
这包含代表七天的样本数据。 忽略关于将其更改为let
值的警告,因为稍后我们需要将其作为var
。
将此代码添加到draw(_:)
的顶部
let width = rect.width
let height = rect.height
并将此代码添加到draw(_:)
结束
//calculate the x point
let margin = Constants.margin
let graphWidth = width - margin * 2 - 4
let columnXPoint = { (column: Int) -> CGFloat in
//Calculate the gap between points
let spacing = graphWidth / CGFloat(self.graphPoints.count - 1)
return CGFloat(column) * spacing + margin + 2
}
x轴点由7个等间距点组成。 上面的代码是一个闭包表达式。 它可以作为函数添加,但对于像这样的小型计算,您可以将它们保持内联。
columnXPoint
将列作为参数,并返回一个值,其中该点应位于x轴上。
添加代码来计算draw(_:)
结束时的y轴点:
// calculate the y point
let topBorder = Constants.topBorder
let bottomBorder = Constants.bottomBorder
let graphHeight = height - topBorder - bottomBorder
let maxValue = graphPoints.max()!
let columnYPoint = { (graphPoint: Int) -> CGFloat in
let y = CGFloat(graphPoint) / CGFloat(maxValue) * graphHeight
return graphHeight + topBorder - y // Flip the graph
}
columnYPoint
也是一个闭包表达式,它将星期几数组中的值作为参数。 它返回y
位置,介于0和最大杯水数之间。
由于Core Graphics中的原点位于左上角,并且您从左下角的原点绘制图形,因此columnYPoint
会调整其返回值,以使图形朝向您期望的方向。
继续在draw(_:)
结束时添加线条绘图代码
// draw the line graph
UIColor.white.setFill()
UIColor.white.setStroke()
// set up the points line
let graphPath = UIBezierPath()
// go to start of line
graphPath.move(to: CGPoint(x: columnXPoint(0), y: columnYPoint(graphPoints[0])))
// add points for each item in the graphPoints array
// at the correct (x, y) for the point
for i in 1..
在此块中,您将为图形创建路径。 UIBezierPath
是从graphPoints
中每个元素的x和y点构建的。
故事板中的图表视图现在应如下所示:
既然您已经验证了线条的正确绘制,请从draw(_:)
结束时删除它
graphPath.stroke()
这只是为了您可以查看故事板中的行并验证计算是否正确。
A Gradient Graph - 梯度图
现在,您将使用路径作为剪切路径在此路径下创建渐变。
首先在draw(_:)
结束时设置剪切路径:
//Create the clipping path for the graph gradient
//1 - save the state of the context (commented out for now)
//context.saveGState()
//2 - make a copy of the path
let clippingPath = graphPath.copy() as! UIBezierPath
//3 - add lines to the copied path to complete the clip area
clippingPath.addLine(to: CGPoint(x: columnXPoint(graphPoints.count - 1), y:height))
clippingPath.addLine(to: CGPoint(x:columnXPoint(0), y:height))
clippingPath.close()
//4 - add the clipping path to the context
clippingPath.addClip()
//5 - check clipping path - temporary code
UIColor.green.setFill()
let rectPath = UIBezierPath(rect: rect)
rectPath.fill()
//end temporary code
上述代码的逐节细分:
- 1)
context.saveGState()
暂时被注释掉了 - 一旦你理解了它的作用,你马上就会回到这一点。 - 2) 将绘制的路径复制到新路径,该路径定义要用渐变填充的区域。
- 3) 完成带角点的区域并关闭路径。 这会添加图表的右下角和左下角。
- 4) 将剪切路径添加到上下文。 填充上下文时,实际只填充剪切的路径。
- 5) 填充上下文。 请记住,
rect
是传递给draw(_ :)
的上下文区域。
故事板中的图表视图现在应如下所示:
接下来,您将使用从用于背景渐变的颜色创建的渐变替换可爱的绿色。
从draw(_:)
结束中删除带有绿色填充的临时代码,然后添加以下代码:
let highestYPoint = columnYPoint(maxValue)
let graphStartPoint = CGPoint(x: margin, y: highestYPoint)
let graphEndPoint = CGPoint(x: margin, y: bounds.height)
context.drawLinearGradient(gradient, start: graphStartPoint, end: graphEndPoint, options: [])
//context.restoreGState()
在这个区块中,您会发现杯水的最大数量,并将其作为渐变的起点。
您无法像使用绿色一样填充整个rect
。 渐变将从上下文的顶部而不是从图的顶部填充,并且不会显示所需的渐变。
记下注释掉的context.restoreGState()
- 在绘制绘图点的圆圈后,您将删除注释。
在draw(_:)
结束时添加以下内容:
//draw the line on top of the clipped gradient
graphPath.lineWidth = 2.0
graphPath.stroke()
此代码绘制原始路径。
你的图表现在正在形成:
Drawing the Data Points - 绘制数据点
在draw(_:)
结束时,添加以下内容:
//Draw the circles on top of the graph stroke
for i in 0..
此代码绘制绘图点并不是什么新鲜事。 它在计算的x和y点处为数组中的每个元素填充圆形路径。
嗯......但是故事板上出现的不是很好的圆形圆点!这是怎么回事?
Context States - 上下文状态
图形上下文可以保存状态。设置许多上下文属性(如填充颜色,变换矩阵,颜色空间或剪辑区域(fill color, transformation matrix, color space or clip region)
)时,实际上是为当前图形状态设置它们。
您可以使用context.saveGState()
来保存状态,它将当前图形状态的副本推送到状态堆栈。您还可以更改上下文属性,但是当您调用context.restoreGState()
时,原始状态将从堆栈中取出,并且上下文属性将还原。这就是为什么你看到了你的点的奇怪问题。
仍然在GraphView.swift
中,在draw(_ :)
中,取消注释在创建剪切路径之前发生的context.saveGState()
,并取消注释在使用剪切路径之后发生的context.restoreGState()
。
通过这样做,你:
- 1) 使用
context.saveGState()
将原始图形状态推送到堆栈。 - 2) 将剪切路径添加到新的图形状态。
- 3) 在剪切路径中绘制渐变。
- 4) 使用
context.restoreGState()
恢复原始图形状态。这是您添加剪切路径之前的状态。
你的图形线和圆圈现在应该更加清晰:
在draw(_:)
结束时,添加代码以绘制三条水平线:
//Draw horizontal graph lines on the top of everything
let linePath = UIBezierPath()
//top line
linePath.move(to: CGPoint(x: margin, y: topBorder))
linePath.addLine(to: CGPoint(x: width - margin, y: topBorder))
//center line
linePath.move(to: CGPoint(x: margin, y: graphHeight/2 + topBorder))
linePath.addLine(to: CGPoint(x: width - margin, y: graphHeight/2 + topBorder))
//bottom line
linePath.move(to: CGPoint(x: margin, y:height - bottomBorder))
linePath.addLine(to: CGPoint(x: width - margin, y: height - bottomBorder))
let color = UIColor(white: 1.0, alpha: Constants.colorAlpha)
color.setStroke()
linePath.lineWidth = 1.0
linePath.stroke()
此代码中没有任何内容是新的。 你所做的只是移动到一个点并绘制一条水平线。
Adding the Graph Labels - 添加图形标签
现在,您将添加标签以使图形用户友好。
转到ViewController.swift
并添加这些outlet
属性:
//Label outlets
@IBOutlet weak var averageWaterDrunk: UILabel!
@IBOutlet weak var maxLabel: UILabel!
@IBOutlet weak var stackView: UIStackView!
这会为您想要动态更改文本的两个标签(平均水量标签,最大水量标签)以及带有日期名称标签的StackView
添加outlets
。
现在转到Main.storyboard
并将以下视图添加为图表视图的子视图:
- 1)
UILabel
文字“Water Drunk”
- 2)
UILabel
,文字Average
- 3)
UILabel
,文本“2”
,旁边是Average
标签 - 4)
UILabel
,文本“99”
,右侧对齐图形顶部 - 5)
UILabel
,文本“0”
,右对齐到图形的底部 - 6) 一个水平StackView,每周的每一天都有标签 - 每个文本的代码都会更改。 中心对齐。
按住Shift键选择所有标签,然后将字体更改为Avenir Next Condensed, Medium style
。
将averageWaterDrunk
,maxLabel
和stackView
连接到Main.storyboard
中的相应视图。 按住Control键从View Controller
拖动到正确的标签,然后从弹出窗口中选择outlet
:
现在您已完成图形视图的设置,在Main.storyboard
中选择Graph View
并选中Hidden
,以便在应用程序首次运行时不显示图形。
转到ViewController.swift
并添加此方法以设置标签:
func setupGraphDisplay() {
let maxDayIndex = stackView.arrangedSubviews.count - 1
// 1 - replace last day with today's actual data
graphView.graphPoints[graphView.graphPoints.count - 1] = counterView.counter
//2 - indicate that the graph needs to be redrawn
graphView.setNeedsDisplay()
maxLabel.text = "\(graphView.graphPoints.max()!)"
// 3 - calculate average from graphPoints
let average = graphView.graphPoints.reduce(0, +) / graphView.graphPoints.count
averageWaterDrunk.text = "\(average)"
// 4 - setup date formatter and calendar
let today = Date()
let calendar = Calendar.current
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate("EEEEE")
// 5 - set up the day name labels with correct days
for i in 0...maxDayIndex {
if let date = calendar.date(byAdding: .day, value: -i, to: today),
let label = stackView.arrangedSubviews[maxDayIndex - i] as? UILabel {
label.text = formatter.string(from: date)
}
}
}
这看起来有点繁琐,但需要设置日历并检索一周中的当前日期:
- 1) 您将今天的数据设置为图形数据数组中的最后一项。在最终项目中,您将通过将其替换为60天的样本数据来扩展它,并且您将包含一个方法,该方法可以分割出最后x天的数据。数组,但这超出了本次会议的范围。
- 2) 如果今天的数据有任何变化,请重新绘制图表。
- 3) 在这里你使用Swift的
reduce
来计算本周喝的杯水量,这是一个非常有用的方法来总结数组中的所有元素。 - 4) 此部分以一种方式设置
DateFormatter
,它将获得一天名称的第一个字母。 - 5) 这个循环遍历
stackView
中的所有标签,我们为日期格式化程序中的每个标签设置文本。
仍然在ViewController.swift
中,从counterViewTap(_ :)
调用这个新方法。在条件的else
部分,注释显示show graph
,添加以下代码:
setupGraphDisplay()
运行该应用程序,然后单击计数器,查看效果:
Mastering the Matrix - 掌握矩阵
你的应用看起来非常好! 您在第一部分中创建的计数器视图可以进行改进,例如添加标记以指示每个要喝的杯水:
现在您已经对CG函数进行了一些实践,您将使用它们来旋转和转换绘图上下文。
请注意,这些标记从中心辐射:
除了绘制上下文之外,您还可以选择通过旋转,缩放和转换上下文的变换矩阵来操纵上下文。
起初,这看起来很令人困惑,但在你完成这些练习后,它会更有意义。 变换的顺序很重要,因此首先我将概述您将使用图表做什么。
下图是旋转上下文然后在上下文中心绘制一个矩形的结果。
在旋转上下文之前绘制黑色矩形,然后是绿色矩形,然后是红色矩形。 有两点需要注意:
- 1) 上下文在左上角旋转(0,0)
- 2) 矩形仍在上下文的中心绘制,但在上下文旋转后。
当您绘制计数器视图的标记时,您将首先变换上下文,然后旋转它。
在此图中,矩形标记位于上下文的最左上角。 蓝线勾勒出变换的上下文,然后上下文旋转(红色虚线)并再次变换。
当红色矩形标记最终被绘制到上下文中时,它将以一定角度出现在视图中。
旋转上下文并平移以绘制红色标记后,需要将其重置为中心,以便可以旋转上下文并再次平移以绘制绿色标记。
就像在Graph View
中使用剪切路径保存上下文状态一样,每次绘制标记时,都将使用变换矩阵保存和恢复状态。
转到CounterView.swift
并将此代码添加到draw(_:)
结束以将标记添加到计数器:
//Counter View markers
let context = UIGraphicsGetCurrentContext()!
//1 - save original state
context.saveGState()
outlineColor.setFill()
let markerWidth: CGFloat = 5.0
let markerSize: CGFloat = 10.0
//2 - the marker rectangle positioned at the top left
let markerPath = UIBezierPath(rect: CGRect(x: -markerWidth / 2, y: 0, width: markerWidth, height: markerSize))
//3 - move top left of context to the previous center position
context.translateBy(x: rect.width / 2, y: rect.height / 2)
for i in 1...Constants.numberOfGlasses {
//4 - save the centred context
context.saveGState()
//5 - calculate the rotation angle
let angle = arcLengthPerGlass * CGFloat(i) + startAngle - .pi / 2
//rotate and translate
context.rotate(by: angle)
context.translateBy(x: 0, y: rect.height / 2 - markerSize)
//6 - fill the marker rectangle
markerPath.fill()
//7 - restore the centred context for the next rotate
context.restoreGState()
}
//8 - restore the original state in case of more painting
context.restoreGState()
这就是你刚才所做的:
- 1) 在操作上下文的矩阵之前,您可以保存矩阵的原始状态。
- 2) 定义路径的位置和形状 - 但您还没有绘制它。
- 3) 移动上下文,以便在上下文的原始中心周围进行旋转。 (上图中的蓝线。)
- 4) 对于每个标记,首先保存居中的上下文状态。
- 5) 使用先前计算的单个角度,确定每个标记的角度并旋转和转换上下文。
- 6) 在旋转和变换的上下文的左上角绘制标记矩形。
- 7) 恢复居中上下文的状态。
- 8) 恢复没有旋转或变换的上下文的原始状态。
现在构建并运行应用程序,并欣赏Flo
的美丽且信息丰富的UI:
后记
本篇主要讲述了基于CoreGraphic的一个简单绘制示例,感兴趣的给个赞或者关注~~~