Swift之CoreText排版神器(續)

本篇是續上篇 Swift之CoreText排版神器(長篇高能) ,沒看上篇基礎篇的建議從上篇看起.

上篇我們介紹了NSAttributeString屬性字和CoreText的簡單使用,以及簡單的圖文混排。這節我們使用CoreText來解決些其他問題。

先來看一副圖

字形

如圖,Origin那塊是基線相當于原點,descent是向下的 一般是負值,ascent是正值,還有lineHeight和capHeight還有x-height都在圖中標出,那在代碼中如何獲取這些值呢?

let font = UIFont.systemFontOfSize(14)
print(font.descender)       //-3.376953125
print(font.ascender)        //13.330078125
print(font.lineHeight)      //16.70703125
print(font.capHeight)       //9.8642578125
print(font.xHeight)         //7.369140625
print(font.leading)         //0.0

這里定義一個14號的文字 取出對應的各個值。這些都是只讀的

純文本排版的時候會有一些細節問題,我們先來繪制一個帶有中文,英文,數字以及emoji表情的文本看看效果。

未處理前的繪制

可以看出在含有emoji的那幾行占有的高度會比較高,空隙比較大。所以如果按boundingRectWithSize 根據字體大小和寬度來計算文本高度的方法就不行了,而且這樣排版看起來也不是很美觀,這樣我們就不能直接用CTFrameDraw來繪制了,可能需要給定行高,一行一行繪制 ,使用CTLineDraw

首先我們需要計算出文字所占的Size.

let SCREEN_WIDTH:CGFloat = UIScreen.mainScreen().bounds.size.width  //屏幕寬度
 /**
     計算Size
     
     - parameter txt: 文本
     
     - returns: size
     */
    func sizeForText(mutableAttrStr:NSMutableAttributedString)->CGSize{
        //創建CTFramesetterRef實例
        let frameSetter = CTFramesetterCreateWithAttributedString(mutableAttrStr)
        
        // 獲得要繪制區域的高度
        let restrictSize = CGSizeMake(SCREEN_WIDTH-20, CGFloat.max)
        let coreTextSize = CTFramesetterSuggestFrameSizeWithConstraints(frameSetter, CFRangeMake(0, 0) , nil, restrictSize, nil)
        return coreTextSize
    }

很簡單 ,根據屬性字得到framesetter 然后再根據framesetter計算出所占的size。

得到size后要怎么操作呢?

這塊我貼下所有代碼 注釋很詳細

import UIKit

class CTextView: UIView {
    
    let SCREEN_WIDTH:CGFloat = UIScreen.mainScreen().bounds.size.width  //屏幕寬度
    let SCREEN_HEIGHT:CGFloat = UIScreen.mainScreen().bounds.size.height    //屏幕高度
    
    override func drawRect(rect: CGRect) {
        super.drawRect(rect)
        
        // 1 獲取上下文
        let context = UIGraphicsGetCurrentContext()
        
        // 2 轉換坐標
        CGContextSetTextMatrix(context, CGAffineTransformIdentity)
        CGContextTranslateCTM(context, 0, self.bounds.size.height)
        CGContextScaleCTM(context, 1.0, -1.0)
        
        // 3 繪制區域
        let path = UIBezierPath(rect: rect)
        
        // 4 創建需要繪制的文字
        let attrString = "來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-蘭emoji??????????????????????水電費洛杉磯大立科技??????????????索拉卡叫我??????????sljwolw19287812來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這"
        
        // 5 設置frame
        let mutableAttrStr = NSMutableAttributedString(string: attrString)
        let framesetter = CTFramesetterCreateWithAttributedString(mutableAttrStr)
        let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, mutableAttrStr.length), path.CGPath, nil)
        
        // 6 取出CTLine 準備一行一行繪制
        let lines = CTFrameGetLines(frame)
        let lineCount = CFArrayGetCount(lines)
        
       
        var lineOrigins:[CGPoint] = Array(count:lineCount,repeatedValue:CGPointZero)
        
        //把frame里每一行的初始坐標寫到數組里,注意CoreText的坐標是左下角為原點
        CTFrameGetLineOrigins(frame, CFRangeMake(0, 0),&lineOrigins)
        //獲取屬性字所占的size
        let size = sizeForText(mutableAttrStr)
        let height = size.height
        
        let font = UIFont.systemFontOfSize(14)
        var frameY:CGFloat = 0
        // 計算每行的高度 (總高度除以行數)
        let lineHeight = height/CGFloat(lineCount)
        for i in 0..<lineCount{
            
            let lineRef = unsafeBitCast(CFArrayGetValueAtIndex(lines,i), CTLineRef.self)
            
            var lineAscent:CGFloat = 0
            var lineDescent:CGFloat = 0
            var leading:CGFloat = 0
            //該函數除了會設置好ascent,descent,leading之外,還會返回這行的寬度
            CTLineGetTypographicBounds(lineRef, &lineAscent, &lineDescent, &leading)
            
            var lineOrigin = lineOrigins[i]
            
            //計算y值(注意左下角是原點)
            frameY = height - CGFloat(i + 1)*lineHeight - font.descender
            //設置Y值
            lineOrigin.y = frameY
            
            //繪制
            CGContextSetTextPosition(context,lineOrigin.x, lineOrigin.y)
            CTLineDraw(lineRef, context!)
            
            //調整坐標
            frameY = frameY - lineDescent
        }
        //
//        CTFrameDraw(frame,context!)
    }
    
    /**
     計算Size
     
     - parameter txt: 文本
     
     - returns: size
     */
    func sizeForText(mutableAttrStr:NSMutableAttributedString)->CGSize{
        //創建CTFramesetterRef實例
        let frameSetter = CTFramesetterCreateWithAttributedString(mutableAttrStr)
        
        // 獲得要繪制區域的高度
        let restrictSize = CGSizeMake(SCREEN_WIDTH-20, CGFloat.max)
        let coreTextSize = CTFramesetterSuggestFrameSizeWithConstraints(frameSetter, CFRangeMake(0, 0) , nil, restrictSize, nil)
        return coreTextSize
    }

}

前面5步和前一個小結是一樣的,這次拿到CTFrame后我們并沒有直接調用CTFrameDraw 來繪制。而是獲取所有的CTLine,拿到CTLine后再計算總高度。根據總高度計算每行高度,然后循環CTLine,獲取每個Line計算Y指標的值,逐行設置位置然后Draw上去。

CGContextSetTextPosition(context,lineOrigin.x, lineOrigin.y) 
CTLineDraw(lineRef, context!)

看下對比效果

對比效果

左邊是直接畫的,右邊是逐行計算后畫的,等高了,看起來不會有層次不齊的感覺了!

下面看看怎么自動識別連接等

實現的思路主要是給控件添加手勢點擊并進行監聽,在用戶點擊時拿到點擊的位置,并在手勢識別結束后用CoreText遍歷每一個CTLine,判斷點擊的位置是否在識別的特定字符串內,如果是則找出該字符串。使CTLineGetStringIndexForPosition函數來找出點擊的字符位于整個字符串的位置。

首先寫兩個正則來檢測文本中的@和鏈接, 如果您有任何需要檢測的都可以添加正則去實現。

//url的正則
let regex_url = "(http|ftp|https):\\/\\/[\\w\\-_]+(\\.[\\w\\-_]+)+([\\w\\-\\.,@?^=%&:/~\\+#]*[\\w\\-\\@?^=%&/~\\+#])?"
    
let regex_someone = "@[^\\s@]+?\\s{1}"

關于正則表達式,不再本文討論范圍內,自行google....

然后就要根據正則匹配字符串。返回對應的range并且修改屬性字的顏色。

//識別特定字符串并改其顏色,返回識別到的字符串所在的range
    func recognizeSpecialStringWithAttributed(attrStr:NSMutableAttributedString)->[NSRange]{
        // 1
        var rangeArray = [NSRange]()
        //識別人名字
        // 2
        let atRegular = try? NSRegularExpression(pattern: regex_someone, options: NSRegularExpressionOptions.CaseInsensitive) //不區分大小寫的
        // 3
        let atResults = atRegular?.matchesInString(attrStr.string, options: NSMatchingOptions.WithTransparentBounds , range: NSMakeRange(0, attrStr.length))
        // 4
        for checkResult in atResults!{
            attrStr.addAttribute(NSForegroundColorAttributeName, value: UIColor.redColor(), range: NSMakeRange(checkResult.range.location, checkResult.range.length))
            rangeArray.append(checkResult.range)
        }
        
        
        //識別鏈接
        let atRegular1 = try? NSRegularExpression(pattern: regex_url, options: NSRegularExpressionOptions.CaseInsensitive) //不區分大小寫的
        let atResults1 = atRegular1?.matchesInString(attrStr.string, options: NSMatchingOptions.WithTransparentBounds , range: NSMakeRange(0, attrStr.length))
        
        for checkResult in atResults1!{
            attrStr.addAttribute(NSForegroundColorAttributeName, value: UIColor.blueColor(), range: NSMakeRange(checkResult.range.location, checkResult.range.length))
            rangeArray.append(checkResult.range)
        }
        

        return rangeArray
    }

我這里就識別了@和鏈接,大家自行添加,這里為了方便并沒有封裝,大家可以封裝下,使用一個結構體,有NSRange 數組 和type類型 或者字典類型,自由發揮。這里只做識別。稍微解釋下代碼

1、定義一個數組存放匹配的Range集合

2、根據前面定義的正則創建一個正則表達式對象,這里會拋出異常為了方便,并沒有處理。option這里使用了CaseInsensitive不區分大小寫。還有很多別的選項。直接command+點擊看。

3、拿到匹配的結果集,包含range屬性(我們需要的)

4、循環添加到數組,并給這個range的字符修改顏色屬性。

解析方法準備好之后就可以開始繪制了,和前面的大同小異

 let SCREEN_WIDTH:CGFloat = UIScreen.mainScreen().bounds.size.width  //屏幕寬度
    let SCREEN_HEIGHT:CGFloat = UIScreen.mainScreen().bounds.size.height    //屏幕高度
  
    
    var lineHeight:CGFloat = 0
    var ctFrame:CTFrameRef?
    
    var spcialRanges = [NSRange]()
    
    //url的正則
    let regex_url = "(http|ftp|https):\\/\\/[\\w\\-_]+(\\.[\\w\\-_]+)+([\\w\\-\\.,@?^=%&:/~\\+#]*[\\w\\-\\@?^=%&/~\\+#])?"
    
    let regex_someone = "@[^\\s@]+?\\s{1}"
    
    let str = "來一段數 @sd圣誕節 字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl http://www.baidu.com 是電話費卡刷卡來這來一段數字,文本emoji http://www.zuber.im 的哈哈哈29993002-309-sdflslsfl是電話費卡 @kakakkak 刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-蘭emoji??????????????????????水電費洛杉磯大立科技??????????????索拉卡叫我??????????sljwolw19287812來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這來一段數字,文本emoji的哈哈哈29993002-309-sdflslsfl是電話費卡刷卡來這"
    var pressRange:NSRange?
    var mutableAttrStr:NSMutableArray!
    var selfHeight:CGFloat = 0
    
    override func drawRect(rect: CGRect) {
        super.drawRect(rect)
        // 1 獲取上下文
        let context = UIGraphicsGetCurrentContext()
        
        // 2 轉換坐標
        CGContextSetTextMatrix(context, CGAffineTransformIdentity)
        CGContextTranslateCTM(context, 0, self.bounds.size.height)
        CGContextScaleCTM(context, 1.0, -1.0)
        
        // 3 繪制區域
        let path = UIBezierPath(rect: rect)
        
        // 4 創建需要繪制的文字

        
        // 5 設置frame
        let mutableAttrStr = NSMutableAttributedString(string: str)
       // 獲取特殊字符range 繪制特殊字符
        self.spcialRanges = recognizeSpecialStringWithAttributed(mutableAttrStr)
        
        let framesetter = CTFramesetterCreateWithAttributedString(mutableAttrStr)
        ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, mutableAttrStr.length), path.CGPath, nil)
        
        // 6 取出CTLine 準備一行一行繪制
        let lines = CTFrameGetLines(ctFrame!)
        let lineCount = CFArrayGetCount(lines)
        
        
        var lineOrigins:[CGPoint] = Array(count:lineCount,repeatedValue:CGPointZero)
        
        //把frame里每一行的初始坐標寫到數組里,注意CoreText的坐標是左下角為原點
        CTFrameGetLineOrigins(ctFrame!, CFRangeMake(0, 0),&lineOrigins)
        //獲取屬性字所占的size
        let size = sizeForText(mutableAttrStr)
        let height = size.height
//        self.frame.size.height = height
        
        let font = UIFont.systemFontOfSize(14)
        var frameY:CGFloat = 0
        // 計算每行的高度 (總高度除以行數)
        lineHeight = height/CGFloat(lineCount)
        for i in 0..<lineCount{
            
            let lineRef = unsafeBitCast(CFArrayGetValueAtIndex(lines,i), CTLineRef.self)
            
            var lineAscent:CGFloat = 0
            var lineDescent:CGFloat = 0
            var leading:CGFloat = 0
            //該函數除了會設置好ascent,descent,leading之外,還會返回這行的寬度
            CTLineGetTypographicBounds(lineRef, &lineAscent, &lineDescent, &leading)
            
            var lineOrigin = lineOrigins[i]
            
            //計算y值(注意左下角是原點)
            frameY = height - CGFloat(i + 1)*lineHeight - font.descender
            //設置Y值
            lineOrigin.y = frameY
            
            //繪制
            CGContextSetTextPosition(context,lineOrigin.x, lineOrigin.y)
            CTLineDraw(lineRef, context!)
            
            //調整坐標
            frameY = frameY - lineDescent
        }
    }

這段代碼和之前差不多,只不過把一些變量放在外面定義以便別的方法使用。還有就是加上了那個解析的方法

self.spcialRanges = recognizeSpecialStringWithAttributed(mutableAttrStr)

在ViewController中調用

let ctURLView = CTURLView()
ctURLView.frame = CGRectMake(10, 100, self.view.bounds.width - 20, 300)
ctURLView.backgroundColor = UIColor.grayColor()
let mutableAttrStr = NSMutableAttributedString(string: ctURLView.str)
let size = ctURLView.sizeForText(mutableAttrStr)
ctURLView.frame.size = size
self.view.addSubview(ctURLView)

這邊要先計算下size

看下效果

配圖

不錯吧 并沒有多少代碼就識別出來了,下面看看處理點擊事件

首先注冊一個tap手勢并設置下代理

    override init(frame: CGRect) {
        super.init(frame: frame)
        //添加手勢
        let tap = UITapGestureRecognizer(target: self, action: "tap:")
        tap.delegate = self
        self.addGestureRecognizer(tap)
    }

然后實現手勢方法和代理

extension CTURLView:UIGestureRecognizerDelegate{

    func tap(gesture:UITapGestureRecognizer){
    
        if gesture.state == .Ended{
            let nStr = self.str as NSString
            let pressStr = nStr.substringWithRange(self.pressRange!)
            print(pressStr)
        }
    }

    override func gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer) -> Bool {
        //點擊處在特定字符串內才進行識別
        var gestureShouldBegin = false
        // 1
        let location = gestureRecognizer.locationInView(self)
        
        // 2
        let lineIndex = Int(location.y/lineHeight)
        
        print("你點擊了第\(lineIndex)行")
        
        // 3 把點擊的坐標轉換為CoreText坐標系下
        let clickPoint = CGPointMake(location.x, lineHeight-location.y)
        
        let lines = CTFrameGetLines(self.ctFrame!);
        let lineCount = CFArrayGetCount(lines)
        if lineIndex < lineCount{
            
            let clickLine =  unsafeBitCast(CFArrayGetValueAtIndex(lines,lineIndex), CTLineRef.self)
            // 4 點擊的index
            let startIndex = CTLineGetStringIndexForPosition(clickLine, clickPoint)
            
            print("strIndex = \(startIndex)")
            // 5
            for range in self.spcialRanges{
                
                if startIndex >= range.location && startIndex <= range.location + range.length{
                    
                    gestureShouldBegin = true
                    self.pressRange = range
                    print(range)
                    
                }   
            }
        }
        return gestureShouldBegin
    }
}

gestureRecognizerShouldBegin是一個代理方法,在這個方法中來檢測特殊字符串

1、拿到觸摸點在當前view的位置

2、根據y值和行高的比獲取line編號,lineHeight是一個全局變量。可以下載本文實例代碼對照看

3、轉成CoreText下坐標

4、獲取字符的index

5、循環特殊字符的range查看index是否在range中 如果在range中 把當前按下的range賦值為此range,打印出來。

這時候 在tap方法中取出這個range對應的字符串 打印出來,如果要處理對應事件。也可在tap這里處理

來看下

配圖

CoreText 能做的不止這些希望大家這兩篇文章可以帶大家了解CoreText,不再懼怕使用底層的API。

實例代碼地址:https://github.com/smalldu/ZZCoreTextDemo

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容