在过去的几周里,经常有人问我在SwiftUI如何使用带属性字符串,所以我决定写一篇文章来分享我对这个主题的了解。
在我们开始之前,让我们把它放在这里:SwiftUI不准备轻松处理带属性字符串。有了这些,让我们来看看填补这一空白的最佳方法,以及在这一过程中我们会发现的限制或问题。
Attributed Strings
在本文中,我假设您知道什么是NSAttributedString以及如何创建NSAttributedString。它们已经存在很长时间了。 如果你需要知道如何建立一个,互联网有丰富的资源。谷歌是你的朋友。
为了简化本文中的示例,它们都将使用相同的带属性字符串,定义为全局变量,并使用以下代码初始化:
let myAttributedString: NSMutableAttributedString = {
let a1: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.systemRed, .kern: 5]
let s1 = NSMutableAttributedString(string: "Red spaced ", attributes: a1)
let a2: [NSAttributedString.Key: Any] = [.strikethroughStyle: 1, .strikethroughColor: UIColor.systemBlue]
let s2 = NSAttributedString(string: "strike through", attributes: a2)
let a3: [NSAttributedString.Key: Any] = [.baselineOffset: 10]
let s3 = NSAttributedString(string: " raised ", attributes: a3)
let a4: [NSAttributedString.Key: Any] = [.font: UIFont(name: "Papyrus", size: 36.0)!, .foregroundColor: UIColor.green]
let s4 = NSAttributedString(string: " papyrus font ", attributes: a4)
let a5: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.systemBlue, .underlineStyle: 1, .underlineColor: UIColor.systemRed]
let s5 = NSAttributedString(string: "underlined ", attributes: a5)
s1.append(s2)
s1.append(s3)
s1.append(s4)
s1.append(s5)
return s1
}()
The Quick Way Out: Ignoring Attributes
有时您可能有一个没有属性的带属性字符串,或者您不介意失去它们的效果。如果你幸运的话,你可以使用它的普通字符串值:
struct ContentView: View {
var body: some View {
Text(myAttributedString.string)
.padding(10)
.border(Color.black)
}
}
或者,您可以使用具有自定义初始化器的Text扩展来执行相同的操作。这似乎有些过分,但我想在这里介绍这种技术,因为我们稍后将对其进行扩展。
extension Text {
init(dumbAttributedString: NSAttributedString) {
self.init(dumbAttributedString.string)
}
}
struct ContentView: View {
var body: some View {
Text(dumbAttributedString: myAttributedString)
.padding(10)
.border(Color.black)
}
}
A Limited, but Effective Approach: Text Concatenation
2020年,SwiftUI增加了连接Text视图的能力。将两个文本视图添加到一起会产生一个新的文本视图:
struct ContentView: View {
var body: some View {
Text("Hello ") + Text("world!")
}
}
你也可以在这些文本视图上使用修饰符:
struct ContentView: View {
var body: some View {
let t = Text("Hello ").foregroundColor(.red) + Text("world!").fontWeight(.heavy)
return t
.padding(10)
.border(Color.black)
}
}
然而,并不是所有的修饰符都能工作,只有那些返回Text的修饰符才能工作。如果一个修饰符返回一些视图,你就不走运了。值得注意的是,在两个版本中存在一些修饰符。例如,foregroundColor是Text视图的一种方法:
extension Text {
public func foregroundColor(_ color: Color?) -> Text
}
但在View协议中还有另一个同名的方法:
extension View {
@inlinable public func foregroundColor(_ color: Color?) -> some View
}
他们都做同样的事情,但是如果你注意返回类型,一个返回文本,而另一个返回一些视图。返回类型将取决于您正在修改的视图。如果它是一个Text视图,它将返回Text。否则,它将是一些视图。
在撰写本文时,可以返回Text的修饰符有:
- baselineOffset()
- bold()
- font()
- fontWeight()
- foregroundColor()
- italic()
- kerning()
- strikethrough()
- tracking()
- underline()
From Attributed String to Concatenated Text
既然我们知道了如何连接Text视图,那么让我们探索一种从带属性字符串到复合Text视图的方法。我们想要实现的是一个自定义文本初始化器。它将接收一个带属性的字符串并返回一个由连接的片段组成的Text视图,每个片段都有与带属性字符串中找到的属性相对应的适当修饰符。使用它看起来像这样:
struct ContentView: View {
var body: some View {
Text(myAttributedString)
.padding(10)
.border(Color.black)
}
}
参见下面的初始化器代码:
extension Text {
init(_ astring: NSAttributedString) {
self.init("")
astring.enumerateAttributes(in: NSRange(location: 0, length: astring.length), options: []) { (attrs, range, _) in
var t = Text(astring.attributedSubstring(from: range).string)
if let color = attrs[NSAttributedString.Key.foregroundColor] as? UIColor {
t = t.foregroundColor(Color(color))
}
if let font = attrs[NSAttributedString.Key.font] as? UIFont {
t = t.font(.init(font))
}
if let kern = attrs[NSAttributedString.Key.kern] as? CGFloat {
t = t.kerning(kern)
}
if let striked = attrs[NSAttributedString.Key.strikethroughStyle] as? NSNumber, striked != 0 {
if let strikeColor = (attrs[NSAttributedString.Key.strikethroughColor] as? UIColor) {
t = t.strikethrough(true, color: Color(strikeColor))
} else {
t = t.strikethrough(true)
}
}
if let baseline = attrs[NSAttributedString.Key.baselineOffset] as? NSNumber {
t = t.baselineOffset(CGFloat(baseline.floatValue))
}
if let underline = attrs[NSAttributedString.Key.underlineStyle] as? NSNumber, underline != 0 {
if let underlineColor = (attrs[NSAttributedString.Key.underlineColor] as? UIColor) {
t = t.underline(true, color: Color(underlineColor))
} else {
t = t.underline(true)
}
}
self = self + t
}
}
}
这是一个很好的解决方案。不幸的是,并非所有属性都可以处理,因为只有少数修饰符返回Text(来自上面列表的那些)。例如,如果带属性的字符串有一个超链接,此解决方案将直接忽略它。如果您不幸有一个不受支持的属性,您可能需要使用下一种方法。
Fallback to UIKit, with UIViewRepresentable (iOS)
当我们耗尽所有swifttui资源时,可能是时候创建一个UIViewRepresentable了。通常,这有自己的一组问题,但至少支持所有属性。我们将从一个简单的UIViewRepresentable开始:
struct ContentView: View {
var body: some View {
AttributedText(attributedString: myAttributedString)
.padding(10)
.border(Color.black)
}
}
struct AttributedText: UIViewRepresentable {
let attributedString: NSAttributedString
init(_ attributedString: NSAttributedString) {
self.attributedString = attributedString
}
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.lineBreakMode = .byClipping
label.numberOfLines = 0
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
uiView.attributedText = attributedString
}
}
正如你所看到的,这个方法的问题是UIViewRepresentable增长到占用所有可用空间。这里是我们需要创造性地让视图遵循我们需要的布局的地方。
试图解决这个问题时,我们首先需要确定我们到底想要什么。文本应该换行吗? 它应该生长到理想的最大宽度吗? 支持高度? 你会尊重带属性的字符串段落风格,还是忽略它? 一旦你回答了这些问题,了解一点UIKit/AppKit就会大有帮助。
我将提出一个在某些情况下可能有用的解决方案。当然,您的需求可能会有所不同。如果是这样,我希望本文为您提供了一个良好的起点。
Using a Binding with sizeThatFits
为了防止视图增长(或收缩太多),我们可以使用frame(width:height:)强制它到一个特定的尺寸。 挑战是找到正确的宽度和高度值。下面的例子创建了一个Binding来连接一个State大小变量,其结果是在UILabel上调用sizeethatfits。
struct ContentView: View {
var body: some View {
AttributedText(myAttributedString)
.padding(10)
.border(Color.black)
}
}
struct AttributedText: View {
@State private var size: CGSize = .zero
let attributedString: NSAttributedString
init(_ attributedString: NSAttributedString) {
self.attributedString = attributedString
}
var body: some View {
AttributedTextRepresentable(attributedString: attributedString, size: $size)
.frame(width: size.width, height: size.height)
}
struct AttributedTextRepresentable: UIViewRepresentable {
let attributedString: NSAttributedString
@Binding var size: CGSize
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.lineBreakMode = .byClipping
label.numberOfLines = 0
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
uiView.attributedText = attributedString
DispatchQueue.main.async {
size = uiView.sizeThatFits(uiView.superview?.bounds.size ?? .zero)
}
}
}
}
这个解决方案不会为每个场景工作,其他选项可能是设置hugging priority和UILabel的压缩阻力(UILabel. setcontenthugingpriority()和UILabel. setcontentcompressionresistance())。最重要的是,你需要有创造性,这取决于你期望的结果。
Summary
SwiftUI远不支持带属性字符串,但是稍加努力,我们至少可以更接近一点。我想我会把它列入明年的愿望清单。