6

SwiftUI: ​Text 中的插值

 3 years ago
source link: https://mp.weixin.qq.com/s/PX8bXSFXgJWMgHqien85jQ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

作者:王巍(onevcat),江湖人称 "喵神",他是 ObjC 中国组织的发起人和领导者,也是著名开源框架 Kingfisher 的作者。

Text 中的插值

Text 是 SwiftUI 中最简单和最常见的 View 了,最基本的用法,我们可以直接把一个字符串字面量传入,来创建一个 Text

Text("Hello World")
640?wx_fmt=jpeg

在 iOS 14 (SwiftUI 2.0) 中,Apple 对 Text 插值进行了很多强化。除了简单的文本之外,我们还可以向 Text 中直接插入 Image

Text("Hello \(Image(systemName: "globe"))")
640?wx_fmt=jpeg

这是一个非常强大的特性,极大简化了图文混排的代码。除了普通的字符串和 Image 以外,Text 中的字符串插值还可以接受其他一些“奇奇怪怪”的类型,部分类型甚至还接受传入特性的 formatter,这给我们带来不少便利:

Text("Date: \(Date(), style: .date)")
Text("Time: \(Date(), style: .time)")
Text("Meeting: \(DateInterval(start: Date(), end: Date(timeIntervalSinceNow: 3600)))")

let fomatter: NumberFormatter = {
    let f = NumberFormatter()
    f.numberStyle = .currency
    return f
}()
Text("Pay: \(123 as NSNumber, formatter: fomatter)")
640?wx_fmt=jpeg

但是同时,一些平时可能很常见的字符串插值用法,在 Text 中并不支持,最典型的,我们可能遇到下面两种关于 appendInterpolation 的错误:

Text("3 == 3 is \(true)")
// 编译错误:
// No exact matches in call to instance method 'appendInterpolation'

struct Person {
    let name: String
    let place: String
}
Text("Hi, \(Person(name: "Geralt", place: "Rivia"))")
// 编译错误:
// Instance method 'appendInterpolation' requires that 'Person' conform to '_FormatSpecifiable'

一开始遇到这些错误很可能会有点懵,appendInterpolation 是什么,_FormatSpecifiable 又是什么?要怎么才能让这些类型和 Text 一同工作?

幕后英雄:LocalizedStringKey

SwiftUI 把多语言本地化的支持放到了首位,在直接使用字符串字面量去初始化一个 Text 的时候,所调用到的方法其实是 init(_:tableName:bundle:comment:)

extension Text {
    init(
        _ key: LocalizedStringKey, 
        tableName: String? = nil, 
        bundle: Bundle? = nil, 
        comment: StaticString? = nil
    )
}

Text 使用输入的 key 去 bundle 中寻找本地化的字符串文件,并且把满足设备语言的结果渲染出来。

因为 LocalizedStringKey 满足 ExpressibleByStringInterpolation (以及其父协议 ExpressibleByStringLiteral),它可以直接由字符串的字面量转换而来。也就是说,在上面例子中,不论被插值的是 Image 还是 Date,最后得到的,作为 Text 初始化方法的输入的,其实都是 LocalizedStringKey 实例。

对于字符串字面量来说,Text 会使用上面这个 LocalizedStringKey 重载。如果先把字符串存储在一个 String 里,比如 let s = "hello",那么 Text(s) 将会选取另一个,接受 StringProtocol 的初始化方法:init<S>(_ content: S) where S : StringProtocol

Text 的另一个重要的初始化方法是 init(verbatim:)。如果你完全不需要本地化对应,那么使用这个方法将让你直接使用输入的字符串,从而完全跳过 LocalizedStringKey

我们可以证明一下这一点:当按照普通字符串插值的方法,尝试简单地打印上面的插值字符串时,得到的结果如下:

print("Hello \(Image(systemName: "globe"))")
// Hello Image(provider: SwiftUI.ImageProviderBox<SwiftUI.Image.(unknown context at $1b472d684).NamedImageProvider>)

