Core Text Tutorial for iOS : Making a Magazine App 翻譯

躍然發表於2017-12-07

原文:https://www.raywenderlich.com/153591/core-text-tutorial-ios-making-magazine-app

Core Text 是一個底層文字引擎,當與 Core Graphics/ Quartz 框架一起使用時,它可以對佈局和格式進行細粒度的控制。

在 iOS 7 時候,Apple 釋出了 TextKit 類庫,它可以儲存、列出和顯示帶有各種排版特徵的文字。雖然 TextKit 功能強大,在佈局文字時已經足夠強大,但相對而言, Core Text 可以提供更多的控制。例如,如果你需要直接使用 Quartz,那麼 Core Text 就可以。如果你需要構建自己的佈局引擎,Core Text 將幫助你生成 “字形”,並將它們相對地放置在精細排版中。

本教程通過使用 Core Text 建立一個非常簡的單雜誌應用… Zombies !

開始

開啟 Xcode ,基於 Single View Application Template 建立一個新的 Swift universal project ,並命名為 CoreTextMagazine。

然後,新增 Core Text framework 到我們的工程中:
1. 單擊專案導航器中的專案檔案。
2. 在 “General” 下,滾動到下面的 “Linked Frameworks and Libraries”。
3. 單擊 “+”, 並搜尋 “CoreText”。
4. 選擇 “CoreText.framework” ,然後單擊 “Add” 按鈕。

現在工程已經建好了,是時候開始編碼了。

新增一個 Core Text View

作為開始,我們將建立一個 UIView,在它的 draw(_:) 方法內將使用 Core Text。

建立一個 Cocoa Touch Class 檔案,它繼承自 UIView 。將其命名為 CTView 。

開啟 CTView.swift,新增如下程式碼:

import CoreText

而後,設定這個自定義的 view 為應用的主檢視。開啟 Main.storyboard,開啟右邊的 Utilities 選單,選中它上邊 toolbar 的 Identity Inspector 按鈕,Interface Builder 左邊選單,選中 View。Utilities 選單的 Class 文字框現在應該為 UIView。鍵入文字框 CTView ,將其設定為主檢視控制器的 View。

然後,開啟 CTView.swift ,替換方法如下:

//1      
override func draw(_ rect: CGRect) {         
  // 2       
  guard let context = UIGraphicsGetCurrentContext() else { return }      
  // 3       
  let path = CGMutablePath()         
  path.addRect(bounds)       
  // 4
  let attrString = NSAttributedString(string: "Hello World")
  // 5
  let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
  // 6
  let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attrString.length), path, nil) 
  // 7
  CTFrameDraw(frame, context)
}

讓我逐行解釋下上面的程式碼。
1. 一旦 view 建立,draw(_:) 將自動執行來渲染 view 下的 layer。
2. 開啟我們將用於繪圖的當前圖形上下文。
3. 建立一個路徑,該路徑限制繪圖區域,整個檢視的邊界在該控制下。
4. 在 Core Text 中,使用 NSAttributedString 來儲存文字及其屬性,而不是 String 或 NSString。初始 “Hello World” 作為一個帶屬性的字串。
5. CTFramesetterCreateWithAttributedString 建立一個使用提供的屬性字串的 CTFramesetter,CTFramesetter 將管理我們的字型引用和繪圖框架。
6. 建立 CTFrame,通過擁有 CTFramesetterCreateFrame 使整個字串在路徑中呈現。
7. CTFrameDraw 在給定的上下文中繪製 CTFrame 。

這就是我們要繪製一些簡單的文字所需要的所有步驟。 Build and Run,我們可以看到執行結果。

Uh-oh… 這似乎哪裡不對,不是嗎?和許多低階 api 一樣,Core Text 使用了一個 “y -翻轉” 座標系。更糟糕的是,內容也跟著垂直翻轉了!

在 guard let context 程式碼下面新增如下程式碼,解決方向錯亂問題:

// Flip the coordinate system
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)

此程式碼通過向檢視的上下文中應用轉換來翻轉內容。

Build and run 應用。先忽略狀態列的重疊,以後我們將會學習如何解決這個問題。

恭喜,我們已經建立了屬於自己的第一個 Core Text 應用。

Core Text Object Model

如果你對 CTFramesetter 和 CTFrame 有點困惑,沒關係,我們現在講解一下它。

