翻译 - Core Text Tutorial for iOS: Making a Magazine App

原文:Core Text Tutorial for iOS: Making a Magazine App

更新说明:本教程已由Lyndsey Scott更新到Swift 4和Xcode 9。最初的教程是由Marin Todorov编写的。

Core Text是一个low-level 文本引擎,当与Core Graphics/Quartz框架一起使用时,它可以让你对布局和格式进行粒度更加精细的控制。

在iOS 7中,苹果发布了一个名为Text Kit的高级库,用于存储、布局和显示具有各种排版特征的文本。虽然Text Kit是强大的,通常在布局文本时是足够的,但Core Text可以提供更多的控制。例如,如果你需要直接使用Quartz,使用Core Text。如果你需要创建自己的布局引擎,Core Text将帮助你生成“ glyphs,以及它们彼此精细排版后相对定位。”

本教程将带你通过使用Core Text创建一个非常简单的杂志应用程序的过程……

哦,《僵尸月刊》的读者们已经同意,只要你在本教程中还在使用它们,他们就不会吃你的大脑……所以你可能想要尽快开始!

注意:要充分利用本教程,你需要首先了解iOS开发的基础知识。如果你是iOS开发新手,你应该先看看这个网站上的其他教程。

开始

打开Xcode,用Single View Application Template创建一个新的Swift通用项目,命名为CoreTextMagazine。

接下来,将Core Text框架添加到项目中:

在项目导航器中单击项目文件(左侧的条带)
在“常规”下,向下滚动到底部的“链接框架和库”
点击“+”搜索“CoreText”
选择“CoreText.framework”,点击“Add”按钮。就是这样!
项目设置已经完成,该开始敲代码了。

添加一个Core Text View

对于初学者来说,你将创建一个自定义的UIView,它将在draw(_:)方法中使用Core Text。

创建一个新的Cocoa Touch 类文件,继承与UIView,类名CTView。

打开CTView.swift,并在import UIKit下添加以下内容:

import CoreText

接下来,将这个新的自定义视图设置为应用程序中的主视图。打开Main.storyboard,打开右手边的Utilities菜单,然后在它的顶部工具栏中选择Identity Inspector图标。在Interface Builder的左侧菜单中,选择View。工具菜单的Class字段现在应该显示UIView。要子类化主视图控制器的视图,在Class字段中键入CTView并按Enter键。


image

接下来,打开CTView.swift,用下面的语句替换掉注释掉的draw(_:):

//1      
override func draw(_ rect: CGRect) {         
  // 2       
  guard let context = UIGraphicsGetCurrentContext() else { return }      
  // 3       
  let path = CGMutablePath()         
  path.addRect(bounds)       
  // 4
  let attrString = NSAttributedString(string: "Hello World")
  // 5
  let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
  // 6
  let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attrString.length), path, nil) 
  // 7
  CTFrameDraw(frame, context)
}

让我们一步一步来。

在视图创建时,draw(_:)将自动运行以渲染视图的backing layer。
展开将用于绘图的当前图形上下文。
创建一个路径来限制绘制区域,在这个例子中是整个视图的边界
在Core Text中,你使用NSAttributedString,而不是String或NSString,来保存文本和它的属性。初始化“Hello World”为带属性字符串。
CTFramesetterCreateWithAttributedString使用提供的带属性字符串创建一个CTFramesetter。CTFramesetter将管理你的字体参考和你的绘图框架。
通过使用CTFramesetterCreateFrame渲染路径内的整个字符串,创建一个CTFrame。
CTFrameDraw在给定的上下文中绘制CTFrame。
这就是绘制一些简单文本所需要的全部内容!构建、运行并查看结果。


image

这似乎不对,是吗?像许多低级的api一样,Core Text使用y -翻转的坐标系统。更糟糕的是,内容也是垂直翻转的!

在guard let context语句下面直接添加以下代码,以修复内容方向:

// Flip the coordinate system
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)

这段代码通过将转换应用到视图的上下文来翻转内容。

构建并运行这个应用程序。不要担心状态栏重叠,稍后你将学习如何用边距修复这个问题。


image

祝贺你的第一个核心文本应用!僵尸们对你的进展很满意。

Core Text Object Model

如果你对CTFramesetter和CTFrame有点困惑,那没关系,因为是时候澄清一下了。:]

Core Text对象模型是这样的:


image

当你创建一个CTFramesetter引用并为它提供一个NSAttributedString时,一个CTTypesetter的实例就会被自动创建以供你管理字体。接下来,使用CTFramesetter创建一个或多个用来呈现文本的帧。

当您创建一个框架时,您为它提供要在其矩形内呈现的文本子范围。Core Text自动为每一行文本创建一个CTLine,为每一段具有相同格式的文本创建一个CTRun。例如,Core Text会创建一个CTRun,如果你在一行中有几个红色的单词,然后另一个CTRun用于下面的纯文本,然后另一个CTRun用于一个粗体句子,等等。Core Text为你创建基于提供的NSAttributedString属性的CTRun。此外,每个CTRun对象都可以采用不同的属性,因此您可以很好地控制字距、连接、宽度、高度等。

进入杂志应用程序!

下载并解压缩僵尸杂志材料
把这个文件夹拖到你的Xcode项目中。当出现提示时,请确保选中了Copy items if neededCreate groups

要创建应用程序,您需要对文本应用各种属性。您将创建一个简单的文本标记解析器,它将使用标记来设置杂志的格式。

创建一个新的CocoaTouch Class文件,继承NSObject,命名为MarkupParser。

首先,快速浏览一下zombies.txt。看到它是如何在整个文本中包含括弧格式标记的了吗?
" img src "标记参考杂志图像
"font color/face "标记决定文本颜色和字体。

打开MarkupParser.swift,并将其内容替换为以下内容:

import UIKit
import CoreText

class MarkupParser: NSObject {
  
  // MARK: - Properties
  var color: UIColor = .black
  var fontName: String = "Arial"
  var attrString: NSMutableAttributedString!
  var images: [[String: Any]] = []

  // MARK: - Initializers
  override init() {
    super.init()
  }
  
  // MARK: - Internal
  func parseMarkup(_ markup: String) {

  }
}

这里你添加了属性来保存字体和文本颜色;设置默认值;创建一个变量来保存由parseMarkup(_:)产生的带属性字符串;并创建了一个数组,该数组最终将保存定义文本中图像的大小、位置和文件名的字典信息。

编写一个解析器通常是很困难的工作,但是本教程的解析器将非常简单,并且只支持开始标记—这意味着一个标记将设置它后面文本的样式,直到找到一个新标记。文本标记看起来像这样:

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

并产生这样的输出:

这些是红色蓝色的单词。

我们开始尝试解析!

将以下内容添加到parseMarkup(_:):

//1
attrString = NSMutableAttributedString(string: "")
//2 
do {
  let regex = try NSRegularExpression(pattern: "(.*?)(<[^>]+>|\\Z)",
                                      options: [.caseInsensitive,
                                                .dotMatchesLineSeparators])
  //3
  let chunks = regex.matches(in: markup, 
                             options: NSRegularExpression.MatchingOptions(rawValue: 0), 
                             range: NSRange(location: 0,
                                            length: markup.characters.count))
} catch _ {
}
  1. attrString一开始为空,但最终将包含解析后的标记。
  2. 这个正则表达式将文本块与紧跟其后的标签进行匹配。它说,“遍历字符串直到你找到一个开始的括号,然后遍历字符串直到你碰到一个结束的括号(或文档的结尾)。”
  3. 搜索正则表达式匹配的标记的整个范围,然后生成生成的NSTextCheckingResults的数组。

注意:要了解更多关于正则表达式的知识,请查看NSRegularExpression教程。

现在您已经将所有文本和格式化标记解析为块,接下来将遍历块以构建带属性字符串。

但是在那之前,你注意到match(在:options:range:)是如何接受一个NSRange作为参数的吗?当你将nsregulareexpression函数应用到你的标记字符串时,会有很多NSRange到Range的转换。斯威夫特是我们所有人的好朋友,所以它应该得到帮助。

仍然在MarkupParser.swift中,在文件末尾添加以下扩展名:

// MARK: - String
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
  }
}

(不好意思,这块没看懂)
这个函数将字符串的开始和结束索引转换为 utf16view格式编码,即字符串的UTF-16代码单元集合中的位置;然后转换每个String.UTF16View。索引的字符串。指数格式;当合并时,生成Swift的range格式:range。只要索引是有效的,该方法将返回原始NSRange的Range表示。

你的Swift现在很冷静。现在回到处理文本和标记块的问题。


image

在parseMarkup(_:)中添加下面的let块(在do块中):

let defaultFont: UIFont = .systemFont(ofSize: UIScreen.main.bounds.size.height / 40)
//1
for chunk in chunks {  
  //2
  guard let markupRange = markup.range(from: chunk.range) else { continue }
  //3    
  let parts = markup[markupRange].components(separatedBy: "<")
  //4
  let font = UIFont(name: fontName, size: UIScreen.main.bounds.size.height / 40) ?? defaultFont       
  //5
  let attrs = [NSAttributedStringKey.foregroundColor: color, NSAttributedStringKey.font: font] as [NSAttributedStringKey : Any]
  let text = NSMutableAttributedString(string: parts[0], attributes: attrs)
  attrString.append(text)
}
  1. 循环块。
  2. 获取当前NSTextCheckingResult的range,打开range <String。索引>,并继续处理块,只要它存在。
  3. 用“<”分隔块。第一部分包含杂志文本,第二部分包含标签(如果存在的话)。
  4. 使用fontName创建一个字体,当前默认为“Arial”,以及一个相对于设备屏幕的大小。如果fontName没有产生一个有效的UIFont,设置字体为默认字体。
  5. 创建一个字体格式的字典,将其应用于部分[0]以创建带属性字符串,然后将该字符串附加到结果字符串。
    要处理"font"标签,在attrString.append(text)后面插入以下内容:
// 1
if parts.count <= 1 {
  continue
}
let tag = parts[1]
//2
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
      //3
      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
      }
  }
  //5    
  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
  1. 如果少于两个部分,则跳过循环体的其余部分。否则,将第二部分存储为标记。
  2. 如果标签以"font"开头,创建一个正则表达式来查找字体的"color"值,然后使用该正则表达式来枚举标签匹配的"color"值。在这种情况下,应该只有一个匹配的颜色值。
  3. 如果enumerateMatches(在:options:range:using:)返回一个有效的匹配与标签中的有效范围,找到指定的值(例如<font color="red">返回"red")并添加" color "以形成一个UIColor选择器。执行该选择器,然后将类的颜色设置为返回的颜色(如果存在),如果不存在则设置为黑色。
  4. 类似地,创建一个正则表达式来处理字体的“face”值。如果找到匹配,设置fontName为该字符串。
    伟大的工作!现在parseMarkup(_:)可以获取标记并为Core Text生成一个NSAttributedString。

是时候把你的应用喂给僵尸了!我的意思是,在你的应用中添加一些僵尸……zombies.txt。,)

UIView的工作是显示给它的内容,而不是加载内容。打开CTView.swift,并添加以下绘制draw(_:):

// MARK: - Properties
var attrString: NSAttributedString!

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

接下来,从draw(_:)中删除let attrString = NSAttributedString(string: "Hello World")。

在这里,您创建了一个实例变量来保存带属性字符串,并创建了一个方法来从应用程序的其他地方设置它。

接下来,打开ViewController.swift,并在viewDidLoad()中添加以下内容:

// 1
guard let file = Bundle.main.path(forResource: "zombies", ofType: "txt") else { return }
  
do {
  let text = try String(contentsOfFile: file, encoding: .utf8)
  // 2
  let parser = MarkupParser()
  parser.parseMarkup(text)
  (view as? CTView)?.importAttrString(parser.attrString)
} catch _ {
}

让我们一步一步来。

  1. 从zombie.txt文件中加载文本到字符串中。
  2. 创建一个新的解析器,输入文本,然后将返回的带属性字符串传递给ViewController的CTView。
  3. 构建并运行应用程序!


    image

太棒了?多亏了大约50行解析,你可以简单地使用一个文本文件来保存杂志应用程序的内容。

杂志的基本布局

如果你认为一本关于僵尸新闻的月刊可以放在一页纸里,那你就大错特错了!幸运的是,Core Text在布局列时变得特别有用,因为CTFrameGetVisibleStringRange可以告诉你有多少文本适合给定的框架。意思是,你可以创建一个列,当它满了,你可以创建另一个列,等等。

在这个应用程序中,你必须打印专栏,然后是页面,然后是整本杂志,以免冒犯不死族,所以……是时候把你的CTView子类变成UIScrollView了。

打开CTView.swift,将CTView类改为:

class CTView: UIScrollView {

看到这一幕,僵尸?该应用程序现在可以支持一个永恒的不死冒险!是的——只用一行,现在就可以滚动和分页了。

image

到目前为止,您已经在draw(_:)中创建了框架设置器和框架,但由于您将有许多格式不同的列,所以最好创建单独的列实例。

创建一个新的Cocoa Touch Class文件,命名为CTColumnView子类化UIView。

打开CTColumnView.swift,并添加以下入门代码:

import UIKit
import CoreText

class CTColumnView: UIView {
  
  // MARK: - Properties
  var ctFrame: CTFrame!
  
  // MARK: - Initializers
  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: - Life Cycle
  override func draw(_ rect: CGRect) {
    guard let context = UIGraphicsGetCurrentContext() else { return }
      
    context.textMatrix = .identity
    context.translateBy(x: 0, y: bounds.size.height)
    context.scaleBy(x: 1.0, y: -1.0)
      
    CTFrameDraw(ctFrame, context)
  }
}

这段代码呈现一个CTFrame,就像你在CTView中最初做的那样。自定义初始化器init(frame:ctframe:)设置:

视图的框架。
要绘制到上下文中的CTFrame。
视图的背景色为白色。
接下来,创建一个名为CTSettings.swift的新swift文件,它将保存您的列设置。

将CTSettings.swift中的内容替换为以下内容:

import UIKit
import Foundation

class CTSettings {
  //1
  // MARK: - Properties
  let margin: CGFloat = 20
  var columnsPerPage: CGFloat!
  var pageRect: CGRect!
  var columnRect: CGRect!
  
  // MARK: - Initializers
  init() {
    //2
    columnsPerPage = UIDevice.current.userInterfaceIdiom == .phone ? 1 : 2
    //3
    pageRect = UIScreen.main.bounds.insetBy(dx: margin, dy: margin)
    //4
    columnRect = CGRect(x: 0,
                        y: 0,
                        width: pageRect.width / columnsPerPage,
                        height: pageRect.height).insetBy(dx: margin, dy: margin)
  }
}
  1. 属性将决定页边距(本教程默认为20);每页列数;每页包含列的框架;以及每页每栏的帧大小。
  2. 由于该杂志同时面向iPhone和iPad,所以在iPad上显示两个栏目,在iPhone上显示一个栏目,所以栏目的数量适合不同的屏幕尺寸。
  3. 根据页边距的大小插入页面的整个边界,以计算pag直立。
    将pag直立的宽度除以每页的列数,并在新框架中插入columnRect的边距。
    打开,CTView.swift,替换整个内容如下:
import UIKit
import CoreText

class CTView: UIScrollView {

  //1
  func buildFrames(withAttrString attrString: NSAttributedString,
                   andImages images: [[String: Any]]) {
    //3
    isPagingEnabled = true
    //4
    let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
    //4
    var pageView = UIView()
    var textPos = 0
    var columnIndex: CGFloat = 0
    var pageIndex: CGFloat = 0
    let settings = CTSettings()
    //5
    while textPos < attrString.length {
    }
  }
}
  1. buildFrames(withAttrString:andImages:)将创建CTColumnViews,然后将它们添加到滚动视图。
  2. 启用滚动视图的分页行为;因此,每当用户停止滚动时,滚动视图就会立即进入位置,因此每次只显示一个完整的页面。
  3. 框架设置器将创建每个列的带属性文本的CTFrame。
  4. UIView页面视图将作为每个页面的列子视图的容器;textPos将跟踪下一个字符;columnIndex将跟踪当前列;pageIndex将跟踪当前页面;“设置”可以让你访问应用程序的页边距大小、每页的列数、页面框架和列框架设置。
  5. 你将循环遍历attrString并一列一列地布局文本,直到当前文本位置到达末尾。
    开始循环attrString。在while textPos < attrString.length {中添加.:
//1
if columnIndex.truncatingRemainder(dividingBy: settings.columnsPerPage) == 0 {
  columnIndex = 0
  pageView = UIView(frame: settings.pageRect.offsetBy(dx: pageIndex * bounds.width, dy: 0))
  addSubview(pageView)
  //2
  pageIndex += 1
}   
//3
let columnXOrigin = pageView.frame.size.width / settings.columnsPerPage
let columnOffset = columnIndex * columnXOrigin
let columnFrame = settings.columnRect.offsetBy(dx: columnOffset, dy: 0)

  1. 如果列索引除以每页的列数等于0,从而表明该列是其页面上的第一个列,则创建一个新的页面视图来保存这些列。要设置它的框架,取边距设置。pag竖立和偏移其x原点的当前页面索引乘以屏幕的宽度;因此,在分页滚动视图中,每个杂志页面都将在前一个页面的右侧。
  2. 增加pageIndex。
  3. 通过设置划分页面视图的宽度。columnsPerPage获取第一列的x原点;用这个原点乘以列索引得到列的偏移量;然后通过获取标准的columnRect并通过columnOffset偏移其x原点来创建当前列的框架。
    接下来,添加以下columnFrame初始化:
//1   
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: columnFrame.size))
let ctframe = CTFramesetterCreateFrame(framesetter, CFRangeMake(textPos, 0), path, nil)
//2
let column = CTColumnView(frame: columnFrame, ctframe: ctframe)
pageView.addSubview(column)
//3
let frameRange = CTFrameGetVisibleStringRange(ctframe)
textPos += frameRange.length
//4
columnIndex += 1
  1. 创建一个CGMutablePath列的大小,然后从textPos开始,呈现一个新的CTFrame与尽可能多的文本。
  2. 创建一个CTColumnView与CGRect columnFrame和CTFrame CTFrame然后添加列pageView。
  3. 使用ctframegetvisiblestringgrange(_:)计算列中包含的文本范围,然后将textPos递增该范围长度以反映当前文本位置。
  4. 在循环到下一列之前,将列索引递增1。
    最后,在循环之后设置滚动视图的内容大小:
contentSize = CGSize(width: CGFloat(pageIndex) * bounds.size.width,
                     height: bounds.size.height)

通过设置内容大小为屏幕宽度乘以页面数量,僵尸现在可以滚动到最后。

打开ViewController.swift,并替换
(view as? CTView)?.importAttrString(parser.attrString)


(view as? CTView)?.buildFrames(withAttrString: parser.attrString, andImages: parser.images)

在iPad上构建并运行应用程序。检查双列布局!左右拖动可以在页面之间切换。看起来似乎不错 😄

image

您有列和格式化的文本,但您缺少图像。用Core Text绘制图像并不是那么简单——毕竟它是一个文本框架——但是在你已经创建的标记解析器的帮助下,添加图像应该不会太糟糕。

在核心文本绘制图像

虽然Core Text不能绘制图像,但作为一个布局引擎,它可以为图像留出空间。通过设置CTRun的委托,可以确定CTRun的上升空间、下降空间和宽度。像这样:


image

当Core Text到达一个带有CTRunDelegate的CTRun时,它会问delegate,“我应该为这个数据块留下多少空间?”通过在CTRunDelegate中设置这些属性,您可以在您的图像的文本中留下空间。

首先添加对“img”标签的支持。打开MarkupParser.swift,找到"} //end of font parsing".。在后面立即添加以下内容:

//1
else if tag.hasPrefix("img") { 
      
  var filename:String = ""
  let imageRegex = try NSRegularExpression(pattern: "(?<=src=\")[^\"]+",
                                           options: NSRegularExpression.Options(rawValue: 0))
  imageRegex.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) {
        filename = String(tag[range])
    }
  }
  //2
  let settings = CTSettings()
  var width: CGFloat = settings.columnRect.width
  var height: CGFloat = 0

  if let image = UIImage(named: filename) {
    height = width * (image.size.height / image.size.width)
    // 3
    if height > settings.columnRect.height - font.lineHeight {
      height = settings.columnRect.height - font.lineHeight
      width = height * (image.size.width / image.size.height)
    }
  }
}
  1. 如果标签以"img"开头,使用regex搜索图像的"src"值,即文件名。
  2. 将图像宽度设置为列的宽度,并设置其高度,以便图像保持其高度-宽度宽高比。
  3. 如果图像的高度对于列来说太长,则设置高度以适应列,并减少宽度以保持图像的长宽比。由于图像后面的文本将包含空格属性,所以包含空格信息的文本必须适合于图像的同一列;因此将图像高度设置为settings.columnRect.height - font.lineHeight。
    接下来,在if let图像块之后添加如下内容:
//1
images += [["width": NSNumber(value: Float(width)),
            "height": NSNumber(value: Float(height)),
            "filename": filename,
            "location": NSNumber(value: attrString.length)]]
//2
struct RunStruct {
  let ascent: CGFloat
  let descent: CGFloat
  let width: CGFloat
}

let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
extentBuffer.initialize(to: RunStruct(ascent: height, descent: 0, width: width))
//3
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
})
//4
let delegate = CTRunDelegateCreate(&callbacks, extentBuffer)
//5
let attrDictionaryDelegate = [(kCTRunDelegateAttributeName as NSAttributedStringKey): (delegate as Any)]              
attrString.append(NSAttributedString(string: " ", attributes: attrDictionaryDelegate))
  1. 附加一个包含图像大小,文件名和文本位置的Dictonary到图像。
  2. 定义RunStruct来保存描述空白的属性。然后初始化一个指针,使其包含一个RunStruct,其上升高度等于图像高度,宽度属性等于图像宽度。
  3. 创建一个CTRunDelegateCallbacks,返回属于RunStruct类型指针的上升、下降和宽度属性。
  4. 使用ctrundelegateccreate创建一个绑定回调和数据参数的委托实例。
  5. 创建一个包含委托实例的带属性字典,然后向attrString追加一个空格,它保存文本中空洞的位置和大小信息。

现在MarkupParser处理“img”标签,你需要调整CTColumnView和CTView渲染他们。

CTColumnView.swift开放。添加以下var ctFrame: ctFrame !保存柱子的图像和框架:

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

接下来,将以下内容添加到draw(_:)的底部:

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

在这里,您可以遍历每个图像,并将其绘制到适当框架的上下文中。

接下来打开CTView.swift和下面的属性到类的顶部:

// MARK: - Properties
var imageIndex: Int!

imageIndex将在你绘制CTColumnViews时跟踪当前的图像索引。
接下来,添加以下内容到buildFrames(withAttrString:andImages:)的顶部:

imageIndex = 0

这标记了图像数组的第一个元素。

接下来添加下面的attachImagesWithFrame(_:ctframe:margin:columnView),在buildFrames(withAttrString:andImages:)下面:

func attachImagesWithFrame(_ images: [[String: Any]],
                           ctframe: CTFrame,
                           margin: CGFloat,
                           columnView: CTColumnView) {
  //1
  let lines = CTFrameGetLines(ctframe) as NSArray
  //2
  var origins = [CGPoint](repeating: .zero, count: lines.count)
  CTFrameGetLineOrigins(ctframe, CFRangeMake(0, 0), &origins)
  //3
  var nextImage = images[imageIndex]
  guard var imgLocation = nextImage["location"] as? Int else {
    return
  }
  //4
  for lineIndex in 0..<lines.count {
    let line = lines[lineIndex] as! CTLine
    //5
    if let glyphRuns = CTLineGetGlyphRuns(line) as? [CTRun], 
      let imageFilename = nextImage["filename"] as? String, 
      let img = UIImage(named: imageFilename)  { 
        for run in glyphRuns {

        }
    }
  }
}
  1. 获取CTFrame的CTLine对象的数组。
  2. 使用CTFrameGetOrigins将ctframe的行原点复制到origin数组中。通过设置一个长度为0的范围,CTFrameGetOrigins将知道要遍历整个CTFrame。
  3. 设置nextImage以包含当前图像的属性数据。如果nextImage包含图像的位置,展开它并继续;否则,提前返回。
  4. 循环文本行。
  5. 如果该行的字形运行,filename和image with filename都存在,则循环执行该行的字形运行。
    接下来,在符号run for-loop中添加以下内容:
// 1
let runRange = CTRunGetStringRange(run)    
if runRange.location > imgLocation || runRange.location + runRange.length <= imgLocation {
  continue
}
//2
var imgBounds: CGRect = .zero
var ascent: CGFloat = 0       
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil))
imgBounds.size.height = ascent
//3
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
imgBounds.origin.x = origins[lineIndex].x + xOffset 
imgBounds.origin.y = origins[lineIndex].y
//4
columnView.images += [(image: img, frame: imgBounds)]
//5
imageIndex! += 1
if imageIndex < images.count {
  nextImage = images[imageIndex]
  imgLocation = (nextImage["location"] as AnyObject).intValue
}
  1. 如果当前运行的范围不包含下一个图像,则跳过循环的其余部分。否则,在这里渲染图像。
  2. 使用CTRunGetTypographicBounds计算图像宽度,并将高度设置为找到的上升高度。
  3. 使用CTLineGetOffsetForStringIndex获取直线的x偏移量,然后将其添加到imgBounds的原点。
  4. 将图像及其帧添加到当前的CTColumnView中。
  5. 增加图像索引。如果有一个图像在images[imageIndex],更新nextImage和imgLocation,以便它们引用下一个图像。
image

好的!太棒了!差不多了,最后一步。

添加以下右边的pageView.addSubview(列)内的buildFrames(withAttrString:and imagages:)附加图像,如果他们存在:

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

看看iphone 和ipad的效果:


image

恭喜!为了感谢你们的辛勤工作,僵尸们放过了你们的大脑!:]

哪里可以看到效果?

在这里查看完成的项目

正如在介绍中提到的,Text Kit通常可以替代Core Text;所以试着用Text Kit编写这个教程,看看它是如何比较的。也就是说,这堂 Core Text课不会是徒劳的!TextKit提供了免费的桥接到CoreText,所以你有需要的话,可以很容易地进行框架之间的转换。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容