如何在Swift中运用Text Kit框架(二)

动态的文本格式和存储

在上篇文章中,同学们已经看到了Text Kit可以根据用户在设置选项里对文本属性设置而动态的改变App中的文本属性。但你们不知道它还有更酷的功能,那就是可以根据文本本身的某种字符而更改文本属性。

比如说,你们也许希望我们这个应用中还有以下这些自动的功能:

  • 被波浪号(~)括起来的文本会显示艺术字体。
  • 被下划线(_)括起来的文本会显示斜体。
  • 被破折号(-)括起来的文本会在其上划一道横线。
  • 所有大写字体会显示红色。

pic

如何用TextKit实现上述这些功能就是这篇文章将要向同学们介绍的内容。

在学习这些之前,大家需要先理解在TextKit中,文本的存储系统是如何工作的。下面的图展示了TextKit中对文本进行存储、渲染、显现的基本流程:

pic

在开发过程中,如果你创建了UITextViewUILabelUITextField,那么同时Apple会在幕后自动创建上图中的这些类。你可以使用这些类默认实现的行为,也可以自己实现这些类,从而达到你想要的一些对文本控制的行为。让我们来看看这些类的作用:

  • NSTextStorage的功能是存储文本,并通过字符串属性渲染文本。当文本内容发生任何变化的时候,它会告知布局管理器(Layout Manager)。你们也许脑海中已经有了一点想法,那就是实现一个NSTextStorage的子类,以便实现当文本更新时动态改变文本属性的功能,Bingo!想法基本正确(在后文中你将会看到它的运用)。
  • NSLayoutManager的功能是从NSTextStorage中获取文本并在屏幕上渲染文本。它将作为你应用中的布局引擎。
  • NSTextContainer的功能是在屏幕上描绘一个几何图形区域,要显示的文本就是在这个区域内进行渲染的。每个Text Container一般都和一和UITextView相关联。你可以实现一个NSTextContainer的子类,描绘一个复杂的几何图形,让文本在其中进行渲染。

如果想要在我们的应用中实现动态文本格式的功能,那么就需要实现一个NSTextStorage的子类,然后根据用户键入的字符动态的设置文本格式属性。

一旦你实现了NSTextStorage的子类,那么你就要替换UITextView默认的存储类示例,同学们接着往下看。

UITextStorage的子类

我们选中左侧项目结构目录中的SwiftTextKitNotepad,然后选择菜单栏中的New File… ,接着选择iOS/Source/Cocoa Touch Class ,点击Next

我们给该类起名为SyntaxHighlightTextStorage,让它继承NSTextStorage,确认Language选项为 Swift,然后点击 Next ,最后点击Create

打开SyntaxHighlightTextStorage.swift文件,申明一个新的属性:


let backingStore = NSMutableAttributedString()

文本存储的子类必须要保证自己的持久性,因此使用NSMutableAttributedString作为backingStore的类型。后面我们会经常用到。

然后在该类中添加如下代码:


override var string: String {
return backingStore.string
}

override func attributesAtIndex(index: Int, effectiveRange range: NSRangePointer) -> [NSObject : AnyObject] {
return backingStore.attributesAtIndex(index, effectiveRange: range)
}

首先我们重写了计算属性stringget方法,让它返回咱们定义的backingStore中字符串的值,同样重写了attributesAtIndex方法,并返回backingStoreattributesAtIndex方法的返回值。

最后重写这些必须要重写的方法:


override func replaceCharactersInRange(range: NSRange, withString str: String) {
println("replaceCharactersInRange:\(range) withString:\(str)")

beginEditing()
backingStore.replaceCharactersInRange(range, withString:str)
edited(.EditedCharacters | .EditedAttributes, range: range, changeInLength: (str as NSString).length - range.length)
endEditing()
}

override func setAttributes(attrs: [NSObject : AnyObject]!, range: NSRange) {
println("setAttributes:\(attrs) range:\(range)")

beginEditing()
backingStore.setAttributes(attrs, range: range)
edited(.EditedAttributes, range: range, changeInLength: 0)
endEditing()
}

和上面一样,这些方法的实现都使用我们自己定义的backingStore属性的相同方法代替。然而,从上面的示例代码中可以看到使用backingStore的相关方法时,它是被包围在beginEditingeditedendEditing三个方法中的。这三个方法是必须要写的,它们的作用是当文本准备被编辑时通知与之相关联的布局管理器(Layout Manager)。

