[譯] macOS 上的 Core Graphics 入門教程

本文翻譯自 raywenderlich.comCore Graphics on macOS Tutorial,已咨詢對方網站,可至多翻譯 10 篇文章。
希望各位有英語閱讀能力的話,還是先打賞然后去閱讀英文原吧,畢竟無論是 Xcode,抑或是官方的文檔,還是各種最前沿的資訊都只有英文版本。
綜上,此翻譯版本僅供參考,謝絕轉載

也歡迎你點擊我的頭像查看我翻譯的其他 macOS 開發教程??

更新于 2016-9-22:此教程已更新至 Xcode 8 和 Swift 3。

你肯定見過許多擁有漂亮的界面和華麗的自定義視圖的 app,它們肯定在你的心里留下了深刻的印象,因為它們是那!么!好!看!

Core Graphics 是 Apple 提供的 2D 繪圖引擎,也幾乎是 macOS 和 iOS 所有框架中最酷的了。它可以用來繪制你能想到的所有圖形,從簡單的幾何形狀到復雜的陰影和漸變等視覺效果。

在這個 macOS Core Graphics 教程中,你將會創造一個叫做 DiskInfo 的自定義視圖,它能用一張餅圖和一個條狀圖來顯示出你 Mac 上的硬盤可用空間。這個教程將會讓你擁有把平淡單調的 UI 變得精彩紛呈的能力:

在這個教程中你將學會:

  • 創建并配置一個自定義視圖,這是繪制圖形元素的必要條件;
  • 實現實時渲染預覽功能,有了它你不需要編譯和運行,就能在 Interface Builder 里看到你對圖形的各種修改;
  • 用代碼繪制路徑、填充圖形、創建剪切蒙版剪輯和渲染文本;
  • 使用 AppKit 里的 Cocoa Drawing 工具提供的高級類和方法。

在第一部分中,你將會通過 Core Graphics 來實現繪制一個餅圖,稍后你將會學習如何用 Cocoa Drawing 實現相同的效果。

所以拿起你的小畫刷,我們要開始作畫啦~

準備開始

點擊這里下載 DiskInfo 的起步工程,編譯并運行它:

這個 app 會羅列出你的所有硬盤,點擊任何一個即可查看他的詳細信息。

在操作之前,我們先來熟悉一下這個項目的結構:

  • ViewController.swift:app 的主要 View Controller;
  • VolumeInfo.swift:實現了用于處理硬盤信息的 VolumeInfo 類,以及用于分析不同文件類型所占空間的 FilesDistribution 結構體;
  • NSColor+DiskInfo.swiftNSFont+DiskInfo.swift:擴展了 NSColor,定義了 app 中會用到的顏色和字體;
  • CGFloat+Radians.swift:擴展了 CGFloat,提供了轉換角度值和弧度制的 helper 方法;
  • MountedVolumesDataSource.swiftMountedVolumesDelegate.swift:實現了顯示硬盤信息所必需的各類方法。

注意:這個 app 可以顯示你真正的硬盤用量信息,但在這個教程中,它將會生成隨機的數據。
每次啟動 app 時都計算一次硬盤上所有文件的類型會很耗時,也會消磨完你所有的樂趣,沒人愿意在這上面浪費時間,對吧???

創建一個自定義視圖

你要做的第一件事是創建一個名叫 GraphView 的自定義視圖。這將會是你繪制餅狀圖和條形圖的地方。這個部分中你需要做兩件事:

  1. 創建一個 NSView 的子類;
  2. 重寫 draw(_:) 方法,加入一些用于繪制的代碼。

創建 NSView 的子類

選中項目導航器的 Views 分組,點擊去 Xcode 菜單上的 FileNewFile…,然后點擊 macOSSourceCocoa Class 模版。

點擊 Next,把新的類命名為 GraphView,并讓它繼承自 NSView,把語言選擇為 Swift

點擊 NextCreate 來保存你的文件。

打開 Main.storyboard,在 View Controller Scene 中,從控件庫里拖入一個 Custom View

選中這個 Custom View,在身份檢查器里,把它的類名設置為 GraphView

你需要一些約束,所以保持 Graph View 的選中狀態,點擊 Auto Layout 工具欄上的 Pin 按鈕,把它的 TopBottomLeadingTrailing 約束設置為 0,然后點擊 Add 4 Constrains 按鈕。

點擊 Auto Layout 工具欄上的三角形的 Resolve Auto Layout Issues 按鈕,然后在 Selected Views 部分中點擊 Update Frames。如果這個選項不可用,你可能需要先點擊空白處取消選中 Graph View,然后再次選中它。

重寫 draw(_:)

打開 GraphView.swift,你能看到 Xcode 自動為我們創建了一個 draw(_:) 的實現。把那行注釋替換成以下代碼,并確保你別不小心刪掉了它調用父類此方法的那一行哦。

NSColor.white.setFill() 
NSRectFill(bounds)

第一行代碼把填充顏色設置為了白色,然后通過調用 NSRectFill 方法,你把整個視圖的背景設成了白色。

編譯并運行:

你已經把自定義視圖的背景從默認的灰色改成了白色。

哈哈,我們的畫布已經就緒!就是這么簡單~

實時渲染預覽:@IBDesignable@IBInspectable

Xcode 6 為我們帶來了一個牛×的功能:實時渲染預覽。它允許你在 Interface Builder 里查看你自定義的視圖的樣子,而不用每次都編譯和運行。

要啟用這個功能,你需要用 @IBDesignable 來修飾你的類;并實現 prepareForInterfaceBuilder() 方法來提供一些示例數據(實現這個方法不是必須的)。

打開 GraphView.swift,在類的定義之前加入:

@IBDesignable

現在你需要提供一些示例數據,把這些代碼添加到 GraphView 類中:

var fileDistribution: FilesDistribution? {
  didSet {
    needsDisplay = true
  }
}

override func prepareForInterfaceBuilder() {
  let used = Int64(100000000000)
  let available = used / 3
  let filesBytes = used / 5
  let distribution: [FileType] = [
    .apps(bytes: filesBytes / 2, percent: 0.1),
    .photos(bytes: filesBytes, percent: 0.2),
    .movies(bytes: filesBytes * 2, percent: 0.15),
    .audio(bytes: filesBytes, percent: 0.18),
    .other(bytes: filesBytes, percent: 0.2)
  ]
  fileDistribution = FilesDistribution(capacity: used + available,
                                       available: available,
                                       distribution: distribution)
}

這將會定義一個 fileDistribution 屬性用于存儲硬盤的信息。當這個屬性發生改變,它會設置這個視圖的 needsDisplay 屬性為 true,從而讓視圖重繪自己的內容。

然后,它實現了 prepareForInterfaceBuilder() 方法,以此創建了一個各種文件類型的例子,用于給 Xcode 預覽這個視圖。

注意:你甚至可以在 Interface Builder 里實時修改視覺屬性。這要求你用 @IBInspectable 來修飾這個屬性。

下一步:用 @IBInspectable 修飾所有的視覺屬性,把這些代碼添加到 GraphView 的聲明中:

// 1
fileprivate struct Constants {
  static let barHeight: CGFloat = 30.0
  static let barMinHeight: CGFloat = 20.0
  static let barMaxHeight: CGFloat = 40.0
  static let marginSize: CGFloat = 20.0
  static let pieChartWidthPercentage: CGFloat = 1.0 / 3.0
  static let pieChartBorderWidth: CGFloat = 1.0
  static let pieChartMinRadius: CGFloat = 30.0
  static let pieChartGradientAngle: CGFloat = 90.0
  static let barChartCornerRadius: CGFloat = 4.0
  static let barChartLegendSquareSize: CGFloat = 8.0
  static let legendTextMargin: CGFloat = 5.0
}

// 2
@IBInspectable var barHeight: CGFloat = Constants.barHeight {
  didSet {
    barHeight = max(min(barHeight, Constants.barMaxHeight), Constants.barMinHeight)
  }
}
@IBInspectable var pieChartUsedLineColor: NSColor = NSColor.pieChartUsedStrokeColor
@IBInspectable var pieChartAvailableLineColor: NSColor = NSColor.pieChartAvailableStrokeColor
@IBInspectable var pieChartAvailableFillColor: NSColor = NSColor.pieChartAvailableFillColor
@IBInspectable var pieChartGradientStartColor: NSColor = NSColor.pieChartGradientStartColor
@IBInspectable var pieChartGradientEndColor: NSColor = NSColor.pieChartGradientEndColor
@IBInspectable var barChartAvailableLineColor: NSColor = NSColor.availableStrokeColor
@IBInspectable var barChartAvailableFillColor: NSColor = NSColor.availableFillColor
@IBInspectable var barChartAppsLineColor: NSColor = NSColor.appsStrokeColor
@IBInspectable var barChartAppsFillColor: NSColor = NSColor.appsFillColor
@IBInspectable var barChartMoviesLineColor: NSColor = NSColor.moviesStrokeColor
@IBInspectable var barChartMoviesFillColor: NSColor = NSColor.moviesFillColor
@IBInspectable var barChartPhotosLineColor: NSColor = NSColor.photosStrokeColor
@IBInspectable var barChartPhotosFillColor: NSColor = NSColor.photosFillColor
@IBInspectable var barChartAudioLineColor: NSColor = NSColor.audioStrokeColor
@IBInspectable var barChartAudioFillColor: NSColor = NSColor.audioFillColor
@IBInspectable var barChartOthersLineColor: NSColor = NSColor.othersStrokeColor
@IBInspectable var barChartOthersFillColor: NSColor = NSColor.othersFillColor

// 3
func colorsForFileType(fileType: FileType) -> (strokeColor: NSColor, fillColor: NSColor) {
  switch fileType {
  case .audio(_, _):
    return (strokeColor: barChartAudioLineColor, fillColor: barChartAudioFillColor)
  case .movies(_, _):
    return (strokeColor: barChartMoviesLineColor, fillColor: barChartMoviesFillColor)
  case .photos(_, _):
    return (strokeColor: barChartPhotosLineColor, fillColor: barChartPhotosFillColor)
  case .apps(_, _):
    return (strokeColor: barChartAppsLineColor, fillColor: barChartAppsFillColor)
  case .other(_, _):
    return (strokeColor: barChartOthersLineColor, fillColor: barChartOthersFillColor)
  }
}

這一大坨代碼的作用是:

  1. 聲明了帶有許多常量的結構體 —— 你得在這個 app 的各個地方用到它們;
  2. @IBInspectable 修飾所有可配置的屬性。并使用 NSColor+DiskInfo.swift 中的值為它們賦值。注意:要使一個屬性「inspectable」(可以在 Interface Builder 里直接編輯),你必須聲明它的類型,即使大多數情況下 Swift 會幫你做這件事兒;
  3. 定義一個 Helper 方法,為不同的文件類型返回它的筆觸顏色和填充顏色。你在繪制「什么文件占了多大地兒」的圖表的時候會用到它。

打開 Main.stroyboard,你應該能注意到 Graph View 已經從默認的灰色變成了白色,這意味著實時渲染預覽已經起作用了。

選中 Graph View,打開屬性檢查器,你會發現所有的「inspectable」屬性已經出現在這里了。

現在開始,要查看調整好的效果,你可以直接編譯和運行,也可以直接在 Interface Builder 里查看。

萬事俱備,是時候開始真正的繪制啦!

Graphics Context(圖形上下文)

在使用 Core Graphics 的時候,你并不是直接在視圖中繪畫,而是使用一個叫 Graphics Context(圖形上下文)的東西,它是系統渲染圖形與把圖形顯示在視圖中的中間層。

Core Graphics 使用一個叫「Painter’s Model(畫家模式)」的模式,你可以想像成自己拿著筆,唰唰地在畫布上繪圖樣子。你需要放置一個路徑,然后去填充它,你沒辦法去改變已經布置好的像素們,但你可以在它們之上繼續畫圖。

這個「上下文」非常重要,因為他決定了你最終得到的效果。

繪制路徑

要使用 Core Graphics 繪制一個路徑,你需要定義一個 Path(路徑),也就是 CGPathRef 和可變的 CGMutablePathRef

路徑準備好以后,把它添加到圖形上下文里,就可以根據路徑和繪制屬性渲染出你要的圖形了。

為餅圖…繪制一個路徑

條形圖的基本元素是圓角矩形,所以我們從這里開始入手。

打開 GraphView.swift,把這個擴展添加在文件底部類定義以外的地方:

// MARK: - 用于繪制的 extension

extension GraphView {
  func drawRoundedRect(rect: CGRect, inContext context: CGContext?,
                       radius: CGFloat, borderColor: CGColor, fillColor: CGColor) {
    // 1
    let path = CGMutablePath()
    
    // 2
    path.move( to: CGPoint(x:  rect.midX, y:rect.minY ))
    path.addArc( tangent1End: CGPoint(x: rect.maxX, y: rect.minY ), 
                 tangent2End: CGPoint(x: rect.maxX, y: rect.maxY), radius: radius)
    path.addArc( tangent1End: CGPoint(x: rect.maxX, y: rect.maxY ), 
                 tangent2End: CGPoint(x: rect.minX, y: rect.maxY), radius: radius)
    path.addArc( tangent1End: CGPoint(x: rect.minX, y: rect.maxY ), 
                 tangent2End: CGPoint(x: rect.minX, y: rect.minY), radius: radius)
    path.addArc( tangent1End: CGPoint(x: rect.minX, y: rect.minY ), 
                 tangent2End: CGPoint(x: rect.maxX, y: rect.minY), radius: radius)
    path.closeSubpath()
    
    // 3
    context?.setLineWidth(1.0)
    context?.setFillColor(fillColor)
    context?.setStrokeColor(borderColor)
    
    // 4
    context?.addPath(path)
    context?.drawPath(using: .fillStroke)
  }
}

太長不看???:以上是繪制一個圓角矩形的方法,用人類能理解的語言解釋一遍就是:

  1. 創建一個可以改變的路徑;
  2. 一步一步勾勒出一個圓角矩形:
    • 移動到矩形底邊的中點,這里將是我們的起點;
    • 使用 addArc(tangent1End:tangent2End:radius) 方法繪制右下角的線段,這個方法會繪制出底部的水平線以及右下角的圓角;
    • 添加右邊的線段和右上角的圓角;
    • 添加頂部的線段和左上角的圓角;
    • 添加左邊的線段和左下角的圓角;
    • 閉合路徑,也就是從上一步的重點連接到起點;
  3. 設置繪制屬性:線寬、填充顏色和邊框顏色;
  4. 把路徑添加到圖形上下文,并使用 .fillStroke 參數繪制著個路徑,這個參數將會告訴 Core Graphics 這條路徑需要填充顏色并繪制邊框。

計算位置

使用 Core Graphics 進行繪制的過程其實就是計算各個視覺元素在視圖中位置的過程。所以我們需要關心的就是把不同的元素放置在哪,以及當視圖的大小發生變化時它們該如何應對。

我們準備這樣布局我們的視圖:

打開 GraphView.swift 并添加這個擴展:

// MARK: - 用于計算的 extension

extension GraphView {
  // 1
  func pieChartRectangle() -> CGRect {
    let width = bounds.size.width * Constants.pieChartWidthPercentage - 2 * Constants.marginSize
    let height = bounds.size.height - 2 * Constants.marginSize
    let diameter = max(min(width, height), Constants.pieChartMinRadius)
    let rect = CGRect(x: Constants.marginSize,
                      y: bounds.midY - diameter / 2.0,
                      width: diameter, height: diameter)
    return rect
  }
  
  // 2
  func barChartRectangle() -> CGRect {
    let pieChartRect = pieChartRectangle()
    let width = bounds.size.width - pieChartRect.maxX - 2 * Constants.marginSize
    let rect = CGRect(x: pieChartRect.maxX + Constants.marginSize,
                      y: pieChartRect.midY + Constants.marginSize,
                      width: width, height: barHeight)
    return rect
  }
  
  // 3
  func barChartLegendRectangle() -> CGRect {
    let barchartRect = barChartRectangle()
    let rect = barchartRect.offsetBy(dx: 0.0, dy: -(barchartRect.size.height + Constants.marginSize))
    return rect
  }
}

以上代碼做了這些必要的計算:

  1. 從計算餅圖的位置開始入手,它將會垂直居中,并占據視圖 1/3 的寬度;
  2. 計算條形圖的位置,它將會占據 2/3 的寬度,并處于視圖中部偏上的位置;
  3. 根據餅圖的最小 Y 值和邊距來計算圖例的位置。

現在我們來把把它繪制到你的視圖中去,在 GraphView 的用于繪制的擴展中加入:

func drawBarGraphInContext(context: CGContext?) {
  let barChartRect = barChartRectangle()
  drawRoundedRect(rect: barChartRect, inContext: context,
                  radius: Constants.barChartCornerRadius,
                  borderColor: barChartAvailableLineColor.cgColor,
                  fillColor: barChartAvailableFillColor.cgColor)
}

你需要一個 Helper 方法來繪制條形圖,它會繪制一個圓角矩形,并使用畫筆顏色和填充顏色在空白處繪制圖形,你可以在 NSColor+DiskInfo 擴展中找到這個些顏色。

draw(_:) 方法里的所有代碼替換成:

super.draw(dirtyRect)
      
let context = NSGraphicsContext.current()?.cgContext
drawBarGraphInContext(context: context)

這段代碼會真正地把圖形繪制到視圖中去。首先,通過調用 NSGraphicsContext.current(),我們獲取到了當前視圖的圖形上下文,然后我們調用剛剛編寫的方法繪制出了條形圖。

編譯并運行,你可以看到條形圖已經就位了:

現在,打開 Main.storyboard 并選中 View Controller Scene, 你會看到這個:

Interface Builder 為你實時渲染了預覽。你可以試著去修改一下顏色,它也會實時響應你的修改,是不是很棒棒呀??~

剪切一部分區域(也就是蒙版)

現在我們來制作文件分布圖,也就是這個家伙:

先暫停一下下,我們來理理思路。顯而易見的是,每種文件都有自己的專屬顏色,我們的 app 只需要根據這種文件占硬盤空間大小的百分比來計算每個方塊的寬度,然后用對應的顏色把它繪制出來。

你需要繪制一個不規則的圖形,比如一個!@#¥%%。然而,我們可以通過一個叫 clipping areas(蒙板) 的技術來避免編寫重復代碼。

??這一段的第一句實在沒看懂,求指正:You could create a special shape, such as a filled rectangle with two lines at bottom and top of the rectangle。

你可以把蒙版想象成「在一張紙上剪了個窟窿」,你只能透過這個窟窿看到部分的圖形。這個「窟窿」就叫做「Clipping Mask(剪切蒙版)」,你需要在 Core Graphics 里定義它。

在這個條形圖的例子里,你需要為每種文件分類創建一個完整的圓角矩形,然后通過剪切蒙版來使它們只顯示正確的部分:

理論說完了,我們來動手吧~

開支繪制之前,你需要為選中的硬盤設置 fileDistribution。打開 Main.storyboard,我們來創建一個 Outlet 連接。

在項目導航器里按住 Option? 鍵的同時點擊 ViewController.swift,使它顯示在右半邊的協助編輯器里,然后按住 Control? 鍵的同時把 Graph View 拖動到 View Controller 的代碼里。

在彈出的小氣泡里,把這個 Outlet 命名為 graphView,并點擊 Connect

打開 ViewController.swift 并把這行代碼添加到 showVolumeInfo(_:) 的末尾:

graphView.fileDistribution = volume.fileDistribution

這行代碼設置了 fileDistribution 的值,從而讓 Graph View 能獲取各類文件占的百分比。

打開 GraphView.swift,把這些代碼添加到 drawBarGraphInContext(context:) 的末尾來繪制條形圖:

// 1
if let fileTypes = fileDistribution?.distribution, let capacity = fileDistribution?.capacity, capacity > 0 {
  var clipRect = barChartRect
  // 2
  for (index, fileType) in fileTypes.enumerated() {
    // 3
    let fileTypeInfo = fileType.fileTypeInfo
    let clipWidth = floor(barChartRect.width * CGFloat(fileTypeInfo.percent))
    clipRect.size.width = clipWidth
        
    // 4
    context?.saveGState()
    context?.clip(to: clipRect)

    let fileTypeColors = colorsForFileType(fileType: fileType)
    drawRoundedRect(rect: barChartRect, inContext: context,
                    radius: Constants.barChartCornerRadius,
                    borderColor: fileTypeColors.strokeColor.cgColor,
                    fillColor: fileTypeColors.fillColor.cgColor)
    context?.restoreGState()
        
    // 5
    clipRect.origin.x = clipRect.maxX
  }
}

這些代碼做了這些事兒:

  1. 先確保了 Graph View 擁有一個有效的 fileDistribution
  2. 遍歷 fileDistribution 里的每一種文件類型;
  3. 根據文件的占比計算蒙版的大小;
  4. 存儲圖形上下文的狀態,設置蒙版的大小,用文件類型對應的顏色繪制圓角矩形,并恢復圖形上下文的狀態;
  5. 把剪切蒙版的 x 移動到正確的位置。

你可能會奇怪:為什么要先存儲,再恢復圖形上下文?還記得「painter’s model」嗎?你添加到圖形上下文里的所有東西都會被保存在上下文中,就像你畫在紙上的畫,會一直在那里。

如果你添加了多個剪切蒙版,事實上你是只創建了一個剪切蒙版,并應用到所有矩形上。要避免這種情況,你需要在添加新的剪切蒙版之前存儲上下文的狀態,等你使用完了這個蒙版,再把它恢復出來,再處理新的蒙版。

此時,Xcode 會彈出一個警告,因為 index 從沒被使用過。別擔心,它的待會兒就會派上用場。

編譯并運行,或者直接打開 Main.storyboard

哈哈,DiskInfo 功能似乎已經漸漸完善了呢~除了圖例,這個條形圖已經基本完工了??。

繪制文本

在自定義視圖里繪制文本特別簡單,你需要為這個文本的各種屬性創建一個字典,包含了字體、尺寸、顏色和對齊,把它傳入 Stringdraw(in:withAttributes:) 方法。這些屬性將會在我們計算矩形大小和位置的時候派上用場。

打開 GraphView.swift,把這個屬性添加到類的定義里:

fileprivate var bytesFormatter = ByteCountFormatter()

這將會創建一個 ByteCountFormatter。它會幫我們完成「把字節轉化成人話」這個高深而繁重的工作。

現在,在 drawBarGraphInContext(context:) 方法的 for (index,fileType) in fileTypes.enumerated() 循環里加入這些代碼:

// 1
let legendRectWidth = (barChartRect.size.width / CGFloat(fileTypes.count))
let legendOriginX = barChartRect.origin.x + floor(CGFloat(index) * legendRectWidth)
let legendOriginY = barChartRect.minY - 2 * Constants.marginSize
let legendSquareRect = CGRect(x: legendOriginX, y: legendOriginY,
                              width: Constants.barChartLegendSquareSize,
                              height: Constants.barChartLegendSquareSize)

let legendSquarePath = CGMutablePath()
legendSquarePath.addRect( legendSquareRect )
context?.addPath(legendSquarePath)
context?.setFillColor(fileTypeColors.fillColor.cgColor)
context?.setStrokeColor(fileTypeColors.strokeColor.cgColor)
context?.drawPath(using: .fillStroke)

// 2
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = .byTruncatingTail
paragraphStyle.alignment = .left
let nameTextAttributes = [
  NSFontAttributeName: NSFont.barChartLegendNameFont,
  NSParagraphStyleAttributeName: paragraphStyle]

// 3
let nameTextSize = fileType.name.size(withAttributes: nameTextAttributes)
let legendTextOriginX = legendSquareRect.maxX + Constants.legendTextMargin
let legendTextOriginY = legendOriginY - 2 * Constants.pieChartBorderWidth
let legendNameRect = CGRect(x: legendTextOriginX, y: legendTextOriginY,
                            width: legendRectWidth - legendSquareRect.size.width - 2 *
                              Constants.legendTextMargin,
                            height: nameTextSize.height)

// 4
fileType.name.draw(in: legendNameRect, withAttributes: nameTextAttributes)

// 5
let bytesText = bytesFormatter.string(fromByteCount: fileTypeInfo.bytes)
let bytesTextAttributes = [
  NSFontAttributeName: NSFont.barChartLegendSizeTextFont,
  NSParagraphStyleAttributeName: paragraphStyle,
  NSForegroundColorAttributeName: NSColor.secondaryLabelColor]
let bytesTextSize = bytesText.size(withAttributes: bytesTextAttributes)
let bytesTextRect = legendNameRect.offsetBy(dx: 0.0, dy: -bytesTextSize.height)
bytesText.draw(in: bytesTextRect, withAttributes: bytesTextAttributes)

看起來這一大堆代碼還挺唬人的,其實很簡單:

  1. 你已經很熟悉這一段代碼了:計算圖例的彩色方塊的位置,為它創建一條路徑,并用對應的顏色填充;
  2. 創建一個字典,包含了兩個屬性:字體和 NSMutableParagraphStyle。后者會定義這些文本會怎樣在給定的矩形里被繪制出來。在這個例子中,文本會顯示為左對齊,且若文本超出了矩形范圍,系統會在他的末尾加上省略號;
  3. 計算用于繪制文本的矩形的位置和大小;
  4. 調用 draw(in:withAttributes:),繪制文本;
  5. 使用 bytesFormatter 獲取文本,并設置「文件大小」的文本的屬性。這和之前唯一的區別是:這個文本用 NSFontAttributeName 設置了一個不同的顏色。

編譯并運行,或者前往 Main.storyboard

熱烈祝賀條形圖殺青成功!你現在可以調整一下窗口的大小,看看圖例里的文本時如何給自己加上省略號來適應狹小的空間的。

給自己鼓個掌吧??~

Cocoa Drawing

macOS 還提供了使用 AppKit 的框架來進行繪制的選項。它將會提供更高層的抽象繪圖法。它使用各種類來代替 C 語言的函數,它還包含了許多 Helper 方法來更輕松地應對常見繪圖任務。在兩個框架中,圖形上下文是一樣的。如果你對 Core Graphics 很熟悉的話,你應該能很輕松地掌握 Cocoa Drawing。

和 Core Graphics 一樣,你需要創建并繪制路徑,但在 Cocoa Drawing,我們使用 NSBezierPath,它和 CGPathRef 是一樣的。

我們要繪制的餅圖是這樣的:

你需要分三步來繪制它:

  1. 創建一條圓形的路徑,用于顯示總硬盤空間,然后用定義好的顏色繪制它;
  2. 為已用空間創建一條路徑,并繪制它;
  3. 為已用空間的路徑繪制一個漸變填充。

打開 GraphView.swift,把這個方法添加到用于繪制的 extension 里:

func drawPieChart() {
  guard let fileDistribution = fileDistribution else {
    return
  }
  
  // 1
  let rect = pieChartRectangle()
  let circle = NSBezierPath(ovalIn: rect)
  pieChartAvailableFillColor.setFill()
  pieChartAvailableLineColor.setStroke()
  circle.stroke()
  circle.fill()
  
  // 2
  let path = NSBezierPath()
  let center = CGPoint(x: rect.midX, y: rect.midY)
  let usedPercent = Double(fileDistribution.capacity - fileDistribution.available) /
    Double(fileDistribution.capacity)
  let endAngle = CGFloat(360 * usedPercent)
  let radius = rect.size.width / 2.0
  path.move(to: center)
  path.line(to: CGPoint(x: rect.maxX, y: center.y))
  path.appendArc(withCenter: center, radius: radius,
                                         startAngle: 0, endAngle: endAngle)
  path.close()
  
  // 3
  pieChartUsedLineColor.setStroke()
  path.stroke()
}

我們來分析一下這段代碼:

  1. init(ovalIn:) 構造方法創建一條圓形的路徑,設置它的填充顏色和筆觸顏色,然后繪制這條路徑;
  2. 為已用空間創建一條路徑:
    • 根據已用空間計算扇形的角度;
    • 移動到大圓的圓心;
    • 添加一條從圓心到圓的右頂點的線段;
    • 根據之前計算的角度添加一條圓弧;
    • 閉合圖形,也就是從圓弧的終點連接到圓心;
  3. 調用 stroke() 方法,設置筆觸顏色;

你應該能發現這段代碼和之前的區別:

  • 代碼中沒有提到過圖形上下文,因為我們調用的方法會自動獲取當前的上下文,在這個例子中,就是視圖自己的圖形上下文;
  • 角是以角度制計算,而不是弧度制。CGFloat+Radians.swift 擴展了 CGFloat 類來進行了自動轉換。

現在把這行代碼添加到 draw(_:) 方法中來繪制餅圖:

drawPieChart()

編譯并運行:

進展不錯!

繪制漸變

Cocoa Drawing 使用 NSGradient 來繪制漸變。

你需要在已使用的扇形里繪制漸變,該怎么實現呢……???

沒錯,用剪切蒙版啊!

你已經創建了一條路徑來繪制已用空間的扇形,在我們繪制漸變之前,先來把它用作剪切蒙版。

把這些代碼添加到 drawPieChart() 方法中:

if let gradient = NSGradient(starting: pieChartGradientStartColor,
                             ending: pieChartGradientEndColor) {
  gradient.draw(in: path, angle: Constants.pieChartGradientAngle)
}

第一行代碼會試著去創建一個兩種顏色構成的漸變。如果創建成功了,就會調用 draw(in:angle:) 方法來繪制它。在大括號里,這個方法會設置蒙版,并在蒙版區域內繪制漸變。是不是特別棒~

編譯并運行:

練習/挑戰:繪制餅圖的圖例

現在我們的自定義視圖已經越來越完美了,但還有一個待辦事項:繪制餅圖的圖例,也就是其內部的文字說明。

你已經知道該怎么去做了,準備好接受挑戰了嘛???

一些小提示:

  1. 使用 bytesFormatter 來獲取硬盤的可用空間(fileDistribution.available 屬性)和總空間(fileDistribution.capacity 屬性);
  2. 計算文本的位置,確保你的文本顯示在各個扇形的中央;
  3. 在計算好的位置用以下屬性繪制文本:
    • Font:NSFont.pieChartLegendFont
    • Used space text color:NSColor.pieChartUsedSpaceTextColor
    • Available space text color:NSColor.pieChartAvailableSpaceTextColor

答案:繪制圖例

把這些代碼添加到 drawPieChart() 方法中:

// 1
let usedMidAngle = endAngle / 2.0
let availableMidAngle = (360.0 - endAngle) / 2.0
let halfRadius = radius / 2.0

// 2
let usedSpaceText = bytesFormatter.string(fromByteCount: fileDistribution.capacity)
let usedSpaceTextAttributes = [
  NSFontAttributeName: NSFont.pieChartLegendFont,
  NSForegroundColorAttributeName: NSColor.pieChartUsedSpaceTextColor]
let usedSpaceTextSize = usedSpaceText.size(withAttributes: usedSpaceTextAttributes)
let xPos = rect.midX + CGFloat(cos(usedMidAngle.radians)) *
  halfRadius - (usedSpaceTextSize.width / 2.0)
let yPos = rect.midY + CGFloat(sin(usedMidAngle.radians)) *
  halfRadius - (usedSpaceTextSize.height / 2.0)
usedSpaceText.draw(at: CGPoint(x: xPos, y: yPos),
                   withAttributes: usedSpaceTextAttributes)

// 3
let availableSpaceText = bytesFormatter.string(fromByteCount: fileDistribution.available)
let availableSpaceTextAttributes = [
  NSFontAttributeName: NSFont.pieChartLegendFont,
  NSForegroundColorAttributeName: NSColor.pieChartAvailableSpaceTextColor]
let availableSpaceTextSize = availableSpaceText.size(withAttributes: availableSpaceTextAttributes)
let availableXPos = rect.midX + cos(-availableMidAngle.radians) *
  halfRadius - (availableSpaceTextSize.width / 2.0)
let availableYPos = rect.midY + sin(-availableMidAngle.radians) *
  halfRadius - (availableSpaceTextSize.height / 2.0)
availableSpaceText.draw(at: CGPoint(x: availableXPos, y: availableYPos),
                        withAttributes: availableSpaceTextAttributes)

代碼含義:

  1. 計算兩個區域的角度;
  2. 創建已用空間的文本的屬性,并計算其 xy,然后繪制它;
  3. 創建總空間的文本的屬性,并計算其 xy,然后繪制它;

現在,編譯并運行你的 app,好好欣賞一下你的杰出作品:

恭喜你!你使用 Core Graphics 和 Cocoa Drawing 創建了一個美麗的 app!

接下來該做啥?

你可以點擊這里下載完整的工程文件。

這個 macOS Core Graphics 教程覆蓋了 macOS 中用于繪制自定義視圖的不同框架的基本知識:

  • 如何使用 Core Graphics 和 Cocoa Drawing 創建和繪制路徑;
  • 如何剪切一個區域;
  • 如何繪制文本串;
  • 如何繪制漸變。

之后的日子里,當你需要創建一些整潔、優美的用戶界面的時候,你應該能自信地拿出 Core Graphics 和 Cocoa Drawing 揮灑創意了。

如果你還想繼續深入,可以參考這些資源:

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,048評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,414評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,169評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,722評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,465評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,823評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,813評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,000評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,554評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,295評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,513評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,035評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,722評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,125評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,430評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,237評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,482評論 2 379

推薦閱讀更多精彩內容