以下為 Core Text 物件模型的樣子:

Core Text Class Hierarchy

當我們建立一個 CTFramesetter 引用併為它提供一個 NSAttributedString 時,將自動建立一個 CTTypesetter 例項來管理字型。接下來,使用 CTFramesetter 建立一個或多個將呈現文字的框架。

當我們建立一個 frame 時,我們提供文字的 subrange,並在它的矩形內渲染文字。Core Text 自動為每一行文字建立一個 CTLine,每段文字 的 CTLine 有相同的格式。例如,如果在一行中有多個紅色單詞,Core Text 將為這幾個詞建立一個 CTRun,然後為剩下的純文字建立另一個 CTRun,再為另一些粗體的詞句建立一個 CTRun,等等。此外,Core Text 根據提供的 NSAttributedString 的屬性為我們建立 CTRuns。每一個 CTRuns 控制元件都可以採用不同的屬性,因此我們可以對 kerning、ligUNK、width、height 等屬性進行很好的控制。

Onto the Magazine App!

下載資源壓縮包the zombie magazine materials.

拖拽資料夾到我們的 Xcode 工程中,當彈框提醒的時候,確定 Copy items if needed 和 Create groups 是選中的。

建立 app ,我們需要將各種屬性應用於文字。我們將建立一個簡單的文字標記解析器,它將使用標記來設定雜誌的格式。

建立一個新的 Cocoa Touch 類,讓它繼承自 NSObject ,命名為 MarkupParser。

首先,快速瀏覽一下 zombies.txt。看看它是如何在文字中包含有括號的格式標記的。 “img src” 標籤指向雜誌圖片和 “font color/face” 標籤決定文字顏色和字型。

開啟 MarkupParser.swift 檔案,替換如下內容:

import UIKit
import CoreText

class MarkupParser: NSObject {

  // MARK: - Properties
  var color: UIColor = .black
  var fontName: String = "Arial"
  var attrString: NSMutableAttributedString!
  var images: [[String: Any]] = []

  // MARK: - Initializers
  override init() {
    super.init()
  }

  // MARK: - Internal
  func parseMarkup(_ markup: String) {

  }
}

這裡新增屬性,用來儲存字型和文字顏色;設定預設值;建立一個變數來儲存 parseMarkup(_:) 產生的屬性字串;並建立了一個陣列,它最終將儲存字典資訊,定義在文字中發現的影像的大小、位置和檔名。

編寫解析器通常很困難,但本教程的解析器非常簡單,只支援開啟標籤——這意味著標籤將設定文字的樣式,直到找到新的標記為止。文字標記將是這樣的:

These are <font color="red">red<font color="black"> and
<font color="blue">blue <font color="black">words.

輸出像這樣:

新增如下方法到 parseMarkup(_:):

//1
attrString = NSMutableAttributedString(string: "")
//2 
do {
  let regex = try NSRegularExpression(pattern: "(.*?)(<[^>]+>|\\Z)",
                                      options: [.caseInsensitive,
                                                .dotMatchesLineSeparators])
  //3
  let chunks = regex.matches(in: markup, 
                             options: NSRegularExpression.MatchingOptions(rawValue: 0), 
                             range: NSRange(location: 0,
                                            length: markup.characters.count))
} catch _ {
}
  1. attrString開始是空的,但最終會包含解析後的標記。
  2. 這個正規表示式,它意思是說,“通過字串查詢,直到找到一個開頭的括號,然後檢視字串,直到找到一個結束括號(或者文件的末尾)。”
  3. 搜尋 regex 匹配的整個標記範圍,然後生成一個 NSTextCheckingResults 結果陣列。

注:想更多瞭解正規表示式,看這裡 NSRegularExpression Tutorial.

現在我們已經將所有文字和格式化標記解析成塊,接下來我們將遍歷塊來構建屬性字串。

但在此之前,我們是否注意到如何匹配 (in:options:range:) 接受一個 NSRange 作為引數呢?當你將 ns 正規表示式函式應用於你的標記字串時,將會有很多 NSRange 到 Range 轉換。Swift 一直是我們所有人的好朋友,關鍵時刻,它給予我們幫助。

在 MarkupParser.swift 中,將下面的擴充套件新增到檔案的末尾:

// MARK: - String
extension String {
  func range(from range: NSRange) -> Range<String.Index>? {
    guard let from16 = utf16.index(utf16.startIndex,
                                   offsetBy: range.location,
                                   limitedBy: utf16.endIndex),
      let to16 = utf16.index(from16, offsetBy: range.length, limitedBy: utf16.endIndex),
      let from = String.Index(from16, within: self),
      let to = String.Index(to16, within: self) else {
        return nil
   }

    return from ..< to
  }
}

該函式將字串的開始和結束索引轉換為由 NSRange表示的字串,String.UTF16View.Index 格式,即字串中 utf - 16 程式碼單元集合中的位置; 然後轉換每個 String.UTF16View.Index 到 String.Index 格式。只要索引是有效的,該方法將返回代表原 NSRange的 Range。

現在是返回處理文字和標記塊的時間了。

在 parseMarkup(_:) 內新增以下 let chunks (在do塊內):

let defaultFont: UIFont = .systemFont(ofSize: UIScreen.main.bounds.size.height / 40)
//1
for chunk in chunks {  
  //2
  guard let markupRange = markup.range(from: chunk.range) else { continue }
  //3    
  let parts = markup[markupRange].components(separatedBy: "<")
  //4
  let font = UIFont(name: fontName, size: UIScreen.main.bounds.size.height / 40) ?? defaultFont       
  //5
  let attrs = [NSAttributedStringKey.foregroundColor: color, NSAttributedStringKey.font: font] as [NSAttributedStringKey : Any]
  let text = NSMutableAttributedString(string: parts[0], attributes: attrs)
  attrString.append(text)
}
  1. 迴圈遍歷 chunks.
  2. 獲取當前的 NSTextCheckingResult 的範圍,開啟Range
// 1
if parts.count <= 1 {
  continue
}
let tag = parts[1]
//2
if tag.hasPrefix("font") {
  let colorRegex = try NSRegularExpression(pattern: "(?<=color=\")\\w+", 
                                           options: NSRegularExpression.Options(rawValue: 0))
  colorRegex.enumerateMatches(in: tag, 
    options: NSRegularExpression.MatchingOptions(rawValue: 0), 
    range: NSMakeRange(0, tag.characters.count)) { (match, _, _) in
      //3
      if let match = match,
        let range = tag.range(from: match.range) {
          let colorSel = NSSelectorFromString(tag[range]+"Color")
          color = UIColor.perform(colorSel).takeRetainedValue() as? UIColor ?? .black
      }
  }
  //5    
  let faceRegex = try NSRegularExpression(pattern: "(?<=face=\")[^\"]+",
                                          options: NSRegularExpression.Options(rawValue: 0))
  faceRegex.enumerateMatches(in: tag, 
    options: NSRegularExpression.MatchingOptions(rawValue: 0), 
    range: NSMakeRange(0, tag.characters.count)) { (match, _, _) in

      if let match = match,
        let range = tag.range(from: match.range) {
          fontName = String(tag[range])
      }
  }
} //end of font parsing
  1. 如果 parts.count 小於2,跳過迴圈體的其餘部分。否則,將第二部分儲存為 tag。
  2. 如果 tag 以 “font” 開始,建立一個 regex 來查詢字型的 “color” 值,然後使用該 regex 通過標記的匹配 “color” 值來列舉。在本例中,應該只有一個匹配的顏色值。

  3. 如果 enumerateMatches(in:options:range:using:) 返回一個有效的 match , match 中含有一個有效的 tag,查詢指定的值(ex . returns ” red “),並追加” color “以形成UIColor選擇器。

  4. 同樣,建立一個 regex 來處理字型的 “face” 值。如果找到匹配,則將 fontName 設定為該字串。

Great job! 現在,parseMarkup(_:) 可以獲取標記併為Core Text 生成一個NSAttributedString。
現在是時候把你的應用程式餵給一些殭屍了!我的意思是,給你的應用喂一些殭屍… zombies.txt )
它實際上是一個 UIView 的工作,顯示給它的內容,而不是載入內容。開啟 CTView.swift 並新增以下的draw(_:):

// MARK: - Properties
var attrString: NSAttributedString!

// MARK: - Internal
func importAttrString(_ attrString: NSAttributedString) {
  self.attrString = attrString
}

接下來,從 draw(_:) 中刪除 attrString = NSAttributedString(string: “Hello World”)。