print("Date: \(Date(), style: .date)")
// 编译错误:
// Cannot infer contextual base in reference to member 'date'

Image 插值直接使用了 struct 的标准描述,给回了一个普通字符串;而 Date 的插值则直接不接受额外参数,给出了编译错误。无论哪个,都不可能作为简单字符串传给 Text 并得到最后的渲染结果。

实际上,在 Text 初始化方法里,这类插值使用的是 LocalizedStringKey 的相关插值方法。这也是在 Swift 5 中新加入的特性,它可以让我们进行对任意类型的输入进行插值 (比如 Image),甚至在插值时设定一些参数 (比如 Date 以及它的 .date style 参数)。

StringInterpolation

普通的字符串插值是 Swift 刚出现时就拥有的特性了。可以使用 \(variable) 的方式,将一个可以表示为 String 的值加到字符串字面量里:

print("3 == 3 is \(true)")
// 3 == 3 is true

let luckyNumber = 7
print("My lucky number is \(luckNumber).")
// My lucky number is 7.

let name = "onevcat"
print("I am \(name).")
// I am onevcat.

在 Swift 5 中,字面量插值得到了强化。我们可以通过让一个类型遵守 ExpressibleByStringInterpolation 来自定义插值行为。这个特性其实已经被讨论过不少了,但是为了让你更快熟悉和回忆起来,我们还是再来看看它的基本用法。

Swift 标准库中的 String 是满足该协议的,想要扩展 String 所支持的插值的类型,我们可以扩展 String.StringInterpolation 类型的实现,为它添加所需要的适当类型。用上面出现过的 Person 作为例子。不加修改的话,print 会按照 Swift struct 的默认格式打印 Person 值:

struct Person {
    let name: String
    let place: String
}

print("Hi, \(Person(name: "Geralt", place: "Rivia"))")
// Hi, Person(name: "Geralt", place: "Rivia")

如果我们想要一个更 role play 一点的名字的话,可以考虑扩展 String.StringInterpolation,添加一个 appendInterpolation(_ person: Person) 方法,来自定义字符串字面量接收到 Person 时的行为:

extension String.StringInterpolation {
    mutating func appendInterpolation(_ person: Person) {
        // 调用的 `appendLiteral(_ literal: String)` 接受 `String` 参数
        appendLiteral("\(person.name) of \(person.place)")
    }
}

现在,String 中 Person 插值的情况会有所变化:

print("Hi, \(Person(name: "Geralt", place: "Rivia"))")
// Hi, Geralt of Rivia

对于多个参数的情况,我们可以在 String.StringInterpolation 添加新的参数,并在插值时用类似“方法调用”写法,将参数传递进去:

struct Person {
    let name: String
    let place: String

// 好朋友的话一般叫昵称就行了
    var nickName: String?
}

extension Person {
    var formalTitle: String { "\(name) of \(place)" }

// 根据朋友关系,返回称呼
    func title(isFriend: Bool) -> String {
        isFriend ? (nickName ?? formalTitle) : formalTitle
    }
}

extension String.StringInterpolation {
    mutating func appendInterpolation(_ person: Person, isFriend: Bool) {
        appendLiteral(person.title(isFriend: isFriend))
    }
}

调用时,加上 isFriend

let person = Person(
    name: "Geralt", place: "Rivia", nickName: "White Wolf"
)
print("Hi, \(person, isFriend: true)")
// Hi, White Wolf

LocalizedStringKey 的字符串插值

Image 和 Date

了解了 StringInterpolation 后,我们可以来看看在 Text 语境下的 LocalizedStringKey 是如何处理插值的了。和普通的 String 类似,LocalizedStringKey 也遵守了 ExpressibleByStringInterpolation,而且 SwiftUI 中已经为它的 StringInterpolation 提供了一些常用的扩展实现。在当前 (iOS 14) 的 SwiftUI 实现中,它们包含了:

