IOS 拾遗程序员计算机杂谈

干货:CoreText教程:制作一个简单图文混排的杂志(翻译向)

2017-11-25  本文已影响52人  madaoCN

学习CoreText入门时发现的文章,特此翻译下来,以资他人学习之用 原文链接 : https://www.raywenderlich.com/153591/core-text-tutorial-ios-making-magazine-app

Core Text是一个可以结合Core Graphics/Quartz框架的底层文本渲染引擎,可以提供精细的布局和格式化控制
iOS7,苹果发布了高层级的Text Kit库,它可以存储,列出并显示具有各种排版特征的文本。尽管Text Kit非常强大,也满足日常文本排版的需要,但是Core Text可以提供更精细的控制。例如,如果你需要直接使用Quartz框架,可以使用Core Text, 如果你需要打造自己独有的排版引擎,Core Text能帮你实现 对字体与相对位置相关的特征,进行精细的控制

此教程将使用Core Text从0到1创建一个简单的杂志应用,那么开始吧

热身

override func draw(_ rect: CGRect) {
        
        // 获取当前上下文
        guard let context = UIGraphicsGetCurrentContext() else {
            return;
        }
        //转换成uikit坐标系
        context.textMatrix = .identity
        context.translateBy(x: 0, y: rect.height)
        context.scaleBy(x: 1, y: -1)
        // 绘制区域路径
        let path = CGMutablePath.init()
        path.addRect(rect)
        // 初始化富文本
        let attString = NSAttributedString.init(string: "hello word")
        // 创建 CTFramesetter
        let frameSetter = CTFramesetterCreateWithAttributedString(attString as CFAttributedString)
        // 创建 CTFrame
        let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, attString.length), path, nil)
        // 在指定上下文绘制CTFrame
        CTFrameDraw(frame, context)
}

由于Quartz坐标系与UIKit坐标系略有不同,所以需要对坐标系进行一次转换,否则绘制出来的文本将会是倒置的

context.textMatrix = .identity
context.translateBy(x: 0, y: rect.height)
context.scaleBy(x: 1, y: -1)

项目跑一下, 成功渲染


渲染文本

当你创建了一个CTFramesetter,并且为它提供了一个NSAttributedString,将会自动创建一个CTTypesetter来管理字体,接下来就可以使用CTFramesetter创建一个或者多个CTFrame来渲染文本

创建CTFrame的时候,CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, attString.length), path, nil)我们给定了范围和文本的绘制路径范围,Core Text自动为每一行文本创建了一个CTLine,每一块文本创建了一个CTRun, 每个CTRun能够设置不同的属性,例如你可以创建一个红色字体的CTRun,再创建另一个为粗体的CTRun,这就是为什么说Core Text能对文本进行精细控制的原因了

撸起袖子,开干

请下载素材链接:百度云链接:https://pan.baidu.com/s/1dF5zVkH 解压之后导入工程即可

我们需要对不同的文本设置不同的属性,我们需要新建一个文本修饰格式解析器来解析 zombies.txt中的属性标签来格式化文本

zombies.txt

img src标签引用了图片,font color/face标签决定了文本的颜色和字体

MarkupParser.swift键入如下代码

// MARK: - 属性
var color: UIColor = .black
var fontName: String = "Arial"
var attrString: NSMutableAttributedString!
var images: [[String: Any]] = []
    
// MARK: - 初始化方法
override init() {
    super.init()
}
    
// MARK: - 内部方法,解析html文本
func parseMarkup(_ markup: String) {
    
}

类中带有字体颜色,字体等属性,parseMarkup(_:)将从文本中解析成属性文本

对于以下文本

These are <font color="red">red<font color="black"> and
<font color="blue">blue <font color="black">words.

将被解析渲染成如下样式


//  attrString初始化为空,最终会被赋值最终解析结果
attrString = NSMutableAttributedString(string: "")
// 解析
do {
       // 匹配标签块
       let regex = try NSRegularExpression.init(pattern: "(.*?)(<[^>]+>|\\Z)", options: NSRegularExpression.Options.dotMatchesLineSeparators)
       let chunks = regex.matches(in: markup, options: NSRegularExpression.MatchingOptions.init(rawValue: 0), range: NSRange.init(location: 0, length: markup.count))
} catch _ {
}

正则匹配出所有的标签块


现在所有的匹配结果都在chunks变量中,只需要遍历chunks来创建AttributedString即可

在此之前,我们注意到matches(in:options:range:)接受了一个NSRange类型参数,接下来还会有许多用到NSRange 转换成 Range的地方,添加如下代码,可将 NSRange 转换成 Range:

// MARK: - String NSRange 转换成 Range
extension String {
    func range(from range: NSRange) -> Range<String.Index>? {
        guard let from16 = utf16.index(utf16.startIndex,
                                       offsetBy: range.location,
                                       limitedBy: utf16.endIndex),
            let to16 = utf16.index(from16, offsetBy: range.length, limitedBy: utf16.endIndex),
            let from = String.Index(from16, within: self),
            let to = String.Index(to16, within: self) else {
                return nil
        }
        return from ..< to
    }
}

parseMarkup(_:) 继续添加如下代码

// 设定默认字体
let defaultFont: UIFont = .systemFont(ofSize: UIScreen.main.bounds.size.height / 40)
// 遍历匹配结果
for chunk in chunks {
    // 获取当前匹配结果 NSTextCheckingResult 在原文本中的范围
    guard let markupRange = markup.range(from: chunk.range) else { continue }
    // 以符号 "<" 分割句子
    let parts = markup[markupRange].components(separatedBy: "<")
    // 从 fontName 属性(Arial)创建字体, 若无该字体,则使用默认字体 defaultFont
    let font = UIFont(name: fontName, size: UIScreen.main.bounds.size.height / 40) ?? defaultFont
    // 为 NSAttributedString 创建 字体颜色和字体 属性
    let attrs = [NSAttributedStringKey.foregroundColor: color, NSAttributedStringKey.font: font] as [NSAttributedStringKey : Any]
    // 将属性 应用于 parts[0]
    let text = NSMutableAttributedString(string: parts[0], attributes: attrs)
    attrString.append(text)
}

为了解析处理font标签,继续添加如下代码

// 如果分割后的模式数组长度小于等于1,则略过 说明不带有形如 <> 的匹配
if parts.count <= 1 {
    continue
}
let tag = parts[1]
// 如果 parts[1] ( < 之后的文本,也就是标签名) 是 font
if tag.hasPrefix("font") {
    // 匹配颜色属性
    let colorRegex = try NSRegularExpression(pattern: "(?<=color=\")\\w+",
                                             options: NSRegularExpression.Options(rawValue: 0))
    colorRegex.enumerateMatches(in: tag,
                                options: NSRegularExpression.MatchingOptions(rawValue: 0),
                                range: NSMakeRange(0, tag.characters.count)) {
            (match, _, _) in
            // 利用 NSObject perform 方法对 color 属性 赋值获取到的颜色
            if let match = match,
                let range = tag.range(from: match.range) {
                let colorSel = NSSelectorFromString(tag[range]+"Color")
                color = UIColor.perform(colorSel).takeRetainedValue() as? UIColor ?? .black
            }
    }
    // 正则匹配 face 字体属性
    let faceRegex = try NSRegularExpression(pattern: "(?<=face=\")[^\"]+",
                                            options: NSRegularExpression.Options(rawValue: 0))
    faceRegex.enumerateMatches(in: tag,
                               options: NSRegularExpression.MatchingOptions(rawValue: 0),
                               range: NSMakeRange(0, tag.characters.count)) { (match, _, _) in
                                
            if let match = match,
                let range = tag.range(from: match.range) {
                fontName = String(tag[range])
            }
    }
} //end of font parsing

到现在为止,已经能够解析出 NSAttributedString

在我们的CTView.swift中添加

// MARK: - Properties
var attrString: NSAttributedString!

// MARK: - Internal
func importAttrString(_ attrString: NSAttributedString) {
  self.attrString = attrString
}

然后 draw(_ rect: CGRect)中删除
let attrString = NSAttributedString(string: "Hello World") from draw(_:)

ViewController.swift中设置入口

let ctView = CTView()
ctView.frame = view.frame
view.addSubview(ctView)

guard let file = Bundle.main.path(forResource: "zombies", ofType: "txt") else { return }

do {
    let text = try String(contentsOfFile: file, encoding: .utf8)
    // 解析器解析
    let parser = MarkupParser()
    parser.parseMarkup(text)
    ctView.importAttrString(parser.attrString)

} catch _ {
}

运行一下,Cool, 效果如下

image.png

杂志布局

我们不仅仅满足一个只显示单页面的应用,CTFrameGetVisibleStringRange使我们能控制一个frame中能显示多少文字,你可以创建列,显示满了之后,可以再次创建一列
在这个应用中,我们将以列为单位,构建多个页面,最终构成一个杂志APP

Let us down

我们先将CTView.swift中基类换成UIScrollView, 使App能够支持多页滚动

class CTView: UIScrollView {

到目前为止我们在CTView.swift中创建了一个framesetter,生成了并绘制了一个CTFrame
接下来创建一个新的类CTColumnView.swift继承于UIView

class CTColumnView: UIView {

    var ctFrame: CTFrame!
    
    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
    }
    
    required init(frame: CGRect, ctframe: CTFrame) {
        super.init(frame: frame)
        self.ctFrame = ctframe
        backgroundColor = .white
    }
    
    // MARK: -
    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        
        // 转换成UIKit坐标系
        context.textMatrix = .identity
        context.translateBy(x: 0, y: bounds.size.height)
        context.scaleBy(x: 1.0, y: -1.0)
        // 在上下文中绘制 CTFrame
        CTFrameDraw(ctFrame, context)
    }
}

接下来我们需要一个CTSettings.swift来对Column列进行配置

class CTSettings {

    // MARK: - 属性
    let margin: CGFloat = 20 // 边距
    var columnsPerPage: CGFloat! // 每页列数
    var pageRect: CGRect! // 页面大小
    var columnRect: CGRect! // 列大小
    
    // MARK: - 初始化
    init() {
        // 如果是iphone 每页显示1列,否则每页两列
        columnsPerPage = UIDevice.current.userInterfaceIdiom == .phone ? 1 : 2
        // 页面frame 边距设置为 margin大小
        pageRect = UIScreen.main.bounds.insetBy(dx: margin, dy: margin)
        // 设置列的frame
        columnRect = CGRect(x: 0,
                            y: 0,
                            width: pageRect.width / columnsPerPage,
                            height: pageRect.height).insetBy(dx: margin, dy: margin)
    }
}

打开CTView.swift, 删去已有代码, 添加如下代码

class CTView: UIScrollView {

func buildFrames(withAttrString attrString: NSAttributedString,
                     andImages images: [[String: Any]]) {
        // 允许UIScrollview 翻页进行翻动
        isPagingEnabled = true
        // CTFrameSetter 将创建每列对应的 CTFrame
        let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
        // 属性
        var pageView = UIView()
        var textPos = 0 //当前字符所在位置
        var columnIndex: CGFloat = 0 //当前列下标
        var pageIndex: CGFloat = 0 //当前页面下标
        let settings = CTSettings() //配置
        // 循环遍历,生成列
        while textPos < attrString.length {

继续向遍历,生成列的循环代码添加:

// columnIndex %s ettings.columnsPerPage为零(truncatingRemainder:对浮点数取余),说明为页面第一列,需要新建一个页,并设置frame
    if columnIndex.truncatingRemainder(dividingBy: settings.columnsPerPage) == 0 {
        columnIndex = 0
        pageView = UIView(frame: settings.pageRect.offsetBy(dx: pageIndex * bounds.width, dy: 0))
        addSubview(pageView)
        // 页面索引自增
        pageIndex += 1
    }
    // 列宽度
    let columnXOrigin = pageView.frame.size.width / settings.columnsPerPage
    // 列偏移量
    let columnOffset = columnIndex * columnXOrigin
    // 计算列的frame
    let columnFrame = settings.columnRect.offsetBy(dx: columnOffset, dy: 0)
    
    // 创建位置路径,确定text分绘制范围
    let path = CGMutablePath()
    path.addRect(CGRect(origin: .zero, size: columnFrame.size))
    // 创建 CTFramesetter 用来创建 CTFrame
    let ctframe = CTFramesetterCreateFrame(framesetter, CFRangeMake(textPos, 0), path, nil)
    // 创建列视图
    let column = CTColumnView(frame: columnFrame, ctframe: ctframe)
    pageView.addSubview(column)
    // 获取CTFrame 能容纳多少文本,从而更新textPos
    let frameRange = CTFrameGetVisibleStringRange(ctframe)
    textPos += frameRange.length
    // 列数指针自增
    columnIndex += 1
}

上述代码中,确定和计算出了每一列视图的位置和应该显示的文本范围
最后,代码末尾,只需要重新设定下UIScrollViewcontentSize即可

// 更新UIScrollview的contentSize
contentSize = CGSize(width: CGFloat(pageIndex) * bounds.size.width,
                             height: bounds.size.height)

ViewController中调用ctView.buildFrames即可

let text = try String(contentsOfFile: file, encoding: .utf8)
let parser = MarkupParser()
parser.parseMarkup(text)
//ctView.importAttrString(parser.attrString)
ctView.buildFrames(withAttrString: parser.attrString, andImages: parser.images)

运行一下,wonderful,一个可翻页效果的App就有了


预览

为App渲染图片

尽管Core text无法直接绘制图片,但是它可以为图片预留显示空间 ,通过CTRun的代理CTRunDelegate,我们可以设定CTRun的上升下降高度,和它的宽度,模型如下图所示

CTRunDelegate

每当Core Text遇到一个CTRun,它就会询问代理我需要为这数据块预留多少空间?,通过CTRunDelegate,我们就能为图片显示预留出空间了

首先在MarkupParser.swift中添加解析img标签的代码

// image 数组 添加 图片属性字典
images += [["width": NSNumber(value: Float(width)),
            "height": NSNumber(value: Float(height)),
            "filename": filename,
            "location": NSNumber(value: attrString.length)]]
// 定义CTRun属性结构体
struct RunStruct {
    let ascent: CGFloat
    let descent: CGFloat
    let width: CGFloat
}
// Memory指针 相当于RunStruct 结构体指针
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
extentBuffer.initialize(to: RunStruct(ascent: height, descent: 0, width: width))
//  创建CTRunDelegateCallbacks 控制占位
var callbacks = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { (pointer) in
}, getAscent: { (pointer) -> CGFloat in
    let d = pointer.assumingMemoryBound(to: RunStruct.self)
    return d.pointee.ascent
}, getDescent: { (pointer) -> CGFloat in
    let d = pointer.assumingMemoryBound(to: RunStruct.self)
    return d.pointee.descent
}, getWidth: { (pointer) -> CGFloat in
    let d = pointer.assumingMemoryBound(to: RunStruct.self)
    return d.pointee.width
})
// 创建绑定了回调的代理
let delegate = CTRunDelegateCreate(&callbacks, extentBuffer)
// 将代理封装至属性字典
let attrDictionaryDelegate = [(kCTRunDelegateAttributeName as NSAttributedStringKey): (delegate as Any)]
attrString.append(NSAttributedString(string: " ", attributes: attrDictionaryDelegate))
}