在這裡,我們建立了一個例項變數來儲存帶屬性的字串,以及將其從 app 的其他地方設定的方法。

接下來, 開啟 ViewController.wift 並將以下內容新增到viewDidLoad():

// 1
guard let file = Bundle.main.path(forResource: "zombies", ofType: "txt") else { return }

do {
  let text = try String(contentsOfFile: file, encoding: .utf8)
  // 2
  let parser = MarkupParser()
  parser.parseMarkup(text)
  (view as? CTView)?.importAttrString(parser.attrString)
} catch _ {
}

詳解如下:

  1. 載入 zombie.txt 中的文字到一個 String。
  2. 建立一個新的解析器,在文字中輸入,然後將返回的屬性字串傳遞給 ViewController 的 CTView。

Build and run the app!

太棒了!得助於 50 行解析程式碼,我們可以簡單地使用一個文字檔案來儲存雜誌應用程式的內容了。

A Basic Magazine Layout

如果你認為一個殭屍新聞的月雜誌可以放到一個微不足道的頁面上,那你就大錯特錯了! 幸運的是 Core Text 佈局列時尤為有用, CTFrameGetVisibleStringRange 可以告訴我們多少文字將適合一個給定的框架。也就是說,你可以建立一個列,然後當它的全部被填充後,你可以再建立另一個列,等等。

對於這個 app,你必須列印列,然後是頁面,然後是一本完整的雜誌,以免冒犯不死族,所以……是時候將 CTView 子類轉換為 UIScrollView 了。

開啟 CTView.swift 和更改類 CTView 一行:

class CTView: UIScrollView {

看到殭屍了吧? 這個應用現在可以支援不死的冒險了! 是的——僅僅一行程式碼,就可以滾動和分頁了。

happy zombie

到目前為止,我們已經建立了框架和框架內 draw(_:),但是由於我們有許多不同格式的列,所以最好建立單獨的列例項。

建立一個新的 Cocoa Touch 類檔案,命名為 CTColumnView,令它繼承自 UIView。

開啟 CTColumnView.swift,新增如下程式碼:

import UIKit
import CoreText

class CTColumnView: UIView {

  // MARK: - Properties
  var ctFrame: CTFrame!

  // MARK: - Initializers
  required init(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)!
  }

  required init(frame: CGRect, ctframe: CTFrame) {
    super.init(frame: frame)
    self.ctFrame = ctframe
    backgroundColor = .white
  }

  // MARK: - Life Cycle
  override func draw(_ rect: CGRect) {
    guard let context = UIGraphicsGetCurrentContext() else { return }

    context.textMatrix = .identity
    context.translateBy(x: 0, y: bounds.size.height)
    context.scaleBy(x: 1.0, y: -1.0)

    CTFrameDraw(ctFrame, context)
  }
}

這段程式碼呈現的是 CTFrame,就像我們之前在 CTView 中所做的那樣。自定義初始化程式,init(框架:ctframe:) 集合:

  1. The view’s frame.
  2. 在上下文渲染的 CTFrame。
  3. 設定 view 的背景顏色為白色。

接下來,建立一個名為 CTSettings.swift 的新的檔案,它用來進行列設定。

用以下程式碼替換 CTSettings.swift 內容:

import UIKit
import Foundation

class CTSettings {
  //1
  // MARK: - Properties
  let margin: CGFloat = 20
  var columnsPerPage: CGFloat!
  var pageRect: CGRect!
  var columnRect: CGRect!

  // MARK: - Initializers
  init() {
    //2
    columnsPerPage = UIDevice.current.userInterfaceIdiom == .phone ? 1 : 2
    //3
    pageRect = UIScreen.main.bounds.insetBy(dx: margin, dy: margin)
    //4
    columnRect = CGRect(x: 0,
                        y: 0,
                        width: pageRect.width / columnsPerPage,
                        height: pageRect.height).insetBy(dx: margin, dy: margin)
  }
}
  1. 屬性將決定頁邊距(本教程的預設值為20),每頁的列數,每一頁的 frame,每一頁 frame 大小。
  2. 由於該雜誌同時提供 iPhone 和 iPad 上的殭屍,在 iPad 上顯示兩列,在 iPhone 上顯示一列,因此每一個螢幕的大小都是合適的。
  3. 設定 pageRect 為 UIScreen.main.bounds.insetBy(dx: margin, dy: margin)。
  4. 設定 columnRect 為將 pageRect 的寬度除以每一頁的列數,併除去邊距。

開啟 CTView.swift, 做如下替換 :

import UIKit
import CoreText

class CTView: UIScrollView {

  //1
  func buildFrames(withAttrString attrString: NSAttributedString,
                   andImages images: [[String: Any]]) {
    //3
    isPagingEnabled = true
    //4
    let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
    //4
    var pageView = UIView()
    var textPos = 0
    var columnIndex: CGFloat = 0
    var pageIndex: CGFloat = 0
    let settings = CTSettings()
    //5
    while textPos < attrString.length {
    }
  }
}

註釋如下:
1. buildFrames(withAttrString:andImages:) 將建立 CTColumnViews,然後新增它們到 scrollview。
2. 啟用scrollview的分頁功能;每當使用者停止滾動時,滾動檢視就會快速地顯示出一個完整的頁面。
3. CTFramesetter framesetter 將為屬性文字建立每個列的CTFrame。
4. UIView pageViews 作為每個頁面的列子檢視的容器; textPos 將跟蹤下一個字元; columnIndex 將跟蹤當前列; pageIndex 將跟蹤當前頁面; settings 允許你訪問應用程式的 margin 大小,每一頁的列,page frame 和 column frame 設定。
5. 我們將遍歷 attrString 並按列列出文字列,直到當前文字位置到達結束為止。

是時候新增 looping attrString 了,在 while textPos < attrString.length {.: 方法內新增如下程式碼:

//1
if columnIndex.truncatingRemainder(dividingBy: settings.columnsPerPage) == 0 {
  columnIndex = 0
  pageView = UIView(frame: settings.pageRect.offsetBy(dx: pageIndex * bounds.width, dy: 0))
  addSubview(pageView)
  //2
  pageIndex += 1
}   
//3
let columnXOrigin = pageView.frame.size.width / settings.columnsPerPage
let columnOffset = columnIndex * columnXOrigin
let columnFrame = settings.columnRect.offsetBy(dx: columnOffset, dy: 0)
  1. 如果 column index 被每頁的列數相除等於 0 ,則表示該列是其頁面上的第一個列,建立一個新的 page檢視來儲存列。使用邊緣 settings.pageRect 設定它的幀。x offset 為當前頁 index 乘以螢幕寬度。當頁面滾動時,每個雜誌頁面將位於前一個頁面的右側。
  2. 自增 pageIndex。
  3. pageView 的寬度除以 settings.columnsPerPage 獲得第一列的 x 座標; x 座標乘以 column index 獲得列偏移。然後用標準列向量來建立當前列的 frame,並通過 columnOffset 來抵消它的 x 原點。

接下來,在 columnFrame initialization 下面新增如下程式碼。

//1   
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: columnFrame.size))
let ctframe = CTFramesetterCreateFrame(framesetter, CFRangeMake(textPos, 0), path, nil)
//2
let column = CTColumnView(frame: columnFrame, ctframe: ctframe)
pageView.addSubview(column)
//3
let frameRange = CTFrameGetVisibleStringRange(ctframe)
textPos += frameRange.length
//4
columnIndex += 1
  1. 建立一個 CGMutablePath,然後從textPos 開始,呈現一個新的 CTFrame,包含儘可能多的文字。
  2. 使用 CGRect columnFrame 和 CTFrame ctframe 建立一個 CTColumnView ,新增列到 pageView。
  3. 使用 CTFrameGetVisibleStringRange(_:) 來計算文字列中包含的範圍,然後 textPos +frameRange.length 來反映當前文字的位置。
  4. 在迴圈到下一列之前,將列索引增加1。

最後,在迴圈之後設定滾動檢視的內容大小:

contentSize = CGSize(width: CGFloat(pageIndex) * bounds.size.width,
                     height: bounds.size.height)

通過將內容大小設定為螢幕寬度乘以頁面數,zombies 現在可以滾動到最後了。

開啟 ViewController.swift ,替換

(view as? CTView)?.importAttrString(parser.attrString)

(view as? CTView)?.buildFrames(withAttrString: parser.attrString, andImages: parser.images)

在 iPad 上執行 app,左右拖動到頁面之間,檢查雙列布局。看起來不錯.:]

我們有了列和格式化的文字,但還缺少影像。使用 Core Text 繪製影像並不是那麼簡單——畢竟它是一個文字框架——但是在我們已經建立的標記解析器的幫助下,新增影像不應該太糟糕。

Drawing Images in Core Text

雖然 Core Text 不能繪製影像,但作為一個佈局引擎,它可以留出空白空間來為影像騰出空間。通過設定 CTRun 的 delegate,我們可以確定 CTRun 的 ascent space, descent space and width。像下面這樣:

CTRunDelegate.jpg

當 Core Text 獲得一個帶有 CTRunDelegate 的 CTRun 類時,它會詢問委託,“我應該留出多少空間來處理這段資料?” 通過在CTRunDelegate中設定這些屬性,我們可以在文字中為我們的影像留下空間。

首先,新增 “img” 標籤。開啟 MarkupParser.swift,找到 “} //end of font parsing”,在其後新增如下程式碼:

//1
else if tag.hasPrefix("img") { 

  var filename:String = ""
  let imageRegex = try NSRegularExpression(pattern: "(?<=src=\")[^\"]+",
                                           options: NSRegularExpression.Options(rawValue: 0))
  imageRegex.enumerateMatches(in: tag, 
    options: NSRegularExpression.MatchingOptions(rawValue: 0), 
    range: NSMakeRange(0, tag.characters.count)) { (match, _, _) in

    if let match = match,
      let range = tag.range(from: match.range) {
        filename = String(tag[range])
    }
  }
  //2
  let settings = CTSettings()
  var width: CGFloat = settings.columnRect.width
  var height: CGFloat = 0

  if let image = UIImage(named: filename) {
    height = width * (image.size.height / image.size.width)
    // 3
    if height > settings.columnRect.height - font.lineHeight {
      height = settings.columnRect.height - font.lineHeight
      width = height * (image.size.width / image.size.height)
    }
  }
}
  1. 如果 tag 以 “img” 開始,使用正規表示式尋找 影像的 “src” ,即 filename。
  2. 將影像寬設定為列的寬度,並設定其高度,使影像保持其高寬比。
  3. 如果影像的高度太長,則設定高為適合的列,並減小寬度以保持影像的縱橫比。由於影像後面的文字將包含空的空間屬性,包含空空間資訊的文字必須與影像匹配在同一列中。設定影像的高度為 settings.columnRect.height - font.lineHeight。

接下來,在 if let image 程式碼塊下新增如下程式碼:

//1
images += [["width": NSNumber(value: Float(width)),
            "height": NSNumber(value: Float(height)),
            "filename": filename,
            "location": NSNumber(value: attrString.length)]]
//2
struct RunStruct {
  let ascent: CGFloat
  let descent: CGFloat
  let width: CGFloat
}

let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
extentBuffer.initialize(to: RunStruct(ascent: height, descent: 0, width: width))
//3
var callbacks = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { (pointer) in
}, getAscent: { (pointer) -> CGFloat in
  let d = pointer.assumingMemoryBound(to: RunStruct.self)
  return d.pointee.ascent
}, getDescent: { (pointer) -> CGFloat in
  let d = pointer.assumingMemoryBound(to: RunStruct.self)
  return d.pointee.descent
}, getWidth: { (pointer) -> CGFloat in
  let d = pointer.assumingMemoryBound(to: RunStruct.self)
  return d.pointee.width
})
//4
let delegate = CTRunDelegateCreate(&callbacks, extentBuffer)
//5
let attrDictionaryDelegate = [(kCTRunDelegateAttributeName as NSAttributedStringKey): (delegate as Any)]              
attrString.append(NSAttributedString(string: " ", attributes: attrDictionaryDelegate))
  1. 賦值字典給變數 images,字典包含 image’s size, filename and text location。
  2. 定義 RunStruct 來儲存描述空空間的屬性。然後初始化一個指標,以包含一個 ascent 等於影像高度的 RunStruct,以及一個與影像寬度相等的 width 屬性。
  3. 建立一個 CTRunDelegateCallbacks ,它返回型別指標 RunStruct 的 ascent, descent 以及 width 屬性。
  4. 使用 CTRunDelegateCreate 建立一個 delegate 例項,來繫結 callbacks 和 引數資料(data parameter)。
  5. 建立一個包含 delegate 例項的屬性字典,然後賦值空字串給 attrString, attrString 包含了文字中空洞的位置和大小資訊。

現在,MarkupParser 正在處理“img”標記,我們需要調整 CTColumnView 和 CTView 來呈現它們。

開啟 CTColumnView.swift,在 var ctFrame:CTFrame! 新增如下程式碼,以此控制列中的圖片和frames:

var images: [(image: UIImage, frame: CGRect)] = []

接下來,新增如下程式碼到 draw(_:) 方法的底部:

for imageData in images {
  if let image = imageData.image.cgImage {
    let imgBounds = imageData.frame
    context.draw(image, in: imgBounds)
  }
}

這裡我們遍歷每個 image 並繪製它到合適的 frame。

接下來,開啟 CTView.swift 並在 class 的頂部新增如下屬性:

// MARK: - Properties
var imageIndex: Int!

當你繪製 CTColumnViews 時,imageIndex 將追蹤當前的影像 index。接下來,在 buildFrames(withAttrString:andImages:) 上面新增如下程式碼:

imageIndex = 0

這標記 images 陣列的第一個元素。

接著,在 buildFrames(withAttrString:andImages:): 下面新增如下程式碼:

func attachImagesWithFrame(_ images: [[String: Any]],
                           ctframe: CTFrame,
                           margin: CGFloat,
                           columnView: CTColumnView) {
  //1
  let lines = CTFrameGetLines(ctframe) as NSArray
  //2
  var origins = [CGPoint](repeating: .zero, count: lines.count)
  CTFrameGetLineOrigins(ctframe, CFRangeMake(0, 0), &origins)
  //3
  var nextImage = images[imageIndex]
  guard var imgLocation = nextImage["location"] as? Int else {
    return
  }
  //4
  for lineIndex in 0..<lines.count {
    let line = lines[lineIndex] as! CTLine
    //5
    if let glyphRuns = CTLineGetGlyphRuns(line) as? [CTRun], 
      let imageFilename = nextImage["filename"] as? String, 
      let img = UIImage(named: imageFilename)  { 
        for run in glyphRuns {

        }
    }
  }
}
  1. 獲取 ctframe’s CTLine objects 的陣列。
  2. 使用 CTFrameGetOrigins 拷貝 ctframe’s line origins 到 origins array。通過設定 range length 為 0,CTFrameGetOrigins 知道穿越整個 CTFrame。
  3. 設定 nextImage 來包含當前影像的屬性資料。如果 nextImage 包含影像的位置,開啟它並繼續;否則,提前返回。
  4. 迴圈遍歷 text’s lines 。
  5. 如果 line’s glyph runs, filename 和 filename 的image 都存在,迴圈 glyph runs 。

接下來,新增如下程式碼到 for-loop:

// 1
let runRange = CTRunGetStringRange(run)    
if runRange.location > imgLocation || runRange.location + runRange.length <= imgLocation {
  continue
}
//2
var imgBounds: CGRect = .zero
var ascent: CGFloat = 0       
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil))
imgBounds.size.height = ascent
//3
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
imgBounds.origin.x = origins[lineIndex].x + xOffset 
imgBounds.origin.y = origins[lineIndex].y
//4
columnView.images += [(image: img, frame: imgBounds)]
//5
imageIndex! += 1
if imageIndex < images.count {
  nextImage = images[imageIndex]
  imgLocation = (nextImage["location"] as AnyObject).intValue
}
  1. 如果當前執行的範圍不包含下一個影像,則跳過迴圈其餘部分。否則,在這裡渲染影像。
  2. 使用 CTRunGetTypographicBounds 計算 image width, 將 width 賦值給 ascent。
  3. 使用 CTLineGetOffsetForStringIndex 獲取 line 的 x offset,然後新增它到 imgBounds’ origin。
  4. 新增 image 和它的 frame到當前 CTColumnView。
  5. 增加 imageIndex。如果有影像在 imges 陣列中,更新 nextImage 和 imgLocation,以便它們指向下一個影像。

OK! Great! 只剩下最後一步了。

在方法 buildFrames(withAttrString:andImages:) 中pageView.addSubview(column) 的上部新增如下程式碼:

if images.count > imageIndex {
  attachImagesWithFrame(images, ctframe: ctframe, margin: settings.margin, columnView: column)
}

如果它們存在,附加影像。

在 iPhone 和 iPad 上面執行,效果如下:

恭喜,大功告成。

相關文章