介绍 CoreText 简单应用,主要包括文本节选,可点链接,图文混排等内容。
CoreText
CoreText 是用于处理文字和字体的底层技术。它直接和 Core Graphics 交互。
CoreText 对象能直接获取文本的宽高信息,占用内存少,异步绘制等特点。在引起 UITableView 卡顿常见的原因 Cell 层级过多,离屏渲染,频繁计算 Cell 高度等耗时操作。这个时候 CoreText 就派上用场了,减少层级,CoreText 可以直接将文字和图片直接绘制在 Layer 上,并且支持异步绘制大大节约主线程资源。用来做图文混排的 UITableView 的优化,效果很明显。
基础概念
Font & Character & Glyphs
Font 在计算机意义上字体表示的是同一个大小,同一样式字形的集合
Character 字符表示信息本身,字形是它的图形表示形式,字符一般指某种编码,比如 Unicode 编码就是其中一种。字符和字形不是一一对应关系,同一个 Character 不同 Font 会生成不同的 Glyphs
Glyphs 字形常见参数
- Baseline : 参照线,是一条横线,一般为此为基础进行字体的渲染
- Leading : 行与行之间的间距
- Kerning : 字与字之间的间距
- Origin : 基线上最左侧的点
- Ascent : 一个字形最高点到基线的距离
- Decent : 一个自行最低处到基线的距离,所以一个字符的高度是 ascent + decent 。当一行内有不同字体的文字时候,取最大值 max(ascent + decent)。
- Line Height : max(ascent + decent) + Leading
富文本NSAttributedString
iOS 中用于描述富文本的类,它比 String 多了很多描述字体的属性,.font,.underlineColor,.foregroundColor 等,而且可以设定属性对应的区域 NSRange
let text: NSMutableAttributedString = NSMutableAttributedString(string: "test")
let attributes: [NSAttributedStringKey: Any] = [.font: UIFont.systemFontSize, .foregroundColor: UIColor.black, .underlineColor: UIColor.blue]
text.addAttributes(attributes, range: NSMakeRange(0, 1))
在绘制过程中,其中 CTFramesetter 是由 CFAttributedString(NSAttributedString) 初始化而来,通过传入 CGPath 生成相应的 CTFrame 最后渲染到屏幕是 CTFrame
let frameSetter: CTFramesetter = CTFramesetterCreateWithAttributedString(text)
let path = UIBezierPath(rect: CGRect())
let frame: CTFrame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path.cgPath, nil)
一个 CTFrame 由一个或者多个 CTLine 组成,一个 CTLine 由一个或者多个 CTRun 组成。一个 CTRun 是由相同属性的字符组成。
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() { return }
...
CTFrameDraw(ctFrame, context)
...
let lines = CTFrameGetLines(frame) as! Array
...
let runs = CTLineGetGlyphRuns(lines[0] as! CTLine)
...
}
产品需求是做一个类似知乎的问答系统,支持图文,标签,链接,短视频等基本元素。本文主要介绍基于 CoreText 图文排版一些简单实践应用。
文本
直接看代码吧,简单输出一段文字。
// BKCoreTextConfig.swift
// 文本配置信息
struct BKCoreTextConfig {
let width : CGFloat // 文本最大宽度
let fontName : String
let fontSize : CGFloat
let lineSpace : CGFloat // 行间距
let textColor : UIColor
init(width: CGFloat, fontName: String, fontSize: CGFloat,
lineSpace: CGFloat, textColor: UIColor) {
self.width = width
self.fontName = fontName
self.fontSize = fontSize
self.lineSpace = lineSpace
self.textColor = textColor
}
}
// BKCoreTextData.swift
// 绘制信息内容
struct BKCoreTextData {
let ctFrame : CTFrame
let size : CGSize
init(ctFrame: CTFrame, contentSize: CGSize) {
self.ctFrame = ctFrame
self.size = contentSize
}
}
// BKCoreTextParser.swift
// 解析
static func attributes(with config: BKCoreTextConfig) -> NSDictionary {
// 字体大小
let font = CTFontCreateWithName(config.fontName as CFString, config.fontSize, nil)
//设置行间距
var lineSpace = config.lineSpace
let settings: [CTParagraphStyleSetting] =
[CTParagraphStyleSetting(spec: CTParagraphStyleSpecifier.lineSpacingAdjustment, valueSize: MemoryLayout<CGFloat>.size, value: &lineSpace),
CTParagraphStyleSetting(spec: CTParagraphStyleSpecifier.maximumLineSpacing, valueSize: MemoryLayout<CGFloat>.size, value: &lineSpace),
CTParagraphStyleSetting(spec: CTParagraphStyleSpecifier.minimumLineSpacing, valueSize: MemoryLayout<CGFloat>.size, value: &lineSpace)]
let paragaraph = CTParagraphStyleCreate(settings, 3)
//设置字体颜色
let textColor = config.textColor
let dict = NSMutableDictionary()
dict.setObject(font, forKey: kCTFontAttributeName as! NSCopying)
dict.setObject(paragaraph, forKey: kCTParagraphStyleAttributeName as! NSCopying)
dict.setObject(textColor.cgColor, forKey: kCTForegroundColorAttributeName as! NSCopying)
return dict
}
static func createFrame(frameSetter: CTFramesetter, config: BKCoreTextConfig, height: CGFloat) -> CTFrame {
let path = CGMutablePath()
path.addRect(ccr(x: 0, y: 0, width: config.width, height: height))
return CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
}
static func parse(content: NSAttributedString, config: BKCoreTextConfig) -> BKCoreTextData {
let frameSetter = CTFramesetterCreateWithAttributedString(content)
let restrictSize = CGSize(width: config.width, height: CGFloat(MAXFLOAT))
let coretextSize = CTFramesetterSuggestFrameSizeWithConstraints(frameSetter,
CFRangeMake(0, 0), nil, restrictSize, nil)
let height = coretextSize.height
let frame = self.createFrame(frameSetter: frameSetter, config: config, height: height)
retutn BKCoreTextData.init(ctFrame: frame, contentSize: coretextSize)
}
static func handleText(text: String, config: BKCoreTextConfig) -> BKCoreTextData {
/*
let text = "裘德洛论颜值的话,绝对可以称得上帅的人神共愤,海洋般蓝绿交织的双眼,优雅俊美,随随便便一个动作都能俘获万千少女的心。而且人家不止有颜还多才多艺,小小年纪开始就在音乐剧团表演,气质啊才华啊什么的,完美的让人嫉妒。美图奉上↓"
let config = BKCoreTextConfig(width: kScreenWidth - 30, fontName: "PingFangSC-Regular", fontSize: 12, textColor: UIColor(rgb: 0x9E9E9E))
*/
let attributes = self.attributes(with: config) as? [NSAttributedStringKey : Any]
let attributedString = NSMutableAttributedString(string: content, attributes: attributes)
return parse(content: attributedString, config: config)
}
上面就是给定给一个文本和文本一些配置信息得到一个 frame 现在可以直接绘制出一段文本
// BKCoreTextView.swift
var data : BKCoreTextData? {
didSet { setNeedsDisplay() }
}
....
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext(), let info = data else { return }
/// !!! 坐标转换
context.textMatrix = CGAffineTransform.identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1, y: -1)
CTFrameDraw(info.ctFrame, context)
}
CoreText 坐标系是以左下角为坐标原点,UIKit是以左上角为坐标原点,使用 Core Graphics 需要做坐标的转换,不然看到的内容是倒过来的。
文本节选
以上只是简单的绘制了一段文本,但是呢,产品有需求要限制文本的行数,超过的用 ...
来表示更多。类似于微信朋友圈,内容过多会有 收起
全部
的按钮,功能是相类似的。
如果用 UILabel 设置宽高,UILabel 会自动帮我们处理 ...
。但是 CoreText 需要开发组手动处理,这里主要问题就是要找到最后一行合适的位置放置 ...
思路:
文本通过相关配置文件转换为 NSAttributedString 格式
计算文本的行数 Count
文本行数 Count 小于指定行数 numberOfLines,返回无需处理
文本行数大于指定行数,截取最后一行处理
最后一行显示宽度小于 Config.width, 直接行尾添加
...
最后一行显示宽度大于等于 Config.width ,需对最后一行做 replace 操作
难点: 如何获取最后一行显示宽度
public func CTLineGetOffsetForStringIndex(_ line: CTLine, _ charIndex: CFIndex, _ secondaryOffset: UnsafeMutablePointer<CGFloat>?) -> CGFloat
函数 CTLineGetOffsetForStringIndex 是获取一行文字中指定 charIndex 字符相对原点的偏移量,返回值与 secondaryOffset 同为一个值。如果 charIndex 超出一行的字符长度则返回最大长度结束位置的偏移量。因此想求一行字符所占的像素长度时,就可以使用此函数,将 charIndex 设置为大于字符长度即可这里设置了 100
但是感受算出来的长度还是有一丢丢误差。
static func handleText(text: String, config: BKCoreTextConfig, numberofLines: Int) -> BKCoreTextData {
let attributes = self.attributes(with: config) as? [NSAttributedStringKey : Any]
let attributedString = NSMutableAttributedString(string: content, attributes: attributes)
let currCoreData = self.parse(content: attributedString, config: config)
let lines = CTFrameGetLines(currCoreData.ctFrame) as Array
let count = lines.count
guard numberofLines > 0 else { return currCoreData }
guard count > 0 && count > numberofLines else { return currCoreData }
let num = min(numberofLines, count)
let line = lines[num-1]
let range = CTLineGetStringRange(line as! CTLine)
let position = range.location + range.length
let tmpAttrString = attr.attributedSubstring(from: NSMakeRange(0, position))
var newContent = NSAttributedString()
var offset: CGFloat = 0
CTLineGetOffsetForStringIndex(line as! CTLine,100,&offset)
let length = offset > (config.width - 10) ? range.length - 3 : range.length
let lastLine: NSMutableAttributedString = tmpAttrString.attributedSubstring(from: NSMakeRange(range.location, length)) as! NSMutableAttributedString
/// !!! 去除最后一行的 \n
var str = (lastLine.mutableString.mutableCopy() as! String).replacingOccurrences(of: "\n", with: "")
str.append("...")
let tmp = tmpAttrString.attributedSubstring(from: NSMakeRange(0, range.location))
let newAttr: NSAttributedString = tmp.appending(NSAttributedString.init(string: str))
let attributes = self.attributes(with: config) as? [NSAttributedStringKey : Any]
newContent = NSMutableAttributedString(string: newAttr.string, attributes: attributes)
return self.parseContent(content: newContent, config: config)
}
可点链接
可点链接也是很常见的,比如 点我跳转
后台给的 JSON 字符串可能直接原生态甩过来
print(content)
"嘎嘎嘎嘎哈哈哈哈哈https://wapbaike.baidu.com/item/%e4%b8%9c%e4%ba%ac%e5%9b%bd%e9%99%85%e7%94%b5%e5%bd%b1%e8%8a%82/187783?fr=aladdin"
********思路:********
扫描文本,找出链接地址的字符串 results
文本通过相关配置文件 Config 转换为 attributedString
超链接文本通过配置文件 Config 转换为 linkAttributedString
遍历 results 用 linkAttributedString 替换 attributedString 中链接地址
记录 linkAttributedString 的 range,url 得到 coreTextLinkDatas
// BKCoreTextData.swift
/// 可点击链接
struct BKCoreTextLinkData {
let title : String
let url : String
let range : NSRange
}
struct BKCoreTextData {
...
var linkData : [BKCoreTextLinkData]?
...
}
static func handleText(text: String, config: BKCoreTextConfig, numberofLines: Int) -> BKCoreTextData {
return self.handleLinkAttribute(content: content, config: config) { (attributedString, linkDatas) in
// 同上
....
let newCoreData = self.parseContent(content: attributedString, config: config)
if let links = linkDatas, links.count > 0 {
newCoreData.linkData = links
}
return newCoreData
}
static func handleLinkAttribute(content: String, config: BKCoreTextConfig, completed: @escaping
( _ result : NSAttributedString, _ linkDatas : [BKCoreTextLinkData]?) -> BKCoreTextData) -> BKCoreTextData {
let dataDetector = try? NSDataDetector(types: NSTextCheckingTypes(NSTextCheckingResult.CheckingType.link.rawValue))
let results = dataDetector?.matches(in: content, options: NSRegularExpression.MatchingOptions.reportProgress, range: NSMakeRange(0, content.length))
let attributes = self.attributes(with: config) as? [NSAttributedStringKey : Any]
let attributedString = NSMutableAttributedString(string: content, attributes: attributes)
let linkAttributedString = IconCodes.attributedString(code: .link, size: config.link.fontSize, color: config.link.textColor).appending(NSAttributedString(string: " 网页链接", font: UIFont(name: config.link.fontName, size: config.link.fontSize)!, color: .app_light))
var tmpLinkDatas = [BKCoreTextLinkData]()
if let results = results {
results.reversed().forEach({ (result) in
if result.resultType == .link, let url = URL(string: (content as NSString).substring(with: result.range)) {
linkAttributedString.addAttributes([.link: url], range: NSMakeRange(0, linkAttributedString.length))
attributedString.replaceCharacters(in: result.range, with: linkAttributedString)
let data = BKCoreTextLinkData.init(title: linkAttributedString.string, url: url.absoluteString, range: NSMakeRange(result.range.location, linkAttributedString.length))
tmpLinkDatas.append(data)
}
})
}
return completed(attributedString, tmpLinkDatas)
}
}
一个文本中可能有多个链接,需要识别链接,以及记下每个链接的 Range
效果有了,点击事件要怎么响应,这个时候就用到了上述记录的链接对应的 Range
遍历 coreTextLinkDatas ,找到在 range 中包含获取点击位置 point 的coreTextLinkData 拿到 url 地址
// BKCoreTextView.swift
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch = (touches as NSSet).anyObject() as! UITouch
let point = touch.location(in: self)
guard let frame = data?.ctFrame else { return }
let lines = CTFrameGetLines(frame) as Array
let count = lines.count
var origins = [CGPoint].init(repeating: CGPoint(0, 0), count: count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
var transform = CGAffineTransform.init(translationX: 0, y: bounds.height)
transform = transform.scaledBy(x: 1, y: -1)
guard let links = data?.linkData, links.count > 0 else {
print("没有可点击链接")
return
}
for (index, line) in lines.enumerated() {
let origin = origins[index]
let lineRect = getLineBound(line: line as! CTLine, point: origin)
let rect = lineRect.applying(transform)
if rect.contains(point) == true {
let relativePoint = CGPoint(point.x - rect.minX, point.y - rect.minY)
let idx = CTLineGetStringIndexForPosition(line as! CTLine, relativePoint)
if let link = foundLinkData(at: idx), let url = URL.init(string: link.url) {
print("oh! 点到了。\(url)")
return
} else {
print("不在点击链接范围")
}
}
}
}
func getLineBound(line: CTLine, point: CGPoint) -> CGRect {
var ascent: CGFloat = 0
var descent: CGFloat = 0
var leading: CGFloat = 0
let width: CGFloat = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading))
let height: CGFloat = ascent + descent
return ccr(point.x, point.y - descent, width, height)
}
func foundLinkData(at index: Int) -> BKCoreTextLinkData? {
var link : BKCoreTextLinkData?
data?.linkData?.forEach( {
if NSLocationInRange(index, $0.range ) == true {
link = $0
}
})
return link
}
这只是比较简单的链接样式,可以给它添加下划线,按压态等等,设置它的 NSAttributedString 样式就可以了。
图片混排
就是图片和文字混合排版,如果图片比较多文字少不建议用 Core Text。
Core Text 是一个文本处理框架,不能直接绘制图片,但是它可以给图片预留空间,结合Core Graphic 来绘图。
单排
思路
根据 Config 图片的宽高,设置 CTRunDelegateCallbacks
生成 runDelegate
找到要插入图片的位置,将图片信息封装成一个 attributedString 富文本类型的占位符
富文本类型的占位符
struct BKCoreTextData {
let ctFrame : CTFrame
let size : CGSize
let imageUrl: String
}
struct BKCoreTextConfig {
let size : CGSize
}
static func parse(content: String, imageUrl: String, config: BKCoreTextConfig) -> BKCoreTextData {
var imageCallback = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { (refCon) -> Void in
}, getAscent: { ( refCon) -> CGFloat in
return config.size.height // 高度
}, getDescent: { (refCon) -> CGFloat in
return 0 // 底部距离
}) { (refCon) -> CGFloat in
return config.size.width // 宽度
}
var imageName = "avatar"
let runDelegate = CTRunDelegateCreate(&imageCallback,&imageName)
// 富文本类型的占位符
let imageAttributedString = NSMutableAttributedString(string: " ")
imageAttributedString.addAttribute(NSAttributedStringKey(rawValue:
kCTRunDelegateAttributeName as String), value: runDelegate!, range:
NSMakeRange(0, 1))
imageAttributedString.addAttribute(NSAttributedStringKey(rawValue:
"avatarImage"), value: imageName, range: NSMakeRange(0, 1))
// 富文本类型的占位符插到要显示图片的位置
// 这里的设定是图片插在文本行首。。。
content.insert(imageAttributedString, at: 0)
// 文本绘制同上 多了一个imageUrl信息
let data: BKCoreTextData = ...
return data
}
图片和文本混合怎么显示???
思路
遍历 CTLine
遍历每个 Line 中 CTRun
通过 CTRunGetAttributes 得到所有属性
通过 KVC 取得属性中的代理属性,图片占位符绑定了代理
判断是否之前设置的图片代理来区分文本和图片
获取图片 距离原点偏移量 来计算图片绘制区域的 CGRect
使用 Core Graphics 异步绘制图片
var data: BKCoreTextData? {
didSet { setNeedsDisplay() }
}
private var avatarImage: Image = #默认占位符
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext() else { return }
guard let frame = data?.ctFrame else { return }
context.textMatrix = CGAffineTransform.identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1, y: -1)
CTFrameDraw(frame, context)
let lines = CTFrameGetLines(frame) as Array
let count = lines.count
var origins = [CGPoint].init(repeating: CGPoint(0, 0), count: count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
for (index, line) in lines.enumerated() {
(CTLineGetGlyphRuns(line as! CTLine) as Array).forEach({
var runAscent : CGFloat = 0
var runDescent : CGFloat = 0
let lineOrigin = origins[index]
let attributes = CTRunGetAttributes($0 as! CTRun)
let width = CGFloat( CTRunGetTypographicBounds($0 as!
CTRun, CFRangeMake(0,0), &runAscent, &runDescent, nil))
let location = CTLineGetOffsetForStringIndex(line as! CTLine,
CTRunGetStringRange($0 as! CTRun).location, nil)
let runRect = ccr(lineOrigin.x + location, lineOrigin.y - runDescent,
width, runAscent + runDescent)
let imageNames = attributes.object(forKey: "avatarImage")
if imageNames is String {
DispatchQueue.global().async { [weak self] in
// 获取图片 data.imageUrl
let tmp = ....
DispatchQueue.main.async {
self?.avatarImage = tmp!
self?.setNeedsDisplay(runRect)
}
}
context.draw(avatarImage.cgImage!, in: runRect)
}
})
}
}
这是比较理想的图文混合,图片的高度和文本高度差不多,以及图片的位置又刚好在行首。
组队
来看个实际需求的图文混合
绘制的方式是和上面的是一样的,不同在于图片和文本的排版不一样。
实践步骤:
0.不做处理
1.文字都堆在一起了,给文本不同样式划分段落
2.恩,跟目标很接近了 😀,根据不同段落展示样式,调整行首缩进距离 firstLineHeadIndent 以及基线的距离 baselineOffset
let margin : CGFloat = 20
let paragraphStyle0 = NSMutableParagraphStyle()
paragraphStyle0.alignment = .left
paragraphStyle0.firstLineHeadIndent = image.size.width + margin // 首行缩进
title.addAttributes([.baselineOffset: 15,.paragraphStyle: paragraphStyle0], range: NSMakeRange(0, title.length - 1))
subTitle.addAttributes([.baselineOffset: 10, .paragraphStyle: paragraphStyle0], range: NSMakeRange(0, subTitle.length))
let paragraphStyle1 = NSMutableParagraphStyle()
paragraphStyle1.alignment = .left
paragraphStyle1.firstLineHeadIndent = kScreenWidth - 30 - 20
indicator.addAttributes([.baselineOffset: 28, .paragraphStyle: paragraphStyle1], range: NSMakeRange(0, 1))
3.貌似已经达到目的了,这里也可以体现使用 Core Text 的优势,减少不必要的图层。
但是呢,这只是当前场景一种取巧的方式,hard code 间距,基线距离。如果 title 或者 subTitle 多行,这种方法就失效了。所以类似问题就是要解决图片和文字环绕的排版方式。
图文环绕
draw 函数是直接调用 frame 将内容绘制出来的,frame 是怎么来的
let path = CGMutablePath()
path.addRect(CGRect(x: 0, y: 0, width: config.width, height: height))
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
frame 是根据指定的 path 生成的,所以如果这个 path 将图片区域去掉,得到的 frame 就不包含该区域。但是这个 frame 里面也不再包含图片信息了。
let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height))
// !!! 这个左下角为坐标原点
let imagePath = UIBezierPath(rect: CGRect(x: 3, y: 3, width: image.size.width, height: image.size.height))
// 减去图片区域
path.append(imagePath)
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path.cgPath, nil)
可以看到整个绘制区域被分成了两个部分,一个是图片一个是文本。通过 UIBezierPath 还可以绘制任何想要的形状。剩下的问题就是处理段落之间的行间距。
Done!
总结
本文只是简单介绍了一些 Core Text 的东西,实际上还是有许多的细节还需要细细磨。实际开发过程中可能业务的形式不一,但是知识点是相通的,灵活应用都能达到目的。希望本文能给使用 CoreText 的同学一些启发。
参考
http://blog.devtang.com/2015/06/27/using-coretext-1/
http://blog.devtang.com/2015/06/27/using-coretext-2/
https://www.raywenderlich.com/153591/core-text-tutorial-ios-making-magazine-app
http://blog.cnbang.net/tech/2729/