extension LocalizedStringKey.StringInterpolation {
    mutating func appendInterpolation(_ string: String)
    mutating func appendInterpolation<Subject>(_ subject: Subject, formatter: Formatter? = nil) where Subject : ReferenceConvertible
    mutating func appendInterpolation<Subject>(_ subject: Subject, formatter: Formatter? = nil) where Subject : NSObject
    mutating func appendInterpolation<T>(_ value: T) where T : _FormatSpecifiable
    mutating func appendInterpolation<T>(_ value: T, specifier: String) where T : _FormatSpecifiable
    mutating func appendInterpolation(_ text: Text)
    mutating func appendInterpolation(_ image: Image)
    mutating func appendInterpolation(_ date: Date, style: Text.DateStyle)
    mutating func appendInterpolation(_ dates: ClosedRange<Date>)
    mutating func appendInterpolation(_ interval: DateInterval)
}

在本文第一部分的例子中,所涉及到的 Image 和 Date style 的插值,使用的正是上面所声明了的方法。在接受到正确的参数类型后,通过创建合适的 Text 进而得到最终的 LocalizedStringKey。我们很容易可以写出例子中的两个 appendInterpolation 的具体实现:

mutating func appendInterpolation(_ image: Image) {
    appendInterpolation(Text(image))
}

mutating func appendInterpolation(_ date: Date, style: Text.DateStyle) {
    appendInterpolation(Text(date, style: style))
}

Bool 和 Person

那么现在,我们就很容易理解为什么在最上面的例子中,Bool 和 Person 不能直接用在 Text 里的原因了。

对于 Bool

Text("3 == 3 is \(true)")
// 编译错误:
// No exact matches in call to instance method 'appendInterpolation'

LocalizedStringKey 没有针对 Bool 扩展 appendInterpolation 方法,于是没有办法使用插值的方式生成 LocalizedStringKey 实例。

对于 Person,最初的错误相对难以理解:

Text("Hi, \(Person(name: "Geralt", place: "Rivia"))")
// 编译错误:
// Instance method 'appendInterpolation' requires that 'Person' conform to '_FormatSpecifiable'

对照 SwiftUI 中已有的 appendInterpolation 实现,不难发现,其实它使用的是 :

mutating func appendInterpolation<T>(_ value: T) where T : _FormatSpecifiable

这个最接近的重载方法,不过由于 Person 并没有实现 _FormatSpecifiable 这个私有协议,所以实质上还是找不到合适的插值方法。想要修正这个错误,我们可以选择为 Person 添加 appendInterpolation,或者是让它满足 _FormatSpecifiable 这个私有协议。不过两种方式其实本质上是完全不同的,而且根据实际的使用场景不同,有时候可能会带来意想不到的结果。

我们已经看到为什么 Text,或者更准确地说,LocalizedStringKey,可以接受 Image 和 Date,而不能接受 Bool 或者自定义的 Person 类型了。接下来,让我们具体看看有哪些方法能让 Text 支持其他类型。

为 LocalizedStringKey 自定义插值

如果我们只是想让 Text 可以直接接受 true 或者 false,我们可以简单地为加上 appendInterpolation 的 Bool 重载。

extension LocalizedStringKey.StringInterpolation {
    mutating func appendInterpolation(_ value: Bool) {
        appendLiteral(value.description)
    }
}

这样的话,我们就能避免编译错误了:

Text("3 == 3 is \(true)")

对于 Person,我们可以同样地添加 appendInterpolation,来直接为 LocalizedStringKey 增加 Person 版本的插值方法:

extension LocalizedStringKey.StringInterpolation {
    mutating func appendInterpolation(_ person: Person, isFriend: Bool) {
        appendLiteral(person.title(isFriend: isFriend))
    }
}

