第一步: 基础
实现下面的效果,注意到每一行的间距为 0
1,有这么一个富文本
let attributed = NSMutableAttributedString(string: "新丰美酒斗十千,咸阳游侠多少年。\n相逢意气为君饮,系马高楼垂柳边。\n出身仕汉羽林郎,初随骠骑战渔阳。\n孰知不向边庭苦,纵死犹闻侠骨香。");
attributed.addAttributes([NSAttributedString.Key.font: UIFont.systemFont(ofSize: 20)], range: NSMakeRange(0, attributed.length))
attributed.addAttributes([NSAttributedString.Key.font: UIFont.systemFont(ofSize: 30)], range: NSMakeRange(2, 2))
attributed.addAttributes([NSAttributedString.Key.foregroundColor: UIColor.orange], range: NSMakeRange(0, 7))
attributed.addAttributes([NSAttributedString.Key.foregroundColor: UIColor.red], range: NSMakeRange(8, 7))
2,最上面,划条线
let strokePath = CGMutablePath()
strokePath.addRect(CGRect(x: 1, y: 1, width: UIScreen.main.bounds.width-2, height: 1))
ctx.addPath(strokePath)
ctx.setStrokeColor(UIColor.purple.cgColor)
ctx.strokePath()
CoreText 的坐标系,原点在左下,
UIKit 的坐标系,原点在左上
3,来一个坐标系,翻转
let xHigh = bounds.size.height
ctx.textMatrix = CGAffineTransform.identity
ctx.translateBy(x: 0, y: xHigh)
ctx.scaleBy(x: 1.0, y: -1.0)
4,显示文字
流程是,
拿富文本创建 CTFrame,
拿 CTFrame ,获取里面的 CTLine 集合
把每一行 CTLine,绘制出来
通过 ctx.textPosition,设置每一行的绘制原点
每一行的高度是,lineAscent + lineDescent + lineLeading
绘制一行,原点的 y 值 - (lineAscent + lineDescent),
再绘制第二行,就忽略了 lineLeading ( 行间距 )
let path = CGMutablePath()
path.addRect(bounds)
let ctFrameSetter = CTFramesetterCreateWithAttributedString(attributed)
let ctFrame = CTFramesetterCreateFrame(ctFrameSetter, CFRangeMake(0, attributed.length), path, nil)
let lines = CTFrameGetLines(ctFrame) as NSArray
var originsArray = [CGPoint](repeating: CGPoint.zero, count: lines.count)
CTFrameGetLineOrigins(ctFrame, CFRangeMake(0, 0), &originsArray)
var frameY:CGFloat = 0
for (i,line) in lines.enumerated() {
var lineAscent:CGFloat = 0
var lineDescent:CGFloat = 0
var lineLeading:CGFloat = 0
CTLineGetTypographicBounds(line as! CTLine, &lineAscent, &lineDescent, &lineLeading)
var lineOrigin = originsArray[i]
if i > 0{
frameY = frameY - lineAscent - lineDescent
lineOrigin.y = frameY
}
else{
frameY = lineOrigin.y
}
ctx.textPosition = lineOrigin
CTLineDraw(line as! CTLine, ctx)
}
github repo
第 2 步: 提高
实现下面的效果,有文本框,
竖直方向,间距严格定义
a, 思路
还是一个熟悉的富文本,绘制在 UIView 上
可能渲染的内容比较多,屏幕放不下,
渲染的视图,放在一个 UIScroll 上面
- 先拿着熟悉的富文本,计算出一个足够大的渲染 size,
指定父视图的 content size,
再拿着这个 size,创建 Frame
- 然后自定义绘制,指定每一行的原点,
绘制完成,就计算出了实际的内容 size
来一个回调,指定父视图的 content size
b, 实现
先计算出足够的 size 来渲染,
这里的 size,选用的是 3 倍高度,calculatedSize.height * 3
再创建 CTFrame
var contentPage: NSAttributedString?{
didSet{
guard let page = contentPage else{
return
}
// 计算文本框大小,因为 UIView 没有 UILabel 的 intrinsic size
let widthInUse = UI.std.width - TextContentConst.padding * 2
let calculatedSize = page.boundingRect(with: CGSize(width: widthInUse, height: 3000), options: [.usesFontLeading, .usesLineFragmentOrigin], context: nil).size
let siZ = CGSize(width: widthInUse, height: calculatedSize.height * 3)
// 建立 core text 文本
let framesetter = CTFramesetterCreateWithAttributedString(page)
let path = CGPath(rect: CGRect(origin: CGPoint.zero, size: siZ), transform: nil)
frameRef = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)
s = siZ
}
}
绘制部分
- 先来一个熟悉的坐标系翻转
再准备渲染每一行
// 拿到每一行
guard let lines = CTFrameGetLines(f) as? [CTLine] else{
return
}
let lineCount = lines.count
guard lineCount > 0 else {
return
}
var frameY:CGFloat = 0
var originsArray = [CGPoint](repeating: CGPoint.zero, count: lineCount)
// 拿到每一行的坐标
CTFrameGetLineOrigins(f, CFRangeMake(0, 0), &originsArray)
// 辅助,计算出当前一行的坐标
var lastY: CGFloat = 0
// 最后一行的坐标
var final: CGFloat = 0
// 第一行的坐标,
// 有了这个和上一个,渲染区域的 height, 就出来了
var first: CGFloat? = nil
渲染每一行
- 重点就是,拿到第 2 行的 Y 坐标,
( 第一行的坐标,不用处理 )
第 2 行的坐标 - 第一行的行高 (lineAscent + lineDescent) - 指定的间距
- 至于,第 n 行的 Y 坐标,
第 (n - 1) 行的坐标 - 第 (n - 1) 行的行高 (lineAscent + lineDescent) - 累计的间距 ( lastY )
- x 坐标,大部分都简单,带框的都指定为常数
带拼音的,计算出行宽,TextContentConst.fBgTypoImg.width - CGFloat(sentenceW) 的一半
for (i,line) in lines.enumerated(){
var lineAscent:CGFloat = 0
var lineDescent:CGFloat = 0
var lineLeading:CGFloat = 0
// 拿到当前行的宽度,用于安置拼音
let sentenceW = CTLineGetTypographicBounds(line , &lineAscent, &lineDescent, &lineLeading)
var lineOrigin = originsArray[i]
lineOrigin.x = TextContentConst.padding
if info.eightY.contains(i){
lastY -= 8
}
else{
switch i {
case 1:
lastY -= TextContentConst.padding
default:
lastY -= 20
}
}
if info.pronounceX.contains(i){
let makeUp = TextContentConst.fBgTypoImg.width - CGFloat(sentenceW)
lineOrigin.x += makeUp / 2
}
switch i {
case 0:
frameY = lineOrigin.y
default:
frameY = frameY - (lineAscent + lineDescent)
lineOrigin.y = frameY
}
lineOrigin.y += lastY
let yOffset = lineOrigin.y - lineDescent - 20
if i == 0{
ctx.draw(line: yOffset)
}
ctx.textPosition = lineOrigin
if info.contains(pair: i){
// 画带框的一行
drawPairs(context: ctx, ln: line, startPoint: lineOrigin, ascent: lineAscent)
}
else{
// 画一行
CTLineDraw(line, ctx)
}
// 更新初始和最终的坐标
if first == nil{
first = lineOrigin.y
}
let typoH = lineAscent + lineDescent
final = lineOrigin.y - typoH
}
- 绘制,带框的 pairs
CTLine, 包含若干 CTRun,
上面使用了 CTLineDraw,
这里使用 CTRunDraw
CTRunDraw(run, ctx, CFRange(location: 0, length: 0))
这里的 CFRange(location: 0, length: 0) 是绘制全部
先绘制下面的图片,( 计算有点绕 ), 再绘制上面的文字
x 坐标,比较明确,都是常数
func drawPairs(context ctx: CGContext, ln line: CTLine,startPoint lineOrigin: CGPoint, ascent lineAscent: CGFloat){
if let pieces = CTLineGetGlyphRuns(line) as? [CTRun]{
let pieceCnt = pieces.count
var zeroP = lineOrigin
zeroP.y -= 5
for j in 0..<pieceCnt{
switch j {
case 0:
var frame = TextContentConst.fBgTypoImg
frame.origin.y = lineOrigin.y + lineAscent - TextContentConst.fBgTypoImg.size.height + TextContentConst.offsetP.y
// 绘制下面的图片
bgGrip?.draw(in: frame)
zeroP.x += TextContentConst.offsetP.x
case 1:
zeroP.x = 92
default:
()
}
ctx.textPosition = zeroP
// 绘制上面的文字
CTRunDraw(pieces[j], ctx, CFRange(location: 0, length: 0))
}
}
}
没有 SceneDelegate 的工程, CTLine 里面包含两个 CTRun, 逻辑比较明确
有 SceneDelegate 的工程, CTLine 里面包含 5 个 CTRun, 效果没问题,逻辑 ha ha
github repo
增强,一行多框
可看到,最下面的一行,多个框
该效果,在上面的基础上实现,
不同的是,
这里一行框,每个字都是一个富文本,一个富文本就是一个 CTLine
CTRun draw at range, 效果诡异
ctx.textPosition = lineOrigin
if info.contains(pair: i){
// 绘制前框,后一行文本
drawPairs(context: ctx, ln: line, startPoint: lineOrigin, ascent: lineAscent)
}
else if info.phraseY.contains(i), let startIdx = info.startIdx{
// 绘制一行都是框
// 就这里不一样
let lnHeight = lineAscent + lineDescent + lineLeading
lastY -= drawGrips(m: info, lnH: lnHeight, index: i, dB: startIdx, lineOrigin: lineOrigin, context: ctx, lnAscent: lineAscent)
}
else{
// 绘制一行文本
CTLineDraw(line, ctx)
}
具体的一行框,实现
注意到,原本获取的 CTFrame 的 CTLine, 抛弃了
原本获取的 CTLine 的坐标,留用,作为参考
原本行与行,之间的间距。变成了格子与格子,之间的间距。
所以要返回一个间隔 ( ( 格子高度 - 行高 ) 的一半 ),累积到历史间距 ( lastY )
下面的坐标计算,比较绕
func drawGrips(m info: TxtRenderInfo, lnH lnHeight: CGFloat, index i: Int, dB startIdx: Int, lineOrigin lnOrigin: CGPoint, context ctx: CGContext, lnAscent lineAscent: CGFloat) -> CGFloat{
// 拿到这一行的文本
let content = info.strs[i - startIdx]
let glyphCount = content.count
var frameImg = TextContentConst.fBgTypoImg
let lnOffsset = (TextContentConst.padding - lnHeight) * 0.5
var lineOrigin = lnOrigin
lineOrigin.y -= lnOffsset
var textP = lineOrigin
// 处理这一行的文本的,每一个字
for idx in 0..<glyphCount{
let pieX = String(content[idx])
// 每一个字,变成一个 CTLine
let ln = CTLineCreateWithAttributedString(pieX.word)
let lnSize = ln.lnSize
let typeOriginX = TextContentConst.padding * CGFloat(idx + 1)
textP.x = typeOriginX + (TextContentConst.padding - lnSize.width) * 0.5
ctx.textPosition = textP
frameImg.origin.x = typeOriginX
frameImg.origin.y = lineOrigin.y + lineAscent - TextContentConst.fBgTypoImg.size.height + TextContentConst.offsetP.y
// 绘制下面的背景图
bgGrip?.draw(in: frameImg)
// 绘制上面的文字
CTLineDraw(ln, ctx)
}
return lnOffsset
}