Core Text 处理点击指定文字的事件

1,591 阅读4分钟

背景:

一般点击文本中的链接跳转,可以用 UITextView

UITextView 的实现


class ViewController: UIViewController, UITextViewDelegate{

    @IBOutlet var textView: UITextView!
    override func viewDidLoad() {
        super.viewDidLoad()
        let link = "https://baike.baidu.com/item/%E5%B0%BC%E5%8F%A4%E6%8B%89%E6%96%AF%C2%B7%E5%87%AF%E5%A5%87/1295347?fromtitle=%E5%B0%BC%E5%8F%A4%E6%8B%89%E6%96%AF%E5%87%AF%E5%A5%87&fromid=415246&fr=aladdin"
        let src = "cage 电影,\(link)"
        let attributedString = NSMutableAttributedString(string: src)
        attributedString.addAttribute(.link, value: link, range: src.range(ns: link))
        textView.attributedText = attributedString
    }


    func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        UIApplication.shared.open(URL)
        // 这里放交互事件
        return false
    }

}


缺点,不是很灵活
  • 苹果有自己的设计,链接自动变蓝了
  • 阅读不友好,必须是链接,对 link 来一个百分号解码,就 gg

不能 "".removingPercentEncoding

本文描述下,使用 Core Text 的两种实现:

都采用 CTFrame 把文本绘制出来;

都是用 func touchesBegan 识别出事件

实现一

每一次点击,计算出点击到的点在文本中的索引值,如果在事件的范围中,就响应


class TextRenderView: UIView {


    let frameRef:CTFrame
    let theSize: CGSize
    
    // 事件一
    let keyOne = "Willy's Wonderland"
    // 事件 2 
    let keyTwo = "威利的仙境"
    
    let rawTxt: String
    let contentPage: NSAttributedString
    // 事件的两个范围
    let keyRanges: [Range<String.Index>]
    
    override init(frame: CGRect){
        rawTxt = "When his car breaks down, a quiet loner (Nic Cage) agrees to clean an abandoned family fun center in exchange for repairs. He soon finds himself waging war against possessed animatronic mascots while trapped inside \(keyOne).\n\n 当他的汽车发生故障时,一个安静的独行侠(Nic Cage)同意清洗一个废弃的家庭娱乐中心,以换取修理费。他很快发现自己被困在\(keyTwo)中,与拥有的电子吉祥物发动了战争。"
        var tempRanges = [Range<String.Index>]()
        if let rangeOne = rawTxt.range(of: keyOne){
            tempRanges.append(rangeOne)
        }
        if let rangeTwo = rawTxt.range(of: keyTwo){
            tempRanges.append(rangeTwo)
        }
        keyRanges = tempRanges
        contentPage = NSAttributedString(string: rawTxt, attributes: [NSAttributedString.Key.font: UIFont.regular(ofSize: 15), NSAttributedString.Key.foregroundColor: UIColor.black])
        
        // 计算文本框大小,因为 UIView 没有 UILabel 的 intrinsic  size
        let calculatedSize = contentPage.boundingRect(with: CGSize(width: UI.std.width - 15 * 2, height: UI.std.height), options: [.usesFontLeading, .usesLineFragmentOrigin], context: nil).size
        let padding: CGFloat = 10
        theSize = CGSize(width: calculatedSize.width, height: calculatedSize.height + padding)
        
        // 建立 core text 文本
        let framesetter = CTFramesetterCreateWithAttributedString(contentPage)
        let path = CGPath(rect: CGRect(origin: CGPoint.zero, size: theSize), transform: nil)
        frameRef = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)
        
        super.init(frame: frame)
        backgroundColor = UIColor.white
    }
    
    
    // 绘制文本
    override func draw(_ rect: CGRect) {
        
        guard let ctx = UIGraphicsGetCurrentContext() else{
            return
        }
        ctx.textMatrix = CGAffineTransform.identity
        ctx.translateBy(x: 0, y: bounds.size.height)
        ctx.scaleBy(x: 1.0, y: -1.0)
        CTFrameDraw(frameRef, ctx)
        
    }
    
    // 识别事件
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        guard let touch = touches.first else{
            return
        }
        let pt = touch.location(in: self)
        guard let offset = parserRect(with: pt, frame: frameRef), let pos = rawTxt.index(rawTxt.startIndex, offsetBy: offset, limitedBy: rawTxt.endIndex) else{
            return
        }
        if keyRanges[0].contains(pos){
            print("000")
        }
        else if keyRanges[1].contains(pos){
            print("111")
        }
        
    }
    
    // 拿到点,计算出其对应的字的索引 index
    // 就是一行一行的找
    func parserRect(with point: CGPoint, frame textFrame: CTFrame) -> Int?{
        var result: Int? = nil
        let path: CGPath = CTFrameGetPath(textFrame)
        let bounds = path.boundingBox
        guard let lines = CTFrameGetLines(textFrame) as? [CTLine] else{
            return result
        }
        let lineCount = lines.count
        guard lineCount > 0 else {
            return result
        }
        var origins = [CGPoint](repeating: CGPoint.zero, count: lineCount)
        CTFrameGetLineOrigins(frameRef, CFRangeMake(0, 0), &origins)
        //	开始一行一行的找
        for i in 0..<lineCount{
            let baselineOrigin = origins[i]
            let line = lines[i]
            var ascent: CGFloat = 0
            var descent: CGFloat = 0
            var linegap: CGFloat = 0
            let lineWidth = CTLineGetTypographicBounds(line, &ascent, &descent, &linegap)
            // 计算出了,当前行的位置 rect
            let lineFrame = CGRect(x: baselineOrigin.x, y: bounds.height-baselineOrigin.y-ascent, width: CGFloat(lineWidth), height:  ascent+descent+linegap + 10)
            if lineFrame.contains(point){
                // 找到了,就返回
                result = CTLineGetStringIndexForPosition(line, point)
                break
            }
        }
        return result
    }
}


调用

因为 UIView 没有 UILabel 的 intrinsic size,

所以需要手动计算出文本渲染需要的 size, 赋值给 frame

class ViewController: UIViewController{

    lazy var content = TextRenderView()
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        content.frame = CGRect(origin: .zero, size: content.theSize)
        content.center = CGPoint(x: UI.std.width / 2, y: UI.std.height / 2)
        view.addSubview(content)

    }



实现 2

计算索引,不方便扩大点击热区,特别是上下的热区

可以计算出第一段文本的中心点,距离中心点指定的半径内,就识别到了

第二段同样




func parserRect(with index: Int, in source: String, frame textFrame: CTFrame) -> CGPoint?{
    var result: CGPoint? = nil
    guard let lines = CTFrameGetLines(textFrame) as? [CTLine] else{
        return result
    }
    let lineCount = lines.count
    guard lineCount > 0 else {
        return result
    }
    var origins = [CGPoint](repeating: CGPoint.zero, count: lineCount)
    CTFrameGetLineOrigins(textFrame, CFRangeMake(0, 0), &origins)
    for i in 0..<lineCount{
        let baselineOrigin = origins[i]
        let line = lines[i]
        var ascent: CGFloat = 0
        var descent: CGFloat = 0
        var linegap: CGFloat = 0
        CTLineGetTypographicBounds(line, &ascent, &descent, &linegap)
        let range = CTLineGetStringRange(line)
        if range.location + range.length >= index{
            let xStart = CTLineGetOffsetForStringIndex(line, index, nil)
            result = CGPoint(x: baselineOrigin.x+xStart, y:  baselineOrigin.y + (ascent - descent)/2)
            break
        }
    }
    return result
}


class TextRenderView: UIView {

    let frameRef:CTFrame
    let theSize: CGSize
    // 事件一
    let keyOne = "Willy's Wonderland"
    
    // 事件 2
    let keyTwo = "威利的仙境"
    
    let rawTxt: String
    let contentPage: NSAttributedString
    let keyLocation: [Int]
    let points: [CGPoint?]
    
    override init(frame: CGRect){
        rawTxt = "When his car breaks down, a quiet loner (Nic Cage) agrees to clean an abandoned family fun center in exchange for repairs. He soon finds himself waging war against possessed animatronic mascots while trapped inside \(keyOne).\n\n 当他的汽车发生故障时,一个安静的独行侠(Nic Cage)同意清洗一个废弃的家庭娱乐中心,以换取修理费。他很快发现自己被困在\(keyTwo)中,与拥有的电子吉祥物发动了战争。"
        let rangeOne = rawTxt.range(ns: keyOne)
        let rangeTwo = rawTxt.range(ns: keyTwo)
        keyLocation = [rangeOne.location + rangeOne.length / 2, rangeTwo.location + rangeTwo.length / 2]
        contentPage = NSAttributedString(string: rawTxt, attributes: [NSAttributedString.Key.font: UIFont.regular(ofSize: 12), NSAttributedString.Key.foregroundColor: UIColor.blue])
        
        // 计算文本框大小
        let calculatedSize = contentPage.boundingRect(with: CGSize(width: UI.std.width - 15 * 2, height: UI.std.height), options: [.usesFontLeading, .usesLineFragmentOrigin], context: nil).size
        let padding: CGFloat = 10
        theSize = CGSize(width: calculatedSize.width, height: calculatedSize.height + padding)
        // 拿到待渲染的文本帧
        let framesetter = CTFramesetterCreateWithAttributedString(contentPage)
        let path = CGPath(rect: CGRect(origin: CGPoint.zero, size: theSize), transform: nil)
        frameRef = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)
        // 拿到两个中心点
        var pts = [CGPoint?]()
        for index in keyLocation{
            // 识别出事件的区域,只需要各自计算一次
            pts.append(parserRect(with: index, in: rawTxt, frame: frameRef))
        }
        points = pts
        super.init(frame: frame)
        backgroundColor = UIColor.white
        
    }
    
    
    override func draw(_ rect: CGRect) {
        guard let ctx = UIGraphicsGetCurrentContext() else{
            return
        }
        ctx.textMatrix = CGAffineTransform.identity
        ctx.translateBy(x: 0, y: bounds.size.height)
        ctx.scaleBy(x: 1.0, y: -1.0)
        CTFrameDraw(frameRef, ctx)
        // 上面是渲染出文本
        // 下面是画出中心点,方便理解
        if points.count > 0{
            let path = UIBezierPath()
            path.lineWidth = 5
            for dot in points{
                if let pt = dot{
                    ctx.move(to: pt)
                    ctx.addLine(to: pt)
                    ctx.setLineCap(.round)
                    ctx.setBlendMode(.normal)
                    ctx.setLineWidth(10)
                   ctx.setStrokeColor(UIColor.magenta.cgColor)
                    ctx.strokePath()
                }
                
            }
            
        }
        
    }
    
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        guard let touch = touches.first else{
            return
        }
        let pt = touch.location(in: self)
        // 因为 core text 的坐标系,与 UIKit 坐标系的不同
        // 需要做一个 y 轴的平移翻转
        let eventPt = CGPoint(x: pt.x, y: (bounds.size.height - pt.y))
        if let one = points[0], one.within(point: eventPt){
            // 识别到事件一
            print("0")
        }
        else if let two = points[1], two.within(point: eventPt){
        	// 识别到事件 2
            print("1")
        }
        
    }

}
还可以优化,考虑到可能换行,对于事件文本的几个字,每个字的中心点,都算出来

github repo