上面的代码为 LocalizedStringKey.StringInterpolation 添加了 Bool 和 Person 的支持,但是这样的做法其实破坏了本地化的支持。这可能并不是你想要的效果,甚至造成预料之外的行为。在完全理解前,请谨慎使用。在本文稍后关于本地化的部分,会对这个话题进行更多讨论。

LocalizedStringKey 的真面目

通过 key 查找本地化值

我们花了大量篇幅,一直都在 LocalizedStringKey 和它的插值里转悠。回头想一想,我们似乎还完全没有关注过 LocalizedStringKey 本身到底是什么。正如其名,LocalizedStringKey 是 SwiftUI 用来在 Localization.strings 中查找 key 的类型。试着打印一下最简单的 LocalizedStringKey 值:

let key1: LocalizedStringKey = "Hello World"
print(key1)
// LocalizedStringKey(
//     key: "Hello World", 
//     hasFormatting: false,
//     arguments: []
// )

它会查找 "Hello World" key 对应的字符串。比如在本地化字符串文件中有这样的定义:

// Localization.strings
"Hello World"="你好,世界";

那是使用时,SwiftUI 将根据 LocalizedStringKey.key 的值选取结果:

Text("Hello World")
Text("Hello World")
    .environment(\.locale, Locale(identifier: "zh-Hans"))
640?wx_fmt=jpeg

插值 LocalizedStringKey 的 key

那么有意思的部分来了,下面这个 LocalizedStringKey 的 key 会是什么呢?

let name = "onevcat"
let key2: LocalizedStringKey = "I am \(name)"

是 "I am onevcat" 吗?如果是的话,那这个字符串要如何本地化?如果不是的话,那 key 会是什么?

打印一下看看就知道了:

print(key2)

// LocalizedStringKey(
//     key: "I am %@", 
//     hasFormatting: true, 
//     arguments: [
//         SwiftUI.LocalizedStringKey.FormatArgument(
//             ...storage: Storage.value("onevcat", nil)
//         )
//     ]
// )

key 并不是固定的 "I am onevcat",而是一个 String formatter:"I am %@"。熟悉 String format 的读者肯定对此不会陌生:name 被作为变量,会被传递到 String format 中,并替换掉 %@ 这个表示对象的占位符。所以,在本地化这个字符串的时候,我们需要指定的 key 是 "I am %@"。当然,这个 LocalizedStringKey 也可以对应其他任意的输入:

// Localization.strings
"I am %@"="我是%@";

// ContentView.swift
Text("I am \("onevcat")")
// 我是onevcat

Text("I am \("张三")")
// 我是张三

对于 Image 插值来说,情况很相似:Image 插值的部分会被转换为 %@,以满足本地化 key 的需求:

let key3: LocalizedStringKey = "Hello \(Image(systemName: "globe"))"

print(key3)
// LocalizedStringKey(
//     key: "Hello %@", 
//     ...
// )

// Localization.strings
// "Hello %@"="你好,%@";

Text("Hello \(Image(systemName: "globe"))")
Text("Hello \(Image(systemName: "globe"))")
    .environment(\.locale, Locale(identifier: "zh-Hans"))
640?wx_fmt=jpeg

值得注意的一点是,Image 的插值对应的格式化符号是 %@,这和 String 的插值或者其他一切对象插值所对应的符号是一致的。也就是说,下面的两种插值方式所找到的本地化字符串是相同的:

Text("Hello \("onevcat")")
    .environment(\.locale, Locale(identifier: "zh-Hans"))
Text("Hello \(Image(systemName: "globe"))")
    .environment(\.locale, Locale(identifier: "zh-Hans"))
640?wx_fmt=jpeg

其他类型的插值格式化

可能你已经猜到了,除了 %@ 外,LocalizedStringKey 还支持其他类型的格式化,比如在插值 Int 时,会把 key 中的参数转换为 %lld;对 Double 则转换为 %lf 等:

let key4: LocalizedStringKey = "Hello \(1))"
// LocalizedStringKey(key: "Hello %lld)

let key5: LocalizedStringKey = "Hello \(1.0))"
// LocalizedStringKey(key: "Hello %lf)

使用 Hello %lld 或者 Hello %lf,是不能在本地化文件中匹配到之前的 Hello %@ 的。

更合理的 appendInterpolation 实现

避免 appendLiteral

现在让我们回到 Bool 和 Person 的插值这个话题。在本篇一开始,我们添加了两个插值方法,来让 LocalizedStringKey 接受 Bool 和 Person 的插值:

mutating func appendInterpolation(_ value: Bool) {
    appendLiteral(value.description)
}

mutating func appendInterpolation(_ person: Person, isFriend: Bool) {
    appendLiteral(person.title(isFriend: isFriend))
}

在两个方法中,我们都使用了 appendLiteral 来将 String 直接添加到 key 里,这样做我们得到的会是一个完整的,不含参数的 LocalizedStringKey,在大多数情况下,这不会是我们想要的结果:

let key6: LocalizedStringKey = "3 == 3 is \(true)"
// LocalizedStringKey(key: "3 == 3 is true", ...)

let person = Person(name: "Geralt", place: "Rivia", nickName: "White Wolf")
let key7: LocalizedStringKey = "Hi, \(person, isFriend: false)"
// LocalizedStringKey(key: "Hi, Geralt of Rivia", ...)

在实现新的 appendInterpolation 时,尊重插入的参数,将实际的插入动作转发给已有的 appendInterpolation 实现,让 LocalizedStringKey 类型去处理 key 的合成及格式化字符,应该是更合理和具有一般性的做法:

mutating func appendInterpolation(_ value: Bool) {
    appendInterpolation(value.description)
}

mutating func appendInterpolation(_ person: Person, isFriend: Bool) {
    appendInterpolation(person.title(isFriend: isFriend))
}

let key6: LocalizedStringKey = "3 == 3 is \(true)"
// LocalizedStringKey(key: "3 == 3 is %@", ...)

let key7: LocalizedStringKey = "Hi, \(person, isFriend: false)"
// LocalizedStringKey(key: "Hi, %@", ...)

为 Text 添加样式

结合利用 LocalizedStringKey 参数插值和已有的 appendInterpolation,可以写出一些简便方法。比如可以添加一组字符串格式化的方法,来让 Text 的样式设置更简单一些:

extension LocalizedStringKey.StringInterpolation {
    mutating func appendInterpolation(bold value: LocalizedStringKey){
        appendInterpolation(Text(value).bold())
    }

mutating func appendInterpolation(underline value: LocalizedStringKey){
        appendInterpolation(Text(value).underline())
    }

mutating func appendInterpolation(italic value: LocalizedStringKey) {
        appendInterpolation(Text(value).italic())
    }

mutating func appendInterpolation(_ value: LocalizedStringKey, color: Color?) {
        appendInterpolation(Text(value).foregroundColor(color))
    }
}
Text("A \(bold: "wonderful") serenity \(italic: "has taken") \("possession", color: .red) of my \(underline: "entire soul").")

可以得到如下的效果:

640?wx_fmt=jpeg

对应的 key 是 "A %@ serenity %@ %@ of my %@."。插值的地方都会被认为是需要参数的占位符。在一些情况下可能这不是你想要的结果,不过 attributed string 的本地化在 UIKit 中也是很恼人的存在。相对于 UIKit 来说,SwiftUI 在这方面的进步还是显而易见的。

关于 _FormatSpecifiable

最后我们来看看关于 _FormatSpecifiable 的问题。可能你已经注意到了,在内建的 LocalizedStringKey.StringInterpolation 有两个方法涉及到了 _FormatSpecifiable

mutating func appendInterpolation<T>(_ value: T) where T : _FormatSpecifiable
mutating func appendInterpolation<T>(_ value: T, specifier: String) where T : _FormatSpecifiable

