Core Text 实践:自定义每个字的位置

2,231 阅读5分钟

第一步: 基础

实现下面的效果,注意到每一行的间距为 0

截屏2021-03-22 下午3.06.41.png

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 步: 提高

实现下面的效果,有文本框,

竖直方向,间距严格定义

截屏2021-03-22 下午4.30.21.png

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

增强,一行多框

可看到,最下面的一行,多个框

截屏2021-03-22 下午5.16.46.png

该效果,在上面的基础上实现,

不同的是,

这里一行框,每个字都是一个富文本,一个富文本就是一个 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
    }

github repo