现在MarkupParser可以解析处理img标签了,现在我们只需让CTColumnView && CTView绘制出来就行了

对于CTColumnView.swift添加属性

var images: [(image: UIImage, frame: CGRect)] = []

并在draw(_ rect: CGRect)中添加绘制图片代码

// 绘制图片
for imageData in images {
    if let image = imageData.image.cgImage {
        let imgBounds = imageData.frame
        context.draw(image, in: imgBounds)
    }
}

CTView.swift中添加属性

var imageIndex: Int!

并且在buildFrames(withAttrString:andImages:):方法中做初始化

imageIndex = 0

再次添加attachImagesWithFrame(_:ctframe:margin:columnView)方法

func attachImagesWithFrame(_ images: [[String: Any]],
                           ctframe: CTFrame,
                           margin: CGFloat,
                           columnView: CTColumnView) {
    // 获取ctframe 的`CTLine`数组
    let lines = CTFrameGetLines(ctframe) as NSArray
    // 使用CTFrameGetLineOrigins 将ctframe中的行origin 复制到数组 origins
    var origins = [CGPoint](repeating: .zero, count: lines.count)
    // CFRangeMake(0, 0)代表转换整个CTFrame
    CTFrameGetLineOrigins(ctframe, CFRangeMake(0, 0), &origins)
    // 获取图片对象的location属性,如果没有值直接返回
    var nextImage = images[imageIndex]
    guard var imgLocation = nextImage["location"] as? Int else {
        return
    }
    // 遍历CTLine
    for lineIndex in 0..<lines.count {
    }
}

继续在循环中添加代码

// 遍历CTLine
for lineIndex in 0..<lines.count {
    let line = lines[lineIndex] as! CTLine
    // 如果CTRun, 文件名,图片都存在
    if let glyphRuns = CTLineGetGlyphRuns(line) as? [CTRun],
        let imageFilename = nextImage["filename"] as? String,
        let img = UIImage(named: imageFilename)  {
        for run in glyphRuns {
            // 如果当前CTRun的范围range没有包含nextImage,直接进入一下循环
            let runRange = CTRunGetStringRange(run)
            if runRange.location > imgLocation || runRange.location + runRange.length <= imgLocation {
                continue
            }
            // 通过 CTRunGetTypographicBounds 计算图片的大小
            var imgBounds: CGRect = .zero
            var ascent: CGFloat = 0
            imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil))
            imgBounds.size.height = ascent
            // 通过 CTLineGetOffsetForStringIndex 计算 CTLine x轴的偏移量,
            let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
            // 偏移量需要加上 imgBounds 的 origin
            imgBounds.origin.x = origins[lineIndex].x + xOffset
            imgBounds.origin.y = origins[lineIndex].y
            // 将image 和 image绘制的位置 加入 columnView
            columnView.images += [(image: img, frame: imgBounds)]
            // 图片下标自增,更新imgLocation
            imageIndex! += 1
            if imageIndex < images.count {
                nextImage = images[imageIndex]
                imgLocation = (nextImage["location"] as AnyObject).intValue
            }
        }
    }
}

最终,在buildFrames(withAttrString:andImages:)方法中,语句pageView.addSubview(column)之前调用即可

if images.count > imageIndex {
  attachImagesWithFrame(images, ctframe: ctframe, margin: settings.margin, columnView: column)
}

大功告成


APP

github完整源码地址(注释完备) 传送门:https://github.com/madaoCN/CoreTextMagzine 给个start呗

上一篇下一篇

猜你喜欢

热点阅读