安全且优雅地使用 NSAttributedString - Part.2

在上一篇中,我们讨论了如何安全且优雅地定义一个属性字符串。不过在实际的运用场景中,情况往往会更复杂一些。

例如,当我们需要一个由很多属性不同的部分构成的完整字符串,或者是想要实现图文混排。这个时候,不可避免的需要将所有部分组装在一起,最基础的方法是创建 NSMutableAttributedString ,然后通过 append(_:) 方法来完成字符串的构建。简单易懂是它的有点,然而不知不觉中可能就会写出类似下面这样的代码:

1
2
3
4
5
let mutableAttributedString = NSMutableAttributedString(attributedString: str1)
mutableAttributedString.append(str2)
mutableAttributedString.append(str3)
mutableAttributedString.append(str4)
...

这段代码多少有一些繁重感,也不够直观,有很大的优化余地。条条大路通罗马,也许一千个开发者就有一千种改善的思路,接下来想要讨论两种更优雅的方案,分别借力于枚举 (Enum) 和 字符串插值 (StringInterpolation)。

枚举

和前文类似,我们完全可以再次定义一个枚举来优化代码的可读性。根据自己的需求,定义一些常用的 case,只定义字符串实在是有一些孤独,我们可以再追加一个用于插图的 case:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
enum AttributedString {
case string(String, [StringAttribute]?)
case image(UIImage, offsetX: CGFloat = 0.0, offsetY: CGFloat = 0.0, scale: CGFloat = 1.0)

static func assemble(_ attrStrings: [AttributedString]) -> NSAttributedString {
let mutableAttributedString = NSMutableAttributedString(string: "")
attrStrings.forEach { (attrString: AttributedString) in
switch attrString {
case .string(let string, let attributes):
mutableAttributedString.append(.init(string: string, attributes: attributes?.attributesDictionary))
case .image(let image, let offsetX, let offsetY, let scale):
mutableAttributedString.append(.init(image: image, offsetX: offsetX, offsetY: offsetY, scale: scale))
}
}
return mutableAttributedString
}
}

extension NSAttributedString {
convenience init(image: UIImage, offsetX: CGFloat, offsetY: CGFloat, scale: CGFloat = 1.0) {
let attachment = NSTextAttachment()

let size: CGSize = {
guard scale != 1.0 else { return image.size }
return image.size.applying(CGAffineTransform(scaleX: scale, y: scale))
}()

attachment.bounds = .init(origin: .init(x: offsetX, y: offsetY), size: size)
attachment.image = image

self.init(attachment: attachment)
}
}

这样一来,即简单明了,还可以避免很多晦涩且重复的代码。

1
2
3
4
5
xxxxxx.attributedText = AttributedString.assemble([
.image(UIImage(named: "stop") ?? UIImage(), offsetX: 0.0, offsetY: -8.0, scale: 0.5),
.string("踟蹰不如", [ .foregroundColor(.gray) ]),
.string("停止抱歉", [ .foregroundColor(.orange) ])
])

字符串插值 (StringInterpolation)

相较于枚举,借助字符串插值的方法不会那么直观。这是一个 Swift 5 的新特性,初见之时我其实并没有彻底感受到字符串插值的强大,私以为只是能够将一些字符串的共通处理写的更优雅,尽管这也足以令人欣喜。直到很久之后读了 ExpressibleByStringInterpolation,才真是理解了一些字符串插值为我们带来的便利。文中关于字符串插值初级使用所举的例子十分巧妙,很优雅的避开了 DateFormatter 格式定义的坑。但字符串插值的能力更多体现在自定义字符串插值类型中。

由于我们期待的插值类型是属性字符串,所以我们可以从定义结构体开始着手,首先为该结构体定义一个 NSAttributedString 类型的属性:

1
2
3
struct AttributedString {
var attributedString: NSAttributedString
}

此时需要了解两个协议:ExpressibleByStringLiteralExpressibleByStringInterpolation,后者继承于前者,二者分别有一个必须实现的初始化方法:

1
2
3
4
5
6
7
8
9
10
11
12
extension AttributedString: ExpressibleByStringLiteral {
init(stringLiteral: String) {
self.attributedString = NSAttributedString(string: stringLiteral)
}
}

