美文网首页UI效果iOS精华iOS备忘录
Swift之CoreText排版神器(续)

Swift之CoreText排版神器(续)

作者: smalldu | 来源:发表于2016-02-03 15:44 被阅读1598次

    本篇是续上篇 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

    相关文章

      网友评论

      本文标题:Swift之CoreText排版神器(续)

      本文链接:https://www.haomeiwen.com/subject/ircskttx.html