學(xué)習(xí)CoreText入門時發(fā)現(xiàn)的文章炫狱,特此翻譯下來施绎,以資他人學(xué)習(xí)之用 原文鏈接 : https://www.raywenderlich.com/153591/core-text-tutorial-ios-making-magazine-app
Core Text
是一個可以結(jié)合Core Graphics/Quartz
框架的底層文本渲染引擎楞黄,可以提供精細(xì)的布局和格式化控制
在iOS7
翠储,蘋果發(fā)布了高層級的Text Kit
庫筐乳,它可以存儲隘马,列出并顯示具有各種排版特征的文本野宜。盡管Text Kit
非常強(qiáng)大扫步,也滿足日常文本排版的需要,但是Core Text
可以提供更精細(xì)的控制匈子。例如河胎,如果你需要直接使用Quartz
框架,可以使用Core Text
旬牲, 如果你需要打造自己獨(dú)有的排版引擎仿粹,Core Text
能幫你實(shí)現(xiàn) 對字體與相對位置相關(guān)的特征,進(jìn)行精細(xì)的控制
此教程將使用Core Text
從0到1創(chuàng)建一個簡單的雜志應(yīng)用原茅,那么開始吧
熱身
-
打開Xcode新建一個swift項(xiàng)目吭历,命名為
CoreTextMagazine
新建項(xiàng)目 -
將
CoreText.framework
導(dǎo)入工程
導(dǎo)入CoreText.framework 添加 Core Text View
創(chuàng)建CTView.swift
, 復(fù)寫draw(_:)
方法
override func draw(_ rect: CGRect) {
// 獲取當(dāng)前上下文
guard let context = UIGraphicsGetCurrentContext() else {
return;
}
//轉(zhuǎn)換成uikit坐標(biāo)系
context.textMatrix = .identity
context.translateBy(x: 0, y: rect.height)
context.scaleBy(x: 1, y: -1)
// 繪制區(qū)域路徑
let path = CGMutablePath.init()
path.addRect(rect)
// 初始化富文本
let attString = NSAttributedString.init(string: "hello word")
// 創(chuàng)建 CTFramesetter
let frameSetter = CTFramesetterCreateWithAttributedString(attString as CFAttributedString)
// 創(chuàng)建 CTFrame
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, attString.length), path, nil)
// 在指定上下文繪制CTFrame
CTFrameDraw(frame, context)
}
由于Quartz
坐標(biāo)系與UIKit坐標(biāo)系略有不同,所以需要對坐標(biāo)系進(jìn)行一次轉(zhuǎn)換擂橘,否則繪制出來的文本將會是倒置的
context.textMatrix = .identity
context.translateBy(x: 0, y: rect.height)
context.scaleBy(x: 1, y: -1)
項(xiàng)目跑一下, 成功渲染
- CoreText 對象模型
CTFramesetter
是啥晌区?CTFrame
又是啥?祭出此圖
CoreText對象模型
當(dāng)你創(chuàng)建了一個CTFramesetter
,并且為它提供了一個NSAttributedString
朗若,將會自動創(chuàng)建一個CTTypesetter
來管理字體恼五,接下來就可以使用CTFramesetter
創(chuàng)建一個或者多個CTFrame
來渲染文本
創(chuàng)建CTFrame
的時候,CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, attString.length), path, nil)
我們給定了范圍和文本的繪制路徑范圍哭懈,Core Text
自動為每一行文本創(chuàng)建了一個CTLine
灾馒,每一塊文本創(chuàng)建了一個CTRun
, 每個CTRun
能夠設(shè)置不同的屬性,例如你可以創(chuàng)建一個紅色字體的CTRun
遣总,再創(chuàng)建另一個為粗體的CTRun
睬罗,這就是為什么說Core Text
能對文本進(jìn)行精細(xì)控制的原因了
擼起袖子,開干
請下載素材鏈接:百度云鏈接:https://pan.baidu.com/s/1dF5zVkH 解壓之后導(dǎo)入工程即可
我們需要對不同的文本設(shè)置不同的屬性旭斥,我們需要新建一個文本修飾格式解析器來解析 zombies.txt
中的屬性標(biāo)簽來格式化文本
- 新建
MarkupParser.swift
繼承NSObject
我們首先可以粗看下zombies.txt
中內(nèi)容
img src
標(biāo)簽引用了圖片容达,font color/face
標(biāo)簽決定了文本的顏色和字體
在MarkupParser.swift
鍵入如下代碼
// MARK: - 屬性
var color: UIColor = .black
var fontName: String = "Arial"
var attrString: NSMutableAttributedString!
var images: [[String: Any]] = []
// MARK: - 初始化方法
override init() {
super.init()
}
// MARK: - 內(nèi)部方法,解析html文本
func parseMarkup(_ markup: String) {
}
類中帶有字體顏色垂券,字體等屬性花盐,parseMarkup(_:)
將從文本中解析成屬性文本
對于以下文本
These are <font color="red">red<font color="black"> and
<font color="blue">blue <font color="black">words.
將被解析渲染成如下樣式
- 解析標(biāo)簽
將如下代碼填入parseMarkup(_:)
方法
// attrString初始化為空,最終會被賦值最終解析結(jié)果
attrString = NSMutableAttributedString(string: "")
// 解析
do {
// 匹配標(biāo)簽塊
let regex = try NSRegularExpression.init(pattern: "(.*?)(<[^>]+>|\\Z)", options: NSRegularExpression.Options.dotMatchesLineSeparators)
let chunks = regex.matches(in: markup, options: NSRegularExpression.MatchingOptions.init(rawValue: 0), range: NSRange.init(location: 0, length: markup.count))
} catch _ {
}
正則匹配出所有的標(biāo)簽塊
現(xiàn)在所有的匹配結(jié)果都在
chunks
變量中菇爪,只需要遍歷chunks
來創(chuàng)建AttributedString
即可
在此之前算芯,我們注意到matches(in:options:range:)
接受了一個NSRange
類型參數(shù),接下來還會有許多用到NSRange
轉(zhuǎn)換成 Range
的地方凳宙,添加如下代碼也祠,可將 NSRange
轉(zhuǎn)換成 Range
:
// MARK: - String NSRange 轉(zhuǎn)換成 Range
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
}
}
在parseMarkup(_:)
繼續(xù)添加如下代碼
// 設(shè)定默認(rèn)字體
let defaultFont: UIFont = .systemFont(ofSize: UIScreen.main.bounds.size.height / 40)
// 遍歷匹配結(jié)果
for chunk in chunks {
// 獲取當(dāng)前匹配結(jié)果 NSTextCheckingResult 在原文本中的范圍
guard let markupRange = markup.range(from: chunk.range) else { continue }
// 以符號 "<" 分割句子
let parts = markup[markupRange].components(separatedBy: "<")
// 從 fontName 屬性(Arial)創(chuàng)建字體, 若無該字體,則使用默認(rèn)字體 defaultFont
let font = UIFont(name: fontName, size: UIScreen.main.bounds.size.height / 40) ?? defaultFont
// 為 NSAttributedString 創(chuàng)建 字體顏色和字體 屬性
let attrs = [NSAttributedStringKey.foregroundColor: color, NSAttributedStringKey.font: font] as [NSAttributedStringKey : Any]
// 將屬性 應(yīng)用于 parts[0]
let text = NSMutableAttributedString(string: parts[0], attributes: attrs)
attrString.append(text)
}
為了解析處理font
標(biāo)簽近速,繼續(xù)添加如下代碼
// 如果分割后的模式數(shù)組長度小于等于1诈嘿,則略過 說明不帶有形如 <> 的匹配
if parts.count <= 1 {
continue
}
let tag = parts[1]
// 如果 parts[1] ( < 之后的文本,也就是標(biāo)簽名) 是 font
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
// 利用 NSObject perform 方法對 color 屬性 賦值獲取到的顏色
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
}
}
// 正則匹配 face 字體屬性
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
到現(xiàn)在為止削葱,已經(jīng)能夠解析出 NSAttributedString
了
在我們的CTView.swift
中添加
// MARK: - Properties
var attrString: NSAttributedString!
// MARK: - Internal
func importAttrString(_ attrString: NSAttributedString) {
self.attrString = attrString
}
然后 draw(_ rect: CGRect)
中刪除
let attrString = NSAttributedString(string: "Hello World") from draw(_:)
ViewController.swift
中設(shè)置入口
let ctView = CTView()
ctView.frame = view.frame
view.addSubview(ctView)
guard let file = Bundle.main.path(forResource: "zombies", ofType: "txt") else { return }
do {
let text = try String(contentsOfFile: file, encoding: .utf8)
// 解析器解析
let parser = MarkupParser()
parser.parseMarkup(text)
ctView.importAttrString(parser.attrString)
} catch _ {
}
運(yùn)行一下奖亚,Cool, 效果如下
雜志布局
我們不僅僅滿足一個只顯示單頁面的應(yīng)用,CTFrameGetVisibleStringRange
使我們能控制一個frame
中能顯示多少文字析砸,你可以創(chuàng)建列昔字,顯示滿了之后,可以再次創(chuàng)建一列
在這個應(yīng)用中首繁,我們將以列為單位作郭,構(gòu)建多個頁面,最終構(gòu)成一個雜志APP
Let us down
我們先將CTView.swift
中基類換成UIScrollView
, 使App能夠支持多頁滾動
class CTView: UIScrollView {
到目前為止我們在CTView.swift
中創(chuàng)建了一個framesetter
弦疮,生成了并繪制了一個CTFrame
接下來創(chuàng)建一個新的類CTColumnView.swift
繼承于UIView
class CTColumnView: UIView {
var ctFrame: CTFrame!
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: -
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
// 轉(zhuǎn)換成UIKit坐標(biāo)系
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)
// 在上下文中繪制 CTFrame
CTFrameDraw(ctFrame, context)
}
}
接下來我們需要一個CTSettings.swift
來對Column
列進(jìn)行配置
class CTSettings {
// MARK: - 屬性
let margin: CGFloat = 20 // 邊距
var columnsPerPage: CGFloat! // 每頁列數(shù)
var pageRect: CGRect! // 頁面大小
var columnRect: CGRect! // 列大小
// MARK: - 初始化
init() {
// 如果是iphone 每頁顯示1列夹攒,否則每頁兩列
columnsPerPage = UIDevice.current.userInterfaceIdiom == .phone ? 1 : 2
// 頁面frame 邊距設(shè)置為 margin大小
pageRect = UIScreen.main.bounds.insetBy(dx: margin, dy: margin)
// 設(shè)置列的frame
columnRect = CGRect(x: 0,
y: 0,
width: pageRect.width / columnsPerPage,
height: pageRect.height).insetBy(dx: margin, dy: margin)
}
}
打開CTView.swift
, 刪去已有代碼, 添加如下代碼
class CTView: UIScrollView {
func buildFrames(withAttrString attrString: NSAttributedString,
andImages images: [[String: Any]]) {
// 允許UIScrollview 翻頁進(jìn)行翻動
isPagingEnabled = true
// CTFrameSetter 將創(chuàng)建每列對應(yīng)的 CTFrame
let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
// 屬性
var pageView = UIView()
var textPos = 0 //當(dāng)前字符所在位置
var columnIndex: CGFloat = 0 //當(dāng)前列下標(biāo)
var pageIndex: CGFloat = 0 //當(dāng)前頁面下標(biāo)
let settings = CTSettings() //配置
// 循環(huán)遍歷,生成列
while textPos < attrString.length {
繼續(xù)向遍歷胁塞,生成列的循環(huán)代碼添加:
// columnIndex %s ettings.columnsPerPage為零(truncatingRemainder:對浮點(diǎn)數(shù)取余)咏尝,說明為頁面第一列压语,需要新建一個頁,并設(shè)置frame
if columnIndex.truncatingRemainder(dividingBy: settings.columnsPerPage) == 0 {
columnIndex = 0
pageView = UIView(frame: settings.pageRect.offsetBy(dx: pageIndex * bounds.width, dy: 0))
addSubview(pageView)
// 頁面索引自增
pageIndex += 1
}
// 列寬度
let columnXOrigin = pageView.frame.size.width / settings.columnsPerPage
// 列偏移量
let columnOffset = columnIndex * columnXOrigin
// 計(jì)算列的frame
let columnFrame = settings.columnRect.offsetBy(dx: columnOffset, dy: 0)
// 創(chuàng)建位置路徑编检,確定text分繪制范圍
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: columnFrame.size))
// 創(chuàng)建 CTFramesetter 用來創(chuàng)建 CTFrame
let ctframe = CTFramesetterCreateFrame(framesetter, CFRangeMake(textPos, 0), path, nil)
// 創(chuàng)建列視圖
let column = CTColumnView(frame: columnFrame, ctframe: ctframe)
pageView.addSubview(column)
// 獲取CTFrame 能容納多少文本胎食,從而更新textPos
let frameRange = CTFrameGetVisibleStringRange(ctframe)
textPos += frameRange.length
// 列數(shù)指針自增
columnIndex += 1
}
上述代碼中,確定和計(jì)算出了每一列視圖的位置和應(yīng)該顯示的文本范圍
最后允懂,代碼末尾厕怜,只需要重新設(shè)定下UIScrollView
的contentSize
即可
// 更新UIScrollview的contentSize
contentSize = CGSize(width: CGFloat(pageIndex) * bounds.size.width,
height: bounds.size.height)
ViewController
中調(diào)用ctView.buildFrames
即可
let text = try String(contentsOfFile: file, encoding: .utf8)
let parser = MarkupParser()
parser.parseMarkup(text)
//ctView.importAttrString(parser.attrString)
ctView.buildFrames(withAttrString: parser.attrString, andImages: parser.images)
運(yùn)行一下,wonderful蕾总,一個可翻頁效果的App就有了
為App渲染圖片
盡管Core text
無法直接繪制圖片酣倾,但是它可以為圖片預(yù)留顯示空間 ,通過CTRun
的代理CTRunDelegate
谤专,我們可以設(shè)定CTRun
的上升下降高度,和它的寬度午绳,模型如下圖所示
每當(dāng)Core Text
遇到一個CTRun
置侍,它就會詢問代理我需要為這數(shù)據(jù)塊預(yù)留多少空間?
拦焚,通過CTRunDelegate
蜡坊,我們就能為圖片顯示預(yù)留出空間了
首先在MarkupParser.swift
中添加解析img
標(biāo)簽的代碼
// image 數(shù)組 添加 圖片屬性字典
images += [["width": NSNumber(value: Float(width)),
"height": NSNumber(value: Float(height)),
"filename": filename,
"location": NSNumber(value: attrString.length)]]
// 定義CTRun屬性結(jié)構(gòu)體
struct RunStruct {
let ascent: CGFloat
let descent: CGFloat
let width: CGFloat
}
// Memory指針 相當(dāng)于RunStruct 結(jié)構(gòu)體指針
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
extentBuffer.initialize(to: RunStruct(ascent: height, descent: 0, width: width))
// 創(chuàng)建CTRunDelegateCallbacks 控制占位
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
})
// 創(chuàng)建綁定了回調(diào)的代理
let delegate = CTRunDelegateCreate(&callbacks, extentBuffer)
// 將代理封裝至屬性字典
let attrDictionaryDelegate = [(kCTRunDelegateAttributeName as NSAttributedStringKey): (delegate as Any)]
attrString.append(NSAttributedString(string: " ", attributes: attrDictionaryDelegate))
}
現(xiàn)在MarkupParser
可以解析處理img
標(biāo)簽了,現(xiàn)在我們只需讓CTColumnView && CTView
繪制出來就行了
對于CTColumnView.swift
添加屬性
var images: [(image: UIImage, frame: CGRect)] = []
并在draw(_ rect: CGRect)
中添加繪制圖片代碼
// 繪制圖片
for imageData in images {
if let image = imageData.image.cgImage {
let imgBounds = imageData.frame
context.draw(image, in: imgBounds)
}
}
在CTView.swift
中添加屬性
var imageIndex: Int!
并且在buildFrames(withAttrString:andImages:):
方法中做初始化
imageIndex = 0
再次添加attachImagesWithFrame(_:ctframe:margin:columnView)
方法
func attachImagesWithFrame(_ images: [[String: Any]],
ctframe: CTFrame,
margin: CGFloat,
columnView: CTColumnView) {
// 獲取ctframe 的`CTLine`數(shù)組
let lines = CTFrameGetLines(ctframe) as NSArray
// 使用CTFrameGetLineOrigins 將ctframe中的行origin 復(fù)制到數(shù)組 origins
var origins = [CGPoint](repeating: .zero, count: lines.count)
// CFRangeMake(0, 0)代表轉(zhuǎn)換整個CTFrame
CTFrameGetLineOrigins(ctframe, CFRangeMake(0, 0), &origins)
// 獲取圖片對象的location屬性赎败,如果沒有值直接返回
var nextImage = images[imageIndex]
guard var imgLocation = nextImage["location"] as? Int else {
return
}
// 遍歷CTLine
for lineIndex in 0..<lines.count {
}
}
繼續(xù)在循環(huán)中添加代碼
// 遍歷CTLine
for lineIndex in 0..<lines.count {
let line = lines[lineIndex] as! CTLine
// 如果CTRun, 文件名秕衙,圖片都存在
if let glyphRuns = CTLineGetGlyphRuns(line) as? [CTRun],
let imageFilename = nextImage["filename"] as? String,
let img = UIImage(named: imageFilename) {
for run in glyphRuns {
// 如果當(dāng)前CTRun的范圍range沒有包含nextImage,直接進(jìn)入一下循環(huán)
let runRange = CTRunGetStringRange(run)
if runRange.location > imgLocation || runRange.location + runRange.length <= imgLocation {
continue
}
// 通過 CTRunGetTypographicBounds 計(jì)算圖片的大小
var imgBounds: CGRect = .zero
var ascent: CGFloat = 0
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil))
imgBounds.size.height = ascent
// 通過 CTLineGetOffsetForStringIndex 計(jì)算 CTLine x軸的偏移量僵刮,
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
// 偏移量需要加上 imgBounds 的 origin
imgBounds.origin.x = origins[lineIndex].x + xOffset
imgBounds.origin.y = origins[lineIndex].y
// 將image 和 image繪制的位置 加入 columnView
columnView.images += [(image: img, frame: imgBounds)]
// 圖片下標(biāo)自增,更新imgLocation
imageIndex! += 1
if imageIndex < images.count {
nextImage = images[imageIndex]
imgLocation = (nextImage["location"] as AnyObject).intValue
}
}
}
}
最終,在buildFrames(withAttrString:andImages:)
方法中,語句pageView.addSubview(column)
之前調(diào)用即可
if images.count > imageIndex {
attachImagesWithFrame(images, ctframe: ctframe, margin: settings.margin, columnView: column)
}
大功告成
github完整源碼地址(注釋完備) 傳送門:https://github.com/madaoCN/CoreTextMagzine 給個start唄