上一篇文章CoreGraphics系列一:path介紹瞭如何使用 CoreGraphics 繪製線和圓弧。這一篇文章將深入介紹 Core Graphics,學習繪製漸變,使用 transformation 操控CGContexts
。
1. Core Graphics
下圖介紹了 Core Graphics 與相關框架的層級關係:
UIKit
位於最頂層,也是最常用的一層。UIKit
提供了對 Core Graphics 的封裝。例如,UIBezierPath
是UIKit
對 Core Graphics 中CGPath
的封裝。Core Graphics 中的對象和方法通常以CG
開頭。
這篇文章結束的時候,會創建一個如下圖的圖形:
完整視圖層級如下:
從CoreGraphics-2模版下載這篇文章的模版。模版與上一篇文章CoreGraphics系列一:path結束時沒有太大區別,只是CounterView
放到了另一個黃色視圖裏。運行後如下所示:
2. 設置切換動畫
在ViewController.swift
文件頂部添加以下屬性,標記當前展示的視圖類型:
private var isGraphViewShowing = false
在counterViewTap(_ gesture:)
方法中使用動畫切換counterView
和graphView
:
@objc func handleCounterViewTap(_ 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.toggle()
}
UIView.transition(from:to:duration:options:completion:)
執行了水平反轉動畫。其他可選動畫還有:交叉溶解、垂直翻轉、向上捲曲、向下捲曲。transition使用了showHideTransitionViews
,以便在動畫過程中隱藏視圖,無需移除視圖。
在pushButtonPressed(_:)
方法底部添加以下代碼:
if isGraphViewShowing {
handleCounterViewTap(nil)
}
3. GraphView的構成
如上一篇文章介紹的畫家模型,在 CoreGraphics 中繪製圖形時從後向前繪製。在編碼前需思考繪製順序。
- 背景視圖的漸變。
- 折線下的漸變。
- 折線。
- 折線中的圓點。
- 橫向參考線。
4. 繪製漸變
下面在GraphView
中繪製漸變。進入GraphView.swift
文件,添加以下代碼:
override func draw(_ rect: CGRect) {
// Drawing code
guard let context = UIGraphicsGetCurrentContext() else { return }
let colors = [startColor.cgColor, endColor.cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
// colors的每個位置
let colorLocations: [CGFloat] = [0.0, 1.0]
// 創建gradient
guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: colorLocations) else { return }
let startPoint = CGPoint.zero
let endPoint = CGPoint(x: 0, y: bounds.height)
// 繪製gradient
context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: [])
}
創建CGGradient
的方法init(colorsSpace:colors:locations:)
有以下參數:
-
space
:gradient使用的 color space。 -
colors
:元素類型爲CGColor
的非空數組,其應在space
的 color space。如果 color space 不爲NULL
,color會被轉換到該 color space;反之,color會被轉換到GenericRGB
color space。 -
locations
:colors
每個顏色位置。locations
數組元素是CGFloat
類型,值範圍是0到1。如果數組不包含0和1,Quartz使用數組中最接近0和1的元素。如果locations
是NULL
,colors
第一個元素賦值到位置0,最後一個元素賦值到位置1,其它元素均勻分佈。locations
數組和colors
數組元素數量應一致。
使用drawLinearGradient(_:start:end:options:)
繪製漸變,其包含以下參數:
-
gradient
:包含了color space
、colors
、locations
的 gradient。 -
startPoint
:gradient 開始的位置。 -
endPoint
:gradient結束的位置。 -
options
:控制填充是否超過開始drawsBeforeLocation
、結束drawsAfterEndLocation
位置。
運行後gradient效果如下:
目前,已經不需要containerView
的背景色,可以將其設置爲clearColor
。
5. 裁剪
上面繪製漸變時,填滿了整個區域。如果只需繪製部分區域,可以創建 path 裁剪繪製區域。
進入GraphView.swift
文件,在GraphView
類頂部添加以下常量:
private enum Constants {
static let cornerRadiusSize = CGSize(width: 8.0, height: 8.0)
static let margin: CGFloat = 20.0
static let topBorder: CGFloat = 60.0
static let bottomBorder: CGFloat = 50.0
static let colorAlpha: CGFloat = 0.3
static let circleDiameter: CGFloat = 5.0
}
在GraphView
的draw(_:)
方法開始位置添加以下代碼:
override func draw(_ rect: CGRect) {
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: Constants.cornerRadiusSize)
path.addClip()
...
}
裁剪後的區域限制了繪製漸變的區域。後續會使用該策略在指定 path 以下繪製漸變。
運行後,GraphView
的漸變有了圓角。如下所示:
通常,使用
CoreGraphics
繪製靜態視圖速度很快。但是,如果視圖需要移動,或頻繁重繪,則應使用CoreAnimation。CoreAnimation 經過了優化,使用 GPU 處理繪製任務,而非 CPU。CoreGraphics
中的draw(_:)
使用 CPU 處理繪製任務。Core Animation 中的圓角應使用
CALayer
的cornerRadius
,而非裁剪。
6. 計算繪製點
下面將繪製七個小圓點,其x軸代表星期幾,y軸代表幾杯水。
在GraphView.swift
文件GraphView
類頂部添加以下屬性,代表一週中每天喝了幾杯水。
var graphPoints = [4, 2, 6, 4, 5, 8, 3]
在draw(_:)
頂部添加以下代碼:
let width = rect.width
let height = rect.height
在draw(_:)
尾部添加以下代碼,計算x位置:
// Calculate the x point
let margin = Constants.margin
let graphWidth = width - margin * 2 - 4
let columnXPoint = { (column: Int) -> CGFloat in
let spacing = graphWidth / CGFloat(self.graphPoints.count - 1)
return CGFloat(column) * spacing + margin + 2
}
x軸包含七個等距的點,上面使用閉包表達式計算(closure expression)x位置。也可以使用函數計算x位置,但此類簡單計算寫成閉包更簡潔。
columnXPoint
入參爲列,返回其在x軸的位置。
在draw(_:)
底部添加以下代碼,計算y軸位置:
// Calculate the y point
let topBorder = Constants.topBorder
let bottomBorder = Constants.bottomBorder
let graphHeight = height - topBorder - bottomBorder
guard let maxValue = graphPoints.max() else { return }
let columnYPoint = { (graphPoint: Int) -> CGFloat in
let yPoint = CGFloat(graphPoint) / CGFloat(maxValue) * graphHeight
return graphHeight + topBorder - yPoint
}
columnYPoint
也是一個閉包表達式,根據天數和水量計算出對應y軸位置。
CoreGraphics
的原點位於左上角,繪製折線時原點位於左下角。因此,需調整y值,以便圖表方向符合預期。
繼續在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..<graphPoints.count {
let nextPoint = CGPoint(x: columnXPoint(i), y: columnYPoint(graphPoints[I]))
graphPath.addLine(to: nextPoint)
}
graphPath.stroke()
上述代碼根據x、y位置,創建UIBezierPath
,進而利用path創建折線。
運行後折線圖如下:
可以看到,繪製的折線符合預期,移除 stroke 代碼:
graphPath.stroke()
7. 在折線下繪製漸變
下面使用上面的折線作爲 clipping path,在折線下繪製漸變。
首先,在draw(_:)
底部創建clipping path:
// 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
guard let clippingPath = graphPath.copy() as? UIBezierPath else { return }
// 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
運行後,效果如下:
下面把填充的綠色更換爲漸變。使用以下代碼替換 temporary code:
// 使用y最大值作爲漸變的起點
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 the line on top of the clipped gradient
graphPath.lineWidth = 2.0
graphPath.stroke()
折線圖變得逐漸清晰起來,如下圖所示:
8. 填充規則
路徑填充有兩種規則:非零環繞數(nonzero winding number rule)和奇偶規則(even-odd rule)。填充規則超越了語言,普世通用。
用三個點連成一個三角形,兩種填充規則填充後沒有區別:
如果是兩個重疊的三角形,兩種填充規則填充後可能產生不同:
填充的關鍵是確定圖形哪些是內部、哪些是外部,然後只填充內部。
8.1 非零環繞數
對於給定的曲線C和給定的點P,構造一條無限長的射線,從P指向任意方向。找出C與這條射線的所有交點。按照如下方式計算環繞數:
- 對於每個順時針交叉點(從P射線方向看,曲線從左到右穿過射線),計數減一。
- 對於每個逆時針交叉點,計數加一。
如果最終環繞數爲零,則P在C之外,無需填充;否則,在曲線內,需要填充。
SVG 計算機圖形矢量標準繪製多邊形時默認使用非零規則。
8.2 奇偶規則
對於給定的曲線C和給定的點P,構造一條無限長的射線,從P指向任意方向。找出C與這條射線的所有交點。如果交點數爲奇數,則認爲該點在路徑內,需要填充;如果交點數爲偶數,則認爲該點在路徑外,無需填充。路徑方向不影響交計算交點數量。
非零環繞數和奇偶規則會出現矛盾的情況。如下圖所示,左側用奇偶規則填充,環繞數爲2,即在多邊形外,無需填充;右側使用非零環繞數規則,環繞數爲-2,即非零,在多邊形內,需要填充。
usesEvenOddFillRule
決定繪製路徑時是否使用奇偶規則,默認值爲false
。
9. 繪製數據點
在draw(_:)
尾部添加以下代碼,繪製數據點。
// Draw the circles on top of the graph stroke
for i in 0..<graphPoints.count {
var point = CGPoint(x: columnXPoint(i), y: columnYPoint(graphPoints[I]))
point.x -= Constants.circleDiameter / 2
point.y -= Constants.circleDiameter / 2
let circle = UIBezierPath(ovalIn: CGRect(origin: point, size: CGSize(width: Constants.circleDiameter, height: Constants.circleDiameter)))
circle.fill()
}
上述代碼,根據每個點的x、y座標計算點的位置,最終通過填充圓路徑繪製圓點。
折線中的圓點看起來並不是圓的。
10. Context State
折線中的圓點不是圓形的和 context state 有關。Graphics context 可以保存 state。因此,設置context的 fill color、transformation matrix、color space、clip region等屬性,就是在設置當前 graphics state。
使用context.saveGState()
保存state,它會將當前 graphics state 添加到棧上。saveGState()
後仍可以對 context 的屬性進行修改,當調用context.restoreGState()
後,原始的 state 會從 Stack 中取出,revert掉所有保存state後的修改。
進入GraphView.swift
文件的draw(_:)
方法,取消創建clipping path前context.saveGState()
的註釋。另外,取消註釋使用 clipping path前的context.restoreGState()
。
通過saveGState()
、restoreGState()
達到了以下效果:
- 使用
context.saveGState()
保存 graphics state 到 Stack。 - 對當前的 graphics state 添加 clipping path,達到一種新的 state。
- 在 clipping path 內繪製漸變。
- 使用
context.restoreGState()
恢復 graphics state,即添加 clipping path 前的狀態。
折線圖和圓點應變得清晰了。
在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()
效果如下:
11. 變換矩陣 Transformation Matrix
這一部分通過添加標記來指示每杯水的位置,進而提升視圖效果。
目前,我們已經掌握了一些 CG 函數。下面將使用CoreGraphics
旋轉、偏移 drawing context。
這些標記都是從中心輻射出:
就像向 context 中繪製內容,還可以通過旋轉、縮放、平移 context 的變換矩陣(transformation matrix)來操控 context。
變換矩陣的順序很重要,下面的圖片將介紹都進行哪些操作?如果你對變換(transform)還不瞭解,可以查看我的另一篇文章:CGAffineTransform和CATransform3D。
下圖是旋轉 context,然後在中心繪製矩形:
旋轉前繪製了黑色矩形,旋轉後分別繪製了綠色、紅色矩形。有兩點需要注意:
- 旋轉context時,錨點位於左上角。
- 旋轉context後,矩形仍位於context的中心。
繪製 counter view 的指示時,將先平移後旋轉 context。
上圖中,矩形位於context的左上角,藍色矩形是平移context後的;紅色虛線是旋轉後的,最後對context平移。
在context中繪製紅色矩形時,會以一定角度繪製。繪製完成後,需要重設context的中心,以便旋轉、偏移context,繪製另一個指示。
正如之前繪製 clipping path 時,save、restore 上下文的變換矩陣,繪製指示也需要進行同樣操作。
guard let context = UIGraphicsGetCurrentContext() else { return }
// 1. 保存當前 state
context.saveGState()
outlineColor.setFill()
let markerWidth: CGFloat = 5.0
let markerSize: CGFloat = 10.0
// 2. marker矩形位於左上角
let markerPath = UIBezierPath(rect: CGRect(x: -markerWidth / 2, y: 0, width: markerWidth, height: markerSize))
// 3. 將context平移到中心
context.translateBy(x: rect.width / 2, y: rect.height / 2)
for i in 1...Constants.numberOfGlasses {
// 4. 保存位於中心的state
context.saveGState()
// 5. 計算旋轉角度
let angle = arcLengthPerGlass * CGFloat(i) + startAngle - .pi / 2
// 旋轉、平移。
context.rotate(by: angle)
context.translateBy(x: 0, y: rect.height / 2 - markerSize)
// 6. 填充指示矩形
markerPath.fill()
// 7. 恢復至中心零角度位置,以便進行下一次的計算。
context.restoreGState()
}
// 8. 恢復至初始狀態,以便進行其它繪製。
context.restoreGState()
運行後效果如下:
總結
這篇文章介紹了CoreGraphics
的context,繪製了折線圖、漸變,瞭解了saveGState()
、restoreGState()
。下一篇文章CoreGraphics系列三:pattern和transparency layer將介紹 pattern 和 transparency layer相關內容。
Demo名稱:CoreGraphics2
源碼地址:https://github.com/pro648/BasicDemos-iOS/tree/master/CoreGraphics-2
參考資料:
歡迎更多指正:https://github.com/pro648/tips
本文地址:https://github.com/pro648/tips/blob/master/sources/CoreGraphics系列二:gradient和context.md