通过上面的示例代码你们可能已经注意到了要实现一个NSTextStorage的子类要重写这么多的方法(写这么多的代码)。因为NSTextStorage是一个类集群的公共接口,所以在实现它的子类时只重写一两个方法是没法真正扩展其功能的,上面这几个主要的方法是我们必须要重写的。

注意:类集群是Apple框架中经常使用的一种设计模式。类集群是Objective-C对抽象工厂设计模式的一种简单的实现,它的作用就是将相似的类聚集到一起,通过一个工厂类暴露给开发者,该工厂类提供若干工厂方法,每个或每几个工厂方法对应其包含的某个类。我们比较熟悉的像NSArrayNSNumber都是类集群。

Apple使用类集群将私有的具体的子类压缩到一个公共的抽象超类中,该超类会申明一些方法,用于创建其包含子类的实例。开发者不必关心公共抽象类内部的事情,也无法得知,因为开发者永远只是和这个抽象类打交道。

使用类集群确实可以简化接口,使类的使用和学习更加简单,但是有一点很重要,那就是在使用类集群时要权衡可扩展性和易用性。比如说如果使用类集群,那么你就很难创建一个个性化的子类。

现在,我们已经有了一个自定义的NSTextStorage,接下来就需要用UITextView来使用它了。

让UITextView使用我们自定义的Text Kit Stack

如果我们在Storyboard中创建一个UITextView,那么就同时会自动创建NSTextStorageNSLayoutManagerNSTextContainer这三个类的实例(也就是Text Kit Stack),并且这三个实例是只读状态的。也就意味着在Storyboard中,我们没法让UITextView使用我们自己定义的Text Kit Stack。真的没招了吗?非也,我们可以在代码中创建UITextView,然后让它使用自定义的Text Kit Stack。

打开Main.storyboard,找到NoteEditorViewController,展开它,在Detail Scene/Detail/View中找到 Text View,然后删掉UITextView实例。

然后打开NoteEditorViewController.swift文件,移除UITextViewoutlet。添加如下代码重新申明UITextViewSyntaxHighlightTextStorage


var textView: UITextView!
var textStorage: SyntaxHighlightTextStorage!

接下来移除viewDidLoad方法中的这两行代码:


textView.text = note.contents
textView.font = UIFont.preferredFontForTextStyle(UIFontTextStyleBody)

因为我们已经移除了UITextViewoutlet,并且手动申明了新的UITextView,所以这两行就不需要了,因为我们先要对新的UITextView进行初始化和相关设置。

继续在NoteEditorViewController.swift中添加如下代码:


func createTextView() {
// 1. 初始化用于备份编辑器中文本的存储器
let attrs = [NSFontAttributeName : UIFont.preferredFontForTextStyle(UIFontTextStyleBody)]
let attrString = NSAttributedString(string: note.contents, attributes: attrs)
textStorage = SyntaxHighlightTextStorage()
textStorage.appendAttributedString(attrString)

let newTextViewRect = view.bounds

// 2. 创建layoutManager
let layoutManager = NSLayoutManager()

// 3. 创建text container
let containerSize = CGSize(width: newTextViewRect.width, height: CGFloat.max)
let container = NSTextContainer(size: containerSize)
container.widthTracksTextView = true
layoutManager.addTextContainer(container)
textStorage.addLayoutManager(layoutManager)

// 4. 初始化并设置UITextView
textView = UITextView(frame: newTextViewRect, textContainer: container)
textView.delegate = self
view.addSubview(textView)
}

这次加的代码有点多,不过没关系,我们一一来分析:

  1. 初始化我们之前自定义的文本存储类,并定义了文本的属性。
  2. 创建了一个布局管理器。
  3. 创建了一个文本容器,并添加到上步创建的布局管理器中,然后又将布局管理器添加到我们自定义的文本存储器中。
  4. 初始化文本视图,其frame为父视图的大小,textContainer为上步创建的容器。然后设置了文本视图的代理,并将其添加到父视图中。

到目前为止,之前向大家展示过的这张图中的四个关键点(存储,布局器,容器,文本视图)之间的关系应该会更容易了理解了:

pic

这里要注意的是,在上面添加的代码中文本容器的宽度为屏幕的宽度,但是高度是无限大的,这样设置的目的是为了当UITextView中的文本很长的时候可以上下滚动。

现在我们就可以在viewDidLoad方法中调用createTextView()方法了,不过要记得加在super.viewDidLoad()方法的后面:


super.viewDidLoad()
createTextView()