指定占位格式

Swift 中的部分基本类型,是满足 _FormatSpecifiable 这个私有协议的。该协议帮助 LocalizedStringKey 在拼接 key 时选取合适的占位符表示,比如对 Int 选取 %lld,对 Double 选取 %lf 等。当我们使用 Int 或 Double 做插值时,上面的重载方法将被使用:

Text("1.5 + 1.5 = \(1.5 + 1.5)")

// let key: LocalizedStringKey = "1.5 + 1.5 = \(1.5 + 1.5)"
// print(key)
// 1.5 + 1.5 = %lf

上面的 Text 等号右边将按照 %lf 渲染:

640?wx_fmt=jpeg

如果只想要保留到小数点后一位,可以直接用带有 specifier 参数的版本。在生成 key 时,会用传入的 specifier 取代原本应该使用的格式:

Text("1.5 + 1.5 = \(1.5 + 1.5, specifier: "%.1lf")")

// key: 1.5 + 1.5 = %.1lf
640?wx_fmt=jpeg

为自定义类型实现 _FormatSpecifiable

虽然是私有协议,但是 _FormatSpecifiable 相对还是比较简单的:

protocol _FormatSpecifiable: Equatable {
    associatedtype _Arg
    var _arg: _Arg { get }
    var _specifier: String { get }
}

让 _arg 返回需要被插值的实际值,让 _specifier 返回占位符的格式,就可以了。比如可以猜测 Int: _FormatSpecifiable 的实现是:

extension Int: _FormatSpecifiable {
    var _arg: Int { self }
    var _specifier: String { "%lld" }
}

对于我们在例子中多次用到的 Person,也可以用类似地手法让它满足 _FormatSpecifiable

extension Person: _FormatSpecifiable {
    var _arg: String { "\(name) of \(place)" }
    var _specifier: String { "%@" }
}

这样一来,即使我们不去为 LocalizedStringKey 添加 Person 插值的方法,编译器也会为我们选择 _FormatSpecifiable 的插值方式,将 Person 的描述添加到最终的 key 中了。

  • SwiftUI 2.0 中可以向 Text 中插值 Image 和 Date 这样的非 String 值,这让图文混排或者格式化文字非常方便。

  • 灵活的插值得益于 Swift 5.0 引入的 ExpressibleByStringInterpolation。你可以为 String 自定义插值方式,甚至可以为自定义的任意类型设定字符串插值。

  • 用字符串字面量初始化 Text 的时候,参数的类型是 LocalizedStringKey

  • LocalizedStringKey 实现了接受 Image 或者 Date 的插值方法,所以我们可以在创建 Text 时直接插入 Image 或者格式化的 Date

  • LocalizedStringKey 不接受 Bool 或者自定义类型的插值参数。我们可以添加相关方法,不过这会带来副作用。

  • 我们尝试扩展了 LocalizedStringKey 插值的方法,让它支持了 Bool 和 Person

  • LocalizedStringKey 插值的主要任务是自动生成合适的,带有参数的本地化 key。

  • 在扩展 LocalizedStringKey 插值时,应该是尽可能使用 appendInterpolation,避免参数“被吞”。

  • 插值的格式是由 _FormatSpecifiable 确定的。我们也可以通过让自定义类型实现这个协议的方式,来进行插值。

至此,为什么 Text 中可以插值 Image,以及它背后发生的所有事情,我们应该都弄清楚了。

我们是「老司机技术周报」,每周会发布一份关于 iOS 的周报,也会定期分享一些和 iOS 相关的技术。欢迎关注。

老司机技术周报
老司机技术周报
每周定期整理与发布业界资讯、开发工具、开源代码、学习资料、求职招聘信息。
202篇原创内容
Official Account

关注有礼,关注【老司机技术周报】,回复「2020」,领取学习大礼包。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK