CoreGraphics系列二:gradient和context

上一篇文章CoreGraphics系列一:path介紹瞭如何使用 CoreGraphics 繪製線和圓弧。這一篇文章將深入介紹 Core Graphics,學習繪製漸變,使用 transformation 操控CGContexts

1. Core Graphics

下圖介紹了 Core Graphics 與相關框架的層級關係:

UIKit位於最頂層,也是最常用的一層。UIKit提供了對 Core Graphics 的封裝。例如,UIBezierPathUIKit對 Core Graphics 中CGPath的封裝。Core Graphics 中的對象和方法通常以CG開頭。

這篇文章結束的時候,會創建一個如下圖的圖形:

完整視圖層級如下:

CoreGraphics-2模版下載這篇文章的模版。模版與上一篇文章CoreGraphics系列一:path結束時沒有太大區別,只是CounterView放到了另一個黃色視圖裏。運行後如下所示:

2. 設置切換動畫

ViewController.swift文件頂部添加以下屬性,標記當前展示的視圖類型:

    private var isGraphViewShowing = false

counterViewTap(_ gesture:)方法中使用動畫切換counterViewgraphView

    @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 中繪製圖形時從後向前繪製。在編碼前需思考繪製順序。

  1. 背景視圖的漸變。
  2. 折線下的漸變。
  3. 折線。
  4. 折線中的圓點。
  5. 橫向參考線。

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。
  • locationscolors每個顏色位置。locations數組元素是CGFloat類型,值範圍是0到1。如果數組不包含0和1,Quartz使用數組中最接近0和1的元素。如果locationsNULLcolors第一個元素賦值到位置0,最後一個元素賦值到位置1,其它元素均勻分佈。locations數組和colors數組元素數量應一致。

使用drawLinearGradient(_:start:end:options:)繪製漸變,其包含以下參數:

  • gradient:包含了color spacecolorslocations的 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
    }

GraphViewdraw(_:)方法開始位置添加以下代碼:

    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 中的圓角應使用CALayercornerRadius,而非裁剪。

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()達到了以下效果:

  1. 使用context.saveGState()保存 graphics state 到 Stack。
  2. 對當前的 graphics state 添加 clipping path,達到一種新的 state。
  3. 在 clipping path 內繪製漸變。
  4. 使用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,然後在中心繪製矩形:

旋轉前繪製了黑色矩形,旋轉後分別繪製了綠色、紅色矩形。有兩點需要注意:

  1. 旋轉context時,錨點位於左上角。
  2. 旋轉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

參考資料:

  1. Core Graphics Tutorial: Gradients and Contexts
  2. Nonzero-rule
  3. UIBezierPath繪圖

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

本文地址:https://github.com/pro648/tips/blob/master/sources/CoreGraphics系列二:gradient和context.md

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章