最后还有一点需要注意的是:在代码中创建的自定义的视图不能沿用storyboard中添加的布局约束,这就意味着当改变设备的横竖屏方向时,代码中创建的视图是不能根据布局约束改变大小和位置的。你需要在代码中添加布局约束。

此时我们还需要在viewDidLayoutSubviews方法中加一行代码:


textView.frame = view.bounds

编译并运行应用,在应用中打开一条笔记,然后去编辑它,此时注意观察Xcode的控制台,你可以看到在控制台中像瀑布一般的刷出好多日志信息:

pic

产生这些日志信息的来源是SyntaxHighlightTextStorage类,该类中输出的日志信息可以让你们知道你们自定义的类是否在正常的工作。

现在文本解析器的基本框架看似已经可以稳定的运行了,接下来实现动态文本格式吧!

动态文本格式

在接下来的步骤中,你们要对自定义的文本存储类进行修改,让它实现被星号包围起来的文本加粗的功能。

打开SyntaxHighlightTextStorage.swift文件,添加如下代码:


func applyStylesToRange(searchRange: NSRange) {
// 1. 创建一些字体
let fontDescriptor = UIFontDescriptor.preferredFontDescriptorWithTextStyle(UIFontTextStyleBody)
let boldFontDescriptor = fontDescriptor.fontDescriptorWithSymbolicTraits(.TraitBold)
let boldFont = UIFont(descriptor: boldFontDescriptor, size: 0)
let normalFont = UIFont.preferredFontForTextStyle(UIFontTextStyleBody)

// 2. 匹配由星号包围的文本
let regexStr = "(\\*\\w+(\\s\\w+)*\\*)"
let regex = NSRegularExpression(pattern: regexStr, options: nil, error: nil)
let boldAttributes = [NSFontAttributeName : boldFont]
let normalAttributes = [NSFontAttributeName : normalFont]

// 3. 遍历所有匹配上的文本,设置加粗字体属性
regex.enumerateMatchesInString(backingStore.string, options: nil, range: searchRange) {
match, flags, stop in
let matchRange = match.rangeAtIndex(1)
self.addAttributes(boldAttributes, range: matchRange)

// 4. 还原字体格式
let maxRange = matchRange.location + matchRange.length
if maxRange + 1 < self.length {
self.addAttributes(normalAttributes, range: NSMakeRange(maxRange, 1))
}
}
}

上面的代码主要功能是:

  1. 通过UIFontDescriptor创建一个加粗的字体格式和一个正常的字体格式。UIFontDescriptor可帮助你们避免使用硬编码来设置字体的类型和格式。
  2. 创建一个正则表达式,用于查找被星号包围的文本。比如说,这有一个字符串”iOS8是*非常完美*的一个系统”,那么正则表达式就可以将”*非常完美*“过滤出来。如果你不熟悉正则表达式也没关系,在后面会详细给大家介绍的。
  3. 遍历通过正则表达式过滤出的文本,将我们定义的加粗字体属性设置给它们。
  4. 重置跟在最后一个星号后的文本的属性为正常,确保只在闭合星号内的文本才显示为粗体。

在上面的方法后面添加如下方法:


func performReplacementsForRange(changedRange: NSRange) {
var extendedRange = NSUnionRange(changedRange, NSString(string: backingStore.string).lineRangeForRange(NSMakeRange(changedRange.location, 0)))
extendedRange = NSUnionRange(changedRange, NSString(string: backingStore.string).lineRangeForRange(NSMakeRange(NSMaxRange(changedRange), 0)))
applyStylesToRange(extendedRange)
}

上面这个方法的作用是设置检查被星号包围文本的范围。这个方法是很重要的,因为changedRange通常代表一个字符,而lineRangeForRange将其扩展到一行的范围。

最后在performReplacementsForRange()方法后再添加一个方法:


override func processEditing() {
performReplacementsForRange(self.editedRange)
super.processEditing()
}

当文本发生改变时,processEditing方法会向布局管理器发送通知,告知布局管理器文本发生了变化。

编译运行应用,打开一条笔记,输入一些文本信息,然后用星号包围几段文本,你会看到被星号包围的文本变成了粗体:

pic

是不是很方便呢,你还可以依法炮制,举一反三,比如两个下划线包围的文本显示为斜体等等。

深入完善

实现该功能的基本方法其实很简单:在applyStylesToRange方法中通过正则表达式过滤出你想要的文本信息,然后给过滤出的文本设置新的文本属性。

打开SyntaxHighlightTextStorage.swift文件,添加如下方法:


