Text
组件在 SwiftUI 应用中极为常见。过去几年里,尽管苹果不断扩展其功能,开发者仍期待能更深层次地控制这一组件。在 WWDC 2024 上,SwiftUI 推出了 TextRenderer
协议,赋予开发者调整 Text
组件渲染表现的新能力,使得实现许多先前难以想象的效果成为可能。本文将深入探讨这一新增功能。
TextRenderer 协议的作用是什么?
苹果官方对 TextRenderer
协议的描述是:
A value that can replace the default text view rendering behavior.
TextRenderer
协议赋予开发者在 Text
组件渲染前调整其表现的能力。通过将基于 TextRenderer
的实现应用到 Text
组件上,我们让 Text
最大限度地按照我们的想法来呈现。
从 TextRenderer
协议的声明可以看出,其设计非常简洁,核心是 draw
方法。在此方法中,开发者需根据 Text.Layout
提供的元素信息,在 GraphicsContext
中完成绘制。
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol TextRenderer : Animatable {
// 核心方法,开发者可以在此对 Text 中的元素进行自定义渲染,添加效果
func draw(layout: Text.Layout, in ctx: inout GraphicsContext)
// 如果自定义效果改变了呈现尺寸,可以通过该方法给出新的需求尺寸
@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
func sizeThatFits(proposal: ProposedViewSize, text: TextProxy) -> CGSize
// 如果通过 sizeThatFits 调整了需求尺寸,可以在此调整文本在新尺寸中的位置
@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
var displayPadding: EdgeInsets { get }
}
以下示例展示了如何将 TextRenderer
应用于 Text
,在此代码中,我们为标有 ColorfulEffect
属性的字符增加了多彩效果,并实现了从左至右的动态显示效果。
let textRenderer = Text("TextRenderer")
.customAttribute(ColorfulEffect())
.foregroundStyle(.pink)
.bold()
Text("Build Visual Effects\nwith \(textRenderer)")
.font(.system(.title, design: .rounded, weight: .semibold))
.textRenderer(AppearanceEffectRenderer(elapsedTime: time, totalDuration: 0.8))
基础概念解析
在 TextRenderer
协议的 draw
方法中,首要参数是 Text.Layout
,它携带了与 Text 视图相关的布局和自定义属性信息。Text.Layout
代表整个文本的布局结构,其中包含了多个 Line
。
Line
Line
表示文本布局中的单一行,内含若干 Run
。得益于 Layout
符合 RandomAccessCollection
协议,我们可以轻松迭代 Layout
获取每个 Line
。
for line in layout {
ctx.draw(line) // 直接绘制,不进行修改
}
Line
的 typographicBounds
属性详细记录了关于当前行的边界、尺寸、文本基线、行间距等信息。利用这些数据,我们可以更精确地调整文字的渲染效果。
Run
Run
指的是文本布局中的一连串字形,包括多个 RunSlice
。通过迭代 Line
,我们可以访问每行中的 Run
。
for line in layout {
for run in line {
ctx.draw(run) // 直接绘制,不进行修改
}
}
为了更便捷地操作 Run
,可以使用以下扩展:
extension Text.Layout {
var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> {
flatMap { line in
line
}
}
}
for run in layout.flattenedRuns {
...
}
Layout.Run
与 AttributedString
中的 Run
概念相似,每个 Run
都是一组具有相同样式属性的关联字形。
Text("Hello world") // 一个 Run
let name = Text("fatbobman").foregroundStyle(.pink)
Text("Hello \(name) !\(Image(systemName:"heart"))") // 四个 Run,'Hello ','fatbobman',' !' 和 'heart symbol'
与 AttributedString
不同,我们无法直接从 Run
获取当前的特征信息,如下划线、粗体、斜体等。在这种情况下,Run
的作用将被降低,目前主要用于获取自定义属性(后文将详述)。
探索 AttributedString —— 不仅仅让文字更漂亮 以了解更多关于
AttributedString
的信息。
RunSlice
RunSlice
代表 Run
中的部分字形切片,可以通过迭代 Run
获取。
// 扩展 Layout 协议,简化迭代操作
extension Text.Layout {
var flattenedRunSlices: some RandomAccessCollection<Text.Layout.RunSlice> {
flattenedRuns.flatMap(\.self)
}
}
for slice in layout.flattenedRunSlices {
...
}
直接通过迭代 Run
获取的 RunSlice
通常对应单个 Glyphs,但由于 Text
支持图像的插值,所以我们不能将其完全视为文字。
Run
的下标方法也可以用来访问由多个 Glyphs 构成的 RunSlice
。
GraphicsContext
在 draw
方法中使用的另一参数是 GraphicsContext
。这是 SwiftUI 从 iOS 15 开始提供的一个用于绘制自定义图形的上下文,与 Canvas
视图同步推出,类似于 UIKit 中的 CGContext
,但专为 SwiftUI 设计,与 SwiftUI 的布局和渲染系统紧密集成。
GraphicsContext
提供了一系列方法用于绘制各种元素并添加效果。在 TextRenderer
实现中,我们通过操作这个上下文来改变文本的原始渲染样式。
为了确保所作更改仅适用于特定的文本元素(如 Line
、Run
、RunSlice
),我们通常会创建一个副本并在其上进行操作。
由于这些数据本身包含充足的布局信息,可以直接要求 GraphicsContext
绘制 Line
、Run
和 RunSlice
,而无需显式指定位置和尺寸。
let copy = context
copy.opacity = 0.5
copy.draw(slice) // 绘制 RunSlice,透明度为原来的 50%
打造多彩文字效果
在阐述了一些基础概念之后,接下来我们将通过一个具体实例来展示 TextRenderer
强大的功能。本节中,我们将实现一个名为 ColorfulEffect
的 TextRenderer
,它能够为 Text
中的每个字符赋予独特的颜色变化,让文本显得更加多彩生动。
struct ColorfulRender: TextRenderer {
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
// 遍历 RunSlice 及其索引
for (index, slice) in layout.flattenedRunSlices.enumerated() {
// 根据索引计算颜色调整的角度
let degree = Angle.degrees(360 / Double(index + 1))
// 创建 GraphicsContext 副本
var copy = context
// 应用色相旋转滤镜
copy.addFilter(.hueRotation(degree))
// 在上下文中绘制当前 Slice
copy.draw(slice)
}
}
}
struct ColorfulDemo: View {
var body: some View {
Text("Hello World")
.font(.title)
.fontWeight(.heavy)
.foregroundStyle(.red)
.textRenderer(ColorfulRender())
}
}
值得注意的是,RunSlice
不仅表示文字,ColorfulEffect
同样也会影响 Text
中嵌入的图像。
let heart = Image(systemName: "heart.fill")
Text("Hello \(heart) World")
.font(.title)
.fontWeight(.heavy)
.foregroundStyle(.red)
.textRenderer(ColorfulRender())
在这个示例中,我们对每个 RunSlice
进行了个性化调整。根据具体需求,你也可以选择以 Line
或 Run
为单位进行操作,以实现不同的视觉效果。
赋予文字动态效果
在这一节中,我们将利用 TextRenderer
为文字赋予基于正弦波的动态往复动画效果。
struct AnimatedSineWaveOffsetRender: TextRenderer {
let timeOffset: Double // 时间偏移量
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
let count = layout.flattenedRunSlices.count // 统计文本布局中所有 RunSlice 的数量
let width = layout.first?.typographicBounds.width ?? 0 // 获取文本 Line 的宽度
let height = layout.first?.typographicBounds.rect.height ?? 0 // 获取文本 Line 的高度
// 遍历每个 RunSlice 及其索引
for (index, slice) in layout.flattenedRunSlices.enumerated() {
// 计算当前字符的正弦波偏移量
let offset = animatedSineWaveOffset(
forCharacterAt: index,
amplitude: height / 2, // 振幅设为行高的一半
wavelength: width,
phaseOffset: timeOffset,
totalCharacters: count
)
// 创建上下文副本并进行平移
var copy = context
copy.translateBy(x: 0, y: offset)
// 在修改后的上下文中绘制当前 RunSlice
copy.draw(slice)
}
}
// 根据字符索引计算正弦波偏移量
func animatedSineWaveOffset(forCharacterAt index: Int, amplitude: Double, wavelength: Double, phaseOffset: Double, totalCharacters: Int) -> Double {
let x = Double(index)
let position = (x / Double(totalCharacters)) * wavelength
let radians = ((position + phaseOffset) / wavelength) * 2 * .pi
return sin(radians) * amplitude
}
}
通过不断调整时间偏移,我们可以在视图中实现周期性动画效果。
struct AnimatedSineWaveDemo: View {
@State var offset: Double = 0
@State var timer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect()
var body: some View {
Text("Build Visual Effects with TextRenderer!")
.font(.system(size: 16))
.textRenderer(AnimatedSineWaveOffsetRender(timeOffset: offset))
.onReceive(timer) { _ in
if offset > 1_000_000_000_000 {
offset = 0 // 重置时间偏移
}
offset += 10
}
}
}
尽管动画效果引人注目,但它也暴露了一些潜在问题。例如,当我们为 Text
添加边框时,可以观察到文本的实际尺寸超出了布局系统预设的尺寸。
为了解决这个问题,我们需要利用 TextRenderer
的 sizeThatFits
和 displayPadding
方法调整文本最终布局尺寸和位置。
struct AnimatedSineWaveOffsetRender: TextRenderer {
let fontSize: CGFloat // 字号信息,用于计算 displayPadding
...
func sizeThatFits(proposal: ProposedViewSize, text: TextProxy) -> CGSize {
let originalSize = text.sizeThatFits(proposal)
return CGSize(width: originalSize.width, height: originalSize.height * 2) // 根据最大振幅调整尺寸
}
var displayPadding: EdgeInsets {
let height = fontSize * 1.2
return EdgeInsets(top: -height / 2, leading: 0, bottom: 0, trailing: 0) // 调整文本位置
}
}
Text("Build Visual Effects with TextRenderer!")
.font(.system(size: 16))
.textRenderer(AnimatedSineWaveOffsetRender(timeOffset: offset, fontSize: 16))
.border(.blue)
通过这种方式,我们确保了动画后的文本尺寸与布局尺寸保持一致,并让文本正确地显示在预定位置。这些操作表明,虽然 TextRenderer
提供了广阔的创意空间,但在实际应用中,可能需要根据特定场景对其进行精细调整。
使用 TextAttribute 为特定文本段定制效果
由于当前的 Run
并未提供更细致的文本特征信息,为了精确控制 Text
中特定片段的效果,SwiftUI 引入了 TextAttribute
协议。
在本例中,我们将仅在 Text
中的指定片段上应用多彩效果。
首先定义一个符合 TextAttribute
协议的类型。在 ColorfulAttribute
中,我们将只对包含此属性的 RunSlice
进行颜色调整。
struct ColorfulAttribute: TextAttribute {}
struct ColorfulAttributeRender: TextRenderer {
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
for (index, slice) in layout.flattenedRunSlices.enumerated() {
// 检查当前 Slice 是否包含 ColorfulAttribute 属性
if slice[ColorfulAttribute.self] != nil {
let degree = Angle.degrees(360 / Double(i + 1))
var copy = context
copy.addFilter(.hueRotation(degree))
copy.draw(slice)
} else {
// 无需调整,直接绘制
context.draw(slice)
}
}
}
}
使用 customAttribute
修饰器为特定文本片段设置自定义属性。
struct ColorfulAttributeDemo: View {
var body: some View {
let weekly = Text("Fatbobman Swift Weekly")
.bold()
.foregroundStyle(.pink)
.customAttribute(ColorfulAttribute())
Text("Subscribe \(weekly) now!")
.textRenderer(ColorfulAttributeRender())
}
}
另外,我们也可以直接迭代 Run
并使用 Run
的下标方法进行检测。以下示例中,我们为具备 BlurAttribute
的 Run
添加模糊效果:
struct BlurAttribute: TextAttribute {}
struct BlurEffect: TextRenderer {
func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
for run in layout.flattenedRuns {
if run[BlurAttribute.self] != nil {
// 对具有 BlurAttribute 的 Run 应用模糊效果
var blurContext = ctx
let radius = run.typographicBounds.rect.height / 5
blurContext.addFilter(.blur(radius: radius))
blurContext.draw(run)
}
// 绘制所有 Run,不论是否添加模糊
ctx.draw(run)
}
}
}
struct BlurEffectDemo: View {
var body: some View {
let weekly = Text("Fatbobman Swift Weekly")
.bold()
.foregroundStyle(.pink)
.customAttribute(BlurAttribute())
Text("Subscribe \(weekly) now!")
.textRenderer(BlurEffect())
}
}
实现适用于转场的 TextRenderer
结合 iOS 17 开始引入的 Transition
协议,我们可以将 TextRenderer
实现用作转场动画的处理逻辑。本节将展示如何基于苹果在 WWDC 2024 的 Session 中展示的代码示例来创建一个转场效果。
如果你尚不熟悉转场的概念,请阅读 SwiftUI 的动画机制,了解转场相关的基础知识。该文章虽然是在
Transition
协议推出前撰写的,但所涉及的动画逻辑仍然适用。
首先,我们定义一个名为 LineByLineEffect
的 TextRenderer
实现,该实现能逐行展示文本。
struct LineByLineEffect: TextRenderer {
var elapsedTime: TimeInterval // 已经过的时间
var elementDuration: TimeInterval // 每行的持续时间
var totalDuration: TimeInterval // 总持续时间
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
// 根据 elapsedTime 来决定渲染逻辑
...
}
}
totalDuration
是动画的总时长,而 elapsedTime
则是动画已经进行的时间。在 draw
方法中,我们需要根据 elapsedTime
的值来计算当前的渲染结果。
可以在这里查看完整代码:示例代码。
通过 textRenderer
修饰器应用 LineByLineEffect
,我们可以通过调整参数,让文本呈现在动画过程中任意时间点的状态。以下代码展示了在总时长为 0.9 秒时,0.432 秒时的状态。
let weekly = Text("Fatbobman's Swift Weekly")
.foregroundStyle(.pink).bold()
let swiftui = Text("SwiftUI")
.foregroundStyle(.green).bold()
Text("Get weekly handpicked updates on \(swiftui) from \(weekly)!")
.font(.system(.title, design: .rounded, weight: .semibold))
.textRenderer(LineByLineEffect(elapsedTime: 0.432, elementDuration: 0.6, totalDuration: 0.9))
接下来将 LineByLineEffect
封装到一个 Transition
实现中,转场动画将生成插值数据并传递给 elapsedTime
参数,以完成转场。
struct LineByLineTransition: Transition {
let duration: TimeInterval
init(duration: TimeInterval = 1.0) {
self.duration = duration
}
func body(content: Content, phase: TransitionPhase) -> some View {
// 根据转场阶段设置 elapsedTime,转场将自动创建动画插值数据
let elapsedTime = phase.isIdentity ? duration : 0
// 创建 LineByLineEffect
let renderer = LineByLineEffect(
elapsedTime: elapsedTime,
totalDuration: duration
)
content.transaction { t in
// 根据 disablesAnimations 决定是否启用动画转场
if !t.disablesAnimations {
// 用 linear 覆盖 transaction 的默认动画
t.animation = .linear(duration: duration)
}
} body: { view in
view.textRenderer(renderer)
}
}
}
了解更多关于
Transaction
和动画控制的细节,请参阅 掌握 Transaction,实现 SwiftUI 动画的精准控制。
为了让 LineByLineEffect
正确接收动画插值数据,我们需如下设置 LineByLineEffect
的 animatableData
属性:
struct LineByLineEffect: TextRenderer {
var animatableData: Double {
get { elapsedTime }
set { elapsedTime = newValue }
}
}
现在,应用 LineByLineTransition
到文本上,就能获得以下的转场效果:
let weekly = Text("Fatbobman's Swift Weekly")
.foregroundStyle(.pink).bold()
let swiftui = Text("SwiftUI")
.foregroundStyle(.green).bold()
Text("Get weekly handpicked updates on \(swiftui) from \(weekly)!")
.font(.system(.title, design: .rounded, weight: .semibold))
.transition(LineByLineTransition())
这种方式使得 TextRenderer
不仅能用于创建动态文本效果,也能为文本转场提供动画逻辑,展示其在界面动态表达上的强大能力。
向后兼容性探讨
虽然本文介绍的代码目前只适用于 iOS 18(或相应年份的其他系统),但 TextRenderer
及其主要相关 API 已实现向后兼容至 iOS 17。
很有可能随着 Xcode 16 的正式发布,TextRenderer
的实现就能够在 iOS 17 上运行。这意味着开发者可以在不升级到最新操作系统版本的情况下,开始利用这些新功能。
注意事项:
在构建和使用 TextRenderer
实现过程中,需要考虑以下几点:
- 文本特征信息获取限制: 由于无法直接从
Run
中提取详细的特征信息,这在一定程度上限制了通过TextRenderer
自定义复杂文本效果的能力。为了解决这一问题,可以考虑将长文本拆分为多个独立的Text
组件,分别进行效果应用。 - 单一
TextRenderer
应用: 目前,每个Text
组件只能应用一个TextRenderer
实现。若需复用独立效果,建议将相关逻辑从TextRenderer
中分离出来,以便在多个文本组件中重用。 - 转场动画的复杂性: 使用
TextRenderer
进行转场动画时,必须仔细考量元素的拆分数量、动画的持续时间与总时长之间的关系,以确保动画流畅执行。
总结与展望
TextRenderer
是 SwiftUI 提供的一个强大工具,使开发者能够自定义文本的渲染结果。这不仅增加了对文本渲染的控制,还提供了许多以前无法访问的信息,例如:通过 Layout
的 isTruncated
属性判断文本是否被截断、显示后的行数以及文本的具体布局数据等。
通过使用
TextRenderer
,之前让开发者头疼的为Text
内嵌标签的需求现在可以轻松解决。我已经更新了 在 SwiftUI 中用 Text 实现图文混排 一文,加入了基于TextRenderer
的新方案。
我相信苹果将会继续引入更多这类高级功能,进一步释放 SwiftUI 的潜力。未来几年中,随着这些功能的不断完善和扩展,我们可以期待 SwiftUI 在应用开发中扮演更加重要的角色。