// ExpressibleByStringInterpolation inherits from ExpressibleByStringLiteral
extension AttributedString: ExpressibleByStringInterpolation {
init(stringInterpolation: StringInterpolation) {
self.attributedString = NSAttributedString(attributedString: stringInterpolation.attributedString)
}
}

字面量部分只需要初始化方法即可,插值的情况下自然会复杂一些,从初始化方法接受的参数是一个关联类型就可以看出来。StringInterpolation 才真正在负责在处理插值。它的工作原理是将各个组成部分通过一系列 append 方法组装起来,再作为参数传给上面的初始化方法 init(stringInterpolation:)appendLiteral(_:) 的职责是处理普通的字符串字面量,名为 appendInterpolation 的方法们则是承担了处理类型五花八门的插值的任务。

在我们的例子中,就分别为字符串及其属性,图片定义了不同的 appendInterpolation 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
extension AttributedString: ExpressibleByStringInterpolation {
// Custom String Interpolation Type
struct StringInterpolation: StringInterpolationProtocol {
var attributedString: NSMutableAttributedString

init(literalCapacity: Int, interpolationCount: Int) {
self.attributedString = NSMutableAttributedString()
}

mutating func appendLiteral(_ literal: String) {
let attrString = NSAttributedString(string: literal)
self.attributedString.append(attrString)
}

mutating func appendInterpolation(_ string: String, stringAttributes: [StringAttribute]) {
let attrString = NSAttributedString(string: string,
stringAttributes: stringAttributes)
self.attributedString.append(attrString)
}

// for UIImage
mutating func appendInterpolation(image: UIImage, offsetX: CGFloat, offsetY: CGFloat, scale: CGFloat = 1.0) {
let attachment = NSTextAttachment()

let size: CGSize = {
guard scale != 1.0 else { return image.size }
return image.size.applying(CGAffineTransform(scaleX: scale, y: scale))
}()

attachment.bounds = .init(origin: .init(x: offsetX, y: offsetY), size: size)
attachment.image = image

self.attributedString.append(NSAttributedString(attachment: attachment))
}
}
}

关于 appendLiteral(_:) 方法,有一点不得不说,如果想要在其中做除了 append 之外的事,务必要万分小心,除了显然可见的字面量之外,空格,换行,甚至是字符串前后的不可见部分都会受到其影响。

严格意义上我们已经完成了用字符串插值的方法来组装属性字符串,实在是有些令人激动!不如趁热打铁,顺手添加两个便利方法,一定程度上能够再提升一点点灵活度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct AttributedString {
var attributedString: NSAttributedString

mutating func append(_ newStr: AttributedString) {
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
mutableAttributedString.append(newStr.attributedString)
self.attributedString = mutableAttributedString
}

mutating func append(contentsOf newStrs: [AttributedString]) {
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
newStrs.forEach { str in
mutableAttributedString.append(str.attributedString)
}
self.attributedString = mutableAttributedString
}
}

到这里,组装属性字符串的过程变得优雅且灵活了一些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var attrString1: AttributedString = """
\("踟蹰不如", stringAttributes: [ .foregroundColor(.systemGray), .font(.systemFont(ofSize: 14.0)) ])
\("停止抱歉", stringAttributes: [ .foregroundColor(.systemGray2), .font(.systemFont(ofSize: 14.0)) ])
"""

let attrString2: AttributedString = """
\(image: UIImage(named: "stop") ?? UIImage(), offsetX: 0.0, offsetY: 0.0, scale: 0.5)
\("再过秋天", stringAttributes: [ .foregroundColor(.systemGray3), .font(.systemFont(ofSize: 14.0)) ])
\("烂了蜿蜒", stringAttributes: [ .foregroundColor(.systemGray4), .font(.systemFont(ofSize: 14.0)) ])
"""

attrString1.append(attrString2)

xxxxxlabel.attributedText = attrString1.attributedString

尾声

我想很多人对属性字符串的感情应该是爱恨交织的,希望这两篇文章能为它拉回几张的票…😂

Source Code:

https://github.com/Ckitakishi/PlayWithAttributedString