func createAttributesForFontStyle(style: String, withTrait trait: UIFontDescriptorSymbolicTraits) -> [NSObject : AnyObject] {
let fontDescriptor = UIFontDescriptor.preferredFontDescriptorWithTextStyle(UIFontTextStyleBody)
let descriptorWithTrait = fontDescriptor.fontDescriptorWithSymbolicTraits(trait)
let font = UIFont(descriptor: descriptorWithTrait, size: 0)
return [NSFontAttributeName : font]
}

该方法可以创建正文文本字体的样式。在该方法中,构建UIFont时,其构造函数中的size属性设置为0,这是为了让字体的大小使用用户当前设置的字体大小。

接下来,在该类中添加一个属性和一个方法:


var replacements: [String : [NSObject : AnyObject]]!

func createHighlightPatterns() {
let scriptFontDescriptor = UIFontDescriptor(fontAttributes: [UIFontDescriptorFamilyAttribute : "Zapfino"])

// 1. 让我们的手写字体的大小基于首选设置中正文字体的大小
let bodyFontDescriptor = UIFontDescriptor.preferredFontDescriptorWithTextStyle(UIFontTextStyleBody)
let bodyFontSize = bodyFontDescriptor.fontAttributes()[UIFontDescriptorSizeAttribute] as NSNumber
let scriptFont = UIFont(descriptor: scriptFontDescriptor, size: CGFloat(bodyFontSize.floatValue))

// 2. 创建一些字体属性
let boldAttributes = createAttributesForFontStyle(UIFontTextStyleBody, withTrait:.TraitBold)
let italicAttributes = createAttributesForFontStyle(UIFontTextStyleBody, withTrait:.TraitItalic)
let strikeThroughAttributes = [NSStrikethroughStyleAttributeName : 1]
let scriptAttributes = [NSFontAttributeName : scriptFont]
let redTextAttributes = [NSForegroundColorAttributeName : UIColor.redColor()]

// 创建一个正则表达式的字典
replacements = [
"(\\*\\w+(\\s\\w+)*\\*)" : boldAttributes,
"(_\\w+(\\s\\w+)*_)" : italicAttributes,
"([0-9]+\\.)\\s" : boldAttributes,
"(-\\w+(\\s\\w+)*-)" : strikeThroughAttributes,
"(~\\w+(\\s\\w+)*~)" : scriptAttributes,
"\\s([A-Z]{2,})\\s" : redTextAttributes
]
}

这个方法主要有以下几个功能:

  • 首先,使用Zapfino字体创建一个手写风格的字体。然后让它的字体大小基于正文文本设置的字体大小。
  • 其次,创建若干用于匹配的字体属性。
  • 最后,创建了一个字典,key为各种正则表达式,值为刚才创建的字体属性。

如果你们不熟悉正则表达式,可能这个字典看起来会比较奇怪。但是如果你们一点一点去解析它,就会发现其实很简单。

我们拿出字典中的第一个正则表达式,也就是过滤被星号围绕的文本的表达式来看看:

(\\*\\w+(\\s\\w+)*\\*)

在正则表达式中一个反斜杠“\”代表将下一个字符标记为特殊字符或一个原义字符,双反斜杠的作用是不会使程序解析时忽略“\”,如果你把额外的一个反斜杠去掉,那么该正则表达式应该是这样:

(\*\w+(\s\w+)*\*)

现在我们来一步一步解析这个正则表达式:

  1. (* - 匹配一个星号。
  2. \w+ - 匹配包括下划线在内任何单词字符以及该字符之前的字符。
  3. (\s\w+)* - 这是一个字表达式,\s的含义是匹配任何空白字符,包括空格、制表符、换页符等,*的含义是匹配前面的子表达式0次或多次。
  4. *) - 匹配后面的星号。

注意:如果你想了解更多关于正则表达式的知识,请查阅这篇文章NSRegularExpression tutorial and cheat sheet

接下来我们在SyntaxHighlightTextStorage类的初始化方法中调用createHighlightPatterns()方法:


override init() {
super.init()
createHighlightPatterns()
}

required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}

最后,我们来修改applyStylesToRange()方法:


func applyStylesToRange(searchRange: NSRange) {
let normalAttrs = [NSFontAttributeName : UIFont.preferredFontForTextStyle(UIFontTextStyleBody)]

// 遍历每个需要替换字体属性的文本
for (pattern, attributes) in replacements {
let regex = NSRegularExpression(pattern: pattern, options: nil, error: nil)
regex.enumerateMatchesInString(backingStore.string, options: nil, range: searchRange) {
match, flags, stop in
// 设置字体属性
let matchRange = match.rangeAtIndex(1)
self.addAttributes(attributes, range: matchRange)

// 还原字体样式
let maxRange = matchRange.location + matchRange.length
if maxRange + 1 < self.length {
self.addAttributes(normalAttrs, range: NSMakeRange(maxRange, 1))
}
}
}
}

在之前,该方法只使用一个正则表达式过滤出被星号围绕的文本,并将其设置为粗体,现在,虽然该方法所做的事情没有变,但是却不只使用一个正则表达式,而是遍历正则表达式字典中的所有表达式,过滤出文本中所有被符号围绕的文本,然后设置相应的字体样式。

现在再次编译运行应用,你看到了什么?

pic

到目前为止,似乎这个记事本应用已经大功告成了,但是还有几个小问题需要解决。

第一个问题是如果在使用该应用时,改变手机的方向,也就是从竖屏变为横屏,你会注意到屏幕上的内容并没有变化,这是因为自定义的子类不支持这种转变,之前我们也提到过。

第二个问题是当一条笔记中有很多文本信息的时候,文本的底部会被键盘遮住,这时你打字写东西就很困难。

也是时候解决这两个问题了。

回顾动态类型

要想通过动态类型正确的解决该问题,你们得修改代码,当文本显示区域大小发生改变的通知发生时,要通过字体属性来改变字体。

SyntaxHighlightTextStorage类中添加如下方法:


func update() {
// update the highlight patterns
createHighlightPatterns()

// change the 'global' font
let bodyFont = [NSFontAttributeName : UIFont.preferredFontForTextStyle(UIFontTextStyleBody)]
addAttributes(bodyFont, range: NSMakeRange(0, length))

// re-apply the regex matches
applyStylesToRange(NSMakeRange(0, length))
}

上面的方法更新了所有与各种正则表达式相关联的字体,让所有字符串都基于正文文本的属性,然后重新过滤文本。

最后,打开NoteEditorViewController.swift文件,更新preferredContentSizeChanged()方法:


func preferredContentSizeChanged(notification: NSNotification) {
textStorage.update()
updateTimeIndicatorFrame()
}

编译运行应用,在设置中修改文本大小,看看会发生什么:

pic

改变文本视图的大小

剩下的另一个问题就是编辑长文本时,键盘会遮住一半文本的问题。

解决这个问题的核心就是当键盘弹出时减小文本视图的高度。

打开NoteEditorViewController.swift文件,在viewDidLoad()方法中的createTextView()后面加一行代码:


textView.scrollEnabled = true

这行代码允许文本视图开启滚动条。

然后在viewDidLoad()的最后面添加如下代码:


NSNotificationCenter.defaultCenter().addObserver(self, selector: "keyboardDidShow:", name: UIKeyboardDidShowNotification, object: nil)
NSNotificationCenter.defaultCenter().addObserver(self, selector: "keyboardDidHide:", name: UIKeyboardDidHideNotification, object: nil)

这两个通知可以让你捕捉到键盘弹出和收起的事件,好让你修改文本视图的相应大小。

再在该类中添加如下方法:


func updateTextViewSizeForKeyboardHeight(keyboardHeight: CGFloat) {
textView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height - keyboardHeight)
}

该方法让文本视图的高度减去键盘的高度,保证合适的文本视图高度。

最后就是实现keyboardDidShowkeyboardDidHide方法了:


func keyboardDidShow(notification: NSNotification) {
if let rectValue = notification.userInfo?[UIKeyboardFrameBeginUserInfoKey] as? NSValue {
let keyboardSize = rectValue.CGRectValue().size
updateTextViewSizeForKeyboardHeight(keyboardSize.height)
}
}

func keyboardDidHide(notification: NSNotification) {
updateTextViewSizeForKeyboardHeight(0)
}

当键盘弹出时,你需要从通知中获取到键盘的高度,然后让文本视图减去这个高度,当键盘收回时,要复原文本视图的高度。

注意:在iOS8之前的版本中,当设备的方向发生变化时,也就是从竖屏变为横屏时,我们要计算新的文本视图的大小(因为UIView的高和宽会交换,但是键盘的高和宽不会交换),但是在iOS8中已经没这个必要了。

现在编译运行应用,选择一条长文本的日记,编辑文本,此时弹出的键盘就不会再遮挡文本信息了:

pic

原文链接:Text Kit Tutorial in Swift

分享到: