利用CoreText图文混排

CoreText简介

CoreText是基于IOS3.2及OSX10.5的用于文字精细排版的文本框架。它直接与Core Graphics(又称:Quartz)交互,将需要显示的文本内容,位置,字体,字形直接传递给Quartz,与其他UI组件相比,能更高效的进行渲染。Core Text框架跟其他框架的关系图如下:

img

CoreText与UIWebView在排版方面的优劣比较

UIWebView也常用于处理复杂的排版,对应排版他们之间的优劣如下

  • CoreText占用的内容更少,渲染速度更快。UIWebView占用的内存多,渲染速度慢。
  • CoreText在渲染界面前就可以精确地获得显示内容的高度(只要有了CTFrame即可),而WebView只有渲染出内容后,才能获得内容的高度(而且还需要用JavaScript代码来获取)。
    CoreText的CTFrame可以在后台线程渲染,UIWebView的内容只能在主线程(UI线程)渲染。
  • 基于CoreText可以做更好的原生交互效果,交互效果可以更加细腻。而UIWebView的交互效果都是用JavaScript来实现的,在交互效果上会有一些卡顿的情况存在。例如,在UIWebView下,一个简单的按钮按下的操作,都无法做出原生按钮的即时和细腻的按下效果。

CoreText排版的劣势:

  • CoreText渲染出来的内容不能像UIWebView那样方便地支持内容的复制。
  • 基于CoreText来排版需要自己处理很多复制的逻辑,例如需要自己处理图片与文字混排相关的逻辑,也需要自己实现连接点击操作的支持。

在业界有很多应用都采用CoreText技术进行排版,例如新浪微博客户端,多看阅读客户端等等。

CoreText对象模型图

CoreText绘制的流程图,CTFrame和CTLine CTRun对象之间的关系如下图所示:

img

我们来解释一下这些类:

CFAttributedString :属性字符串,用于存储需要绘制的文字字符和字符属性

CTFramesetter:通过CFAttributedStringRef进行初始化,作为CTFrame对象的生产工厂,负责根据path创建对应的CTFrame

CTFrame:用于绘制文字的类,可以通过CTFrameDraw函数,直接将文字绘制到context上

CTLine:在CTFrame内部是由多个CTLine来组成的,每个CTLine代表一行

CTRun:每个CTLine又是由多个CTRun组成的,每个CTRun代表一组显示风格一致的文本

文字排版的基础概念

  • 字体(Font):和我们平时说的字体不同,计算机意义上的字体表示的是同一大小,同一样式(Style)字形的集合。从这个意义上来说,当我们为文字设置粗体,斜体时其实是使用了另外一种字体(下划线不算)。而平时我们所说的字体只是具有相同设计属性的字体集合,即Font Family或typeface。

  • 字符(Character)和字形(Glyphs):排版过程中一个重要的步骤就是从字符到字形的转换,字符表示信息本身,而字形是它的图形表现形式。字符一般指某种编码,如Unicode编码,而字形则是这些编码对应的图片。但是他们之间不是一一对应关系,同个字符的不同字体族,不同字体大小,不同字体样式都对应了不同的字形。而由于连写(Ligatures)的存在,也会出现多个字符对应一个字形的情况。 此处输入图片的描述
    img

  • 字形描述集(Glyphs Metris):即字形的各个参数。如下面的两张图:
    img
    img

    此处输入图片的描述此处输入图片的描述

    边框(Bounding Box):一个假想的边框,尽可能地容纳整个字形。

    基线(Baseline):一条假想的参照线,以此为基础进行字形的渲染。一般来说是一条横线。

    基础原点(Origin):基线上最左侧的点。

    行间距(Leading):行与行之间的间距。

    字间距(Kerning):字与字之间的距离,为了排版的美观,并不是所有的字形之间的距离都是一致的,但是这个基本步影响到我们的文字排版。

    上行高度(Ascent)和下行高度(Decent):一个字形最高点和最低点到基线的距离,前者为正数,而后者为负数。当同一行内有不同字体的文字时,就取最大值作为相应的值。

    如下图,
    img
    此处输入图片的描述 红框高度既为当前行的行高,绿线为baseline,绿色到红框上部分为当前行的最大Ascent,绿线到黄线为当前行的最大Desent,而黄框的高即为行间距。由此可以得出:lineHeight = Ascent + |Decent| + Leading。

CoreText绘制纯文本

自定义JCTextView继承自UIView, 重写draw(_:)方法,在其中绘制纯文本。代码如下:

class JCTextView: UIView {
    override func draw(_ rect: CGRect) {
        super.draw(rect)

        // 1 获取上下文
        guard let context = UIGraphicsGetCurrentContext()  else {
            return
        }
        // 2 转换坐标:uikit坐上原点,CoreText&CoreGrapic以左下为原点
        context.textMatrix = CGAffineTransform.identity
        context.translateBy(x: 0, y: self.bounds.size.height)
        context.scaleBy(x: 1.0, y: -1.0)
        // 3 绘制区域
        let path = CGMutablePath()
        path.addEllipse(in: self.bounds)
        // 4 创建需要绘制的文字
        let attrString = "Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!///end"
        let mutableAttrStr = NSMutableAttributedString(string: attrString)
        mutableAttrStr.addAttributes([NSFontAttributeName: UIFont.systemFont(ofSize: 20),                       NSForegroundColorAttributeName: UIColor.red], range: NSMakeRange(0, 5))  mutableAttrStr.addAttributes([NSFontAttributeName:UIFont.systemFont(ofSize: 13),NSUnderlineStyleAttributeName: 1], range: NSMakeRange(3,10))
        // 5 生成framesetter
        let framesetter = CTFramesetterCreateWithAttributedString(mutableAttrStr)
        let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, mutableAttrStr.length), path, nil)
        // 6 绘制文本
        CTFrameDraw(frame,context)
    }
}

调用:

let view = JCTextView(frame: CGRect(x: 0, y: 100, width: 300, height: 300))
view.backgroundColor = UIColor.white
self.view.addSubview(view)

效果图:

img

CoreText实现图文混排的原理

使用CoreText进行图文混排的核心思想是把需要摆放图片的位置用空字符替换原来的字符,并且实现CTRunDelegate,用于动态设置空字符的高度和宽度(代表图片的大小),并且对这些空字符设置一个属性名来区别于其他CTRun,之后进行图片渲染的时候就能通过该属性来区分哪些空字符是代表图片的占位符,哪些是普通的空字符。使用CoreText处理点击事件的关键是判断点击的位置是本文内容中的第几个字符,然后通过判断该字符是否在需要处理点击事件的字符串范围内。

import UIKit
import Foundation

class JCView: UIView {
    var image: UIImage?

    override func draw(_ rect: CGRect) {
        super.draw(rect)

        // 1 获取上下文
        guard let context = UIGraphicsGetCurrentContext()  else {
            return
        }

        // 2 转换坐标:uikit坐上原点,CoreText&CoreGrapic已左下为原点
        context.textMatrix = CGAffineTransform.identity
        context.translateBy(x: 0, y: self.bounds.size.height)
        context.scaleBy(x: 1.0, y: -1.0)
        // 3 绘制区域
        let path = UIBezierPath(rect: rect)
        // 4 创建需要绘制的文字
        let attrString = "Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!Hello CoreText!"

        let mutableAttrStr = NSMutableAttributedString(string: attrString)
        mutableAttrStr.addAttributes([NSFontAttributeName: UIFont.systemFont(ofSize: 20),
                                      NSForegroundColorAttributeName: UIColor.red], range: NSMakeRange(0, 5))
        mutableAttrStr.addAttributes([NSFontAttributeName:UIFont.systemFont(ofSize: 13),NSUnderlineStyleAttributeName: 1], range: NSMakeRange(3,10))
        let style = NSMutableParagraphStyle()
        style.lineSpacing = 6 //行间距
        mutableAttrStr.addAttributes([NSParagraphStyleAttributeName: style], range: NSMakeRange(0, mutableAttrStr.length))
        // 5 为图片设置CTRunDelegate,delegate决定留给图片的空间大小
        var imageName = "xiaoyang.jpg"
        var imageCallback =  CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { (refCon) -> Void in

        }, getAscent: { ( refCon) -> CGFloat in

            //                let imageName = "mc"
            //                refCon.initialize()
            //                let image = UIImage(named: imageName)
            return 100  //返回高度

        }, getDescent: { (refCon) -> CGFloat in

            return 50  //返回底部距离

        }, getWidth: { (refCon) -> CGFloat in

            //                let imageName = String("mc")
            //                let image = UIImage(named: imageName)
            return 100  //返回宽度

        })

        let runDelegate = CTRunDelegateCreate(&imageCallback, &imageName)
        let imgString = NSMutableAttributedString(string: " ") // 空格用于给图片留位置
        imgString.addAttributes([kCTRunDelegateAttributeName as String: runDelegate!], range: NSMakeRange(0, 1))
        imgString.addAttribute("imageName", value: imageName, range: NSMakeRange(0, 1))//添加属性,在CTRun中可以识别出这个字符是图片
        mutableAttrStr.insert(imgString, at: 15)

        //网络图片相关
        var  imageCallback1 =  CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { (refCon) -> Void in

        }, getAscent: { ( refCon) -> CGFloat in
            return 70  //返回高度

        }, getDescent: { (refCon) -> CGFloat in

            return 50  //返回底部距离

        }, getWidth: { (refCon) -> CGFloat in
            return 100  //返回宽度
        })
        var imageUrl = "https://www.baidu.com/img/bd_logo1.png" //网络图片链接
        let urlRunDelegate  = CTRunDelegateCreate(&imageCallback1, &imageUrl)
        let imgUrlString = NSMutableAttributedString(string: " ")  // 空格用于给图片留位置
        imgUrlString.addAttribute(kCTRunDelegateAttributeName as String, value: urlRunDelegate!, range: NSMakeRange(0, 1))  //rundelegate  占一个位置
        imgUrlString.addAttribute("urlImageName", value: imageUrl, range: NSMakeRange(0, 1))//添加属性,在CTRun中可以识别出这个字符是图片
        mutableAttrStr.insert(imgUrlString, at: 150)


        // 6 生成framesetter
        let framesetter = CTFramesetterCreateWithAttributedString(mutableAttrStr)
        let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, mutableAttrStr.length), path.cgPath, nil)

        // 7 绘制除图片以外的部分
        CTFrameDraw(frame,context)

        // 8 处理绘制图片逻辑
        let lines = CTFrameGetLines(frame) as NSArray //存取frame中的ctlines

        let ctLinesArray = lines as Array
        var originsArray = [CGPoint](repeating: CGPoint.zero, count:ctLinesArray.count)
        let range: CFRange = CFRangeMake(0, 0)
        CTFrameGetLineOrigins(frame, range, &originsArray)

        //遍历CTRun找出图片所在的CTRun并进行绘制,每一行可能有多个
        for i in 0..<lines.count {
            //遍历每一行CTLine
            let line = lines[i]
            var lineAscent = CGFloat()
            var lineDescent = CGFloat()
            var lineLeading = CGFloat()
            //该函数除了会设置好ascent,descent,leading之外,还会返回这行的宽度
            CTLineGetTypographicBounds(line as! CTLine, &lineAscent, &lineDescent, &lineLeading)

            let runs = CTLineGetGlyphRuns(line as! CTLine) as NSArray
            for j in 0..<runs.count {
                // 遍历每一个CTRun
                var runAscent = CGFloat()
                var runDescent = CGFloat()
                let lineOrigin = originsArray[i]// 获取该行的初始坐标
                let run = runs[j] // 获取当前的CTRun
                let attributes = CTRunGetAttributes(run as! CTRun) as NSDictionary

                let width =  CGFloat(CTRunGetTypographicBounds(run as! CTRun, CFRangeMake(0,0), &runAscent, &runDescent, nil))
                let runRect = CGRect(x: lineOrigin.x + CTLineGetOffsetForStringIndex(line as! CTLine, CTRunGetStringRange(run as! CTRun).location, nil), y: lineOrigin.y - runDescent, width: width, height: runAscent + runDescent)
                let imageNames = attributes["imageName"]
                let urlImageName = attributes["urlImageName"]

                if let imageName = imageNames as? String {
                //本地图片
                let image = UIImage(named: imageName)
                let imageDrawRect = CGRect(x: runRect.origin.x, y: lineOrigin.y-runDescent, width: 100, height: 100)
                if let cgimage = image?.cgImage {
                    context.draw(cgimage, in: imageDrawRect)
                }
            }

                if let urlImageName = urlImageName as? String{
                    var image: UIImage?
                    let imageDrawRect = CGRect(x: runRect.origin.x, y: lineOrigin.y-runDescent, width: 100, height: 100)
                    if self.image == nil {
                        image = UIImage(named:"hs") //灰色图片占位
                        //去下载
                        if let url = NSURL(string: urlImageName){
                            let request = NSURLRequest(url: url as URL)
                            URLSession.shared.dataTask(with: request as URLRequest, completionHandler: { (data, resp, err) -> Void in
                                if let data = data{
                                    DispatchQueue.main.sync(execute: { () -> Void in
                                        self.image = UIImage(data: data)
                                        self.setNeedsDisplay()  //下载完成会重绘
                                    })
                                }
                            }).resume()
                        }
                    } else {
                        image = self.image
                    }
                    if let CGImage = image?.cgImage {
                        context.draw(CGImage, in: imageDrawRect)
                    }
                }
            }
        }
    }
}

截图:

img

参考:

CoreText_Programming_Guide

http://blog.devtang.com/2015/06/27/using-coretext-1/

http://xiangwangfeng.com/2014/03/06/iOS%E6%96%87%E5%AD%97%E6%8E%92%E7%89%88(CoreText)%E9%82%A3%E4%BA%9B%E4%BA%8B/%E9%82%A3%E4%BA%9B%E4%BA%8B/)