用 TextRenderer 构建绚丽动感的文字效果

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

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 中完成绘制。

Swift
@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 属性的字符增加了多彩效果,并实现了从左至右的动态显示效果。

Swift
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))

textrender-transition-colorful-demo1

基础概念解析

TextRenderer 协议的 draw 方法中,首要参数是 Text.Layout,它携带了与 Text 视图相关的布局和自定义属性信息。Text.Layout 代表整个文本的布局结构,其中包含了多个 Line

image-20240617153137710

Line

Line 表示文本布局中的单一行,内含若干 Run。得益于 Layout 符合 RandomAccessCollection 协议,我们可以轻松迭代 Layout 获取每个 Line

Swift
for line in layout {
  ctx.draw(line) // 直接绘制,不进行修改
}

LinetypographicBounds 属性详细记录了关于当前行的边界、尺寸、文本基线、行间距等信息。利用这些数据,我们可以更精确地调整文字的渲染效果。

Run

Run 指的是文本布局中的一连串字形,包括多个 RunSlice。通过迭代 Line,我们可以访问每行中的 Run

Swift
for line in layout {
  for run in layout {
    ctx.draw(run) // 直接绘制,不进行修改
  }
}

为了更便捷地操作 Run,可以使用以下扩展:

Swift
extension Text.Layout {
  var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> {
    flatMap { line in
      line
    }
  }
}

for run in layout.flattenedRuns {
  ...
}

Layout.RunAttributedString 中的 Run 概念相似,每个 Run 都是一组具有相同样式属性的关联字形。

Swift
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 获取。

Swift
// 扩展 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 实现中,我们通过操作这个上下文来改变文本的原始渲染样式。

为了确保所作更改仅适用于特定的文本元素(如 LineRunRunSlice),我们通常会创建一个副本并在其上进行操作。

由于这些数据本身包含充足的布局信息,可以直接要求 GraphicsContext 绘制 LineRunRunSlice,而无需显式指定位置和尺寸。

Swift
let copy = context
copy.opacity = 0.5
copy.draw(slice) // 绘制 RunSlice,透明度为原来的 50%

打造多彩文字效果

在阐述了一些基础概念之后,接下来我们将通过一个具体实例来展示 TextRenderer 强大的功能。本节中,我们将实现一个名为 ColorfulEffectTextRenderer,它能够为 Text 中的每个字符赋予独特的颜色变化,让文本显得更加多彩生动。

Swift
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())
  }
}

image-20240619104825472

值得注意的是,RunSlice 不仅表示文字,ColorfulEffect 同样也会影响 Text 中嵌入的图像。

Swift
let heart = Image(systemName: "heart.fill")
Text("Hello \(heart) World")
  .font(.title)
  .fontWeight(.heavy)
  .foregroundStyle(.red)
  .textRenderer(ColorfulRender())

image-20240619105126689

在这个示例中,我们对每个 RunSlice 进行了个性化调整。根据具体需求,你也可以选择以 LineRun 为单位进行操作,以实现不同的视觉效果。

赋予文字动态效果

在这一节中,我们将利用 TextRenderer 为文字赋予基于正弦波的动态往复动画效果。

Swift
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
  }
}

通过不断调整时间偏移,我们可以在视图中实现周期性动画效果。

Swift
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
      }
  }
}

textrender-sinwave-demo

尽管动画效果引人注目,但它也暴露了一些潜在问题。例如,当我们为 Text 添加边框时,可以观察到文本的实际尺寸超出了布局系统预设的尺寸。

image-20240619111936983

为了解决这个问题,我们需要利用 TextRenderersizeThatFitsdisplayPadding 方法调整文本最终布局尺寸和位置。

Swift
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)

textrender-sinwave-demo2

通过这种方式,我们确保了动画后的文本尺寸与布局尺寸保持一致,并让文本正确地显示在预定位置。这些操作表明,虽然 TextRenderer 提供了广阔的创意空间,但在实际应用中,可能需要根据特定场景对其进行精细调整。

使用 TextAttribute 为特定文本段定制效果

由于当前的 Run 并未提供更细致的文本特征信息,为了精确控制 Text 中特定片段的效果,SwiftUI 引入了 TextAttribute 协议。

在本例中,我们将仅在 Text 中的指定片段上应用多彩效果。

首先定义一个符合 TextAttribute 协议的类型。在 ColorfulAttribute 中,我们将只对包含此属性的 RunSlice 进行颜色调整。

Swift
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 修饰器为特定文本片段设置自定义属性。

Swift
struct ColorfulAttributeDemo: View {
  var body: some View {
    let weekly = Text("Fatbobman Swift Weekly")
      .bold()
      .foregroundStyle(.pink)
      .customAttribute(ColorfulAttribute())

    Text("Subscribe \(weekly) now!")
      .textRenderer(ColorfulAttributeRender())
  }
}

image-20240619122601553

另外,我们也可以直接迭代 Run 并使用 Run 的下标方法进行检测。以下示例中,我们为具备 BlurAttributeRun 添加模糊效果:

Swift
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())
  }
}

image-20240619123505060

实现适用于转场的 TextRenderer

结合 iOS 17 开始引入的 Transition 协议,我们可以将 TextRenderer 实现用作转场动画的处理逻辑。本节将展示如何基于苹果在 WWDC 2024 的 Session 中展示的代码示例来创建一个转场效果。

如果你尚不熟悉转场的概念,请阅读 SwiftUI 的动画机制,了解转场相关的基础知识。该文章虽然是在 Transition 协议推出前撰写的,但所涉及的动画逻辑仍然适用。

首先,我们定义一个名为 LineByLineEffectTextRenderer 实现,该实现能逐行展示文本。

Swift
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 秒时的状态。

Swift
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))

image-20240619143403500

接下来将 LineByLineEffect 封装到一个 Transition 实现中,转场动画将生成插值数据并传递给 elapsedTime 参数,以完成转场。

Swift
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 正确接收动画插值数据,我们需如下设置 LineByLineEffectanimatableData 属性:

Swift
struct LineByLineEffect: TextRenderer {
  var animatableData: Double {
    get { elapsedTime }
    set { elapsedTime = newValue }
  }
}

现在,应用 LineByLineTransition 到文本上,就能获得以下的转场效果:

Swift
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())

textrender-linebyline-transition-demo

这种方式使得 TextRenderer 不仅能用于创建动态文本效果,也能为文本转场提供动画逻辑,展示其在界面动态表达上的强大能力。

向后兼容性探讨

虽然本文介绍的代码目前只适用于 iOS 18(或相应年份的其他系统),但 TextRenderer 及其主要相关 API 已实现向后兼容至 iOS 17。

很有可能随着 Xcode 16 的正式发布,TextRenderer 的实现就能够在 iOS 17 上运行。这意味着开发者可以在不升级到最新操作系统版本的情况下,开始利用这些新功能。

注意事项:

在构建和使用 TextRenderer 实现过程中,需要考虑以下几点:

  • 文本特征信息获取限制: 由于无法直接从 Run 中提取详细的特征信息,这在一定程度上限制了通过 TextRenderer 自定义复杂文本效果的能力。为了解决这一问题,可以考虑将长文本拆分为多个独立的 Text 组件,分别进行效果应用。
  • 单一 TextRenderer 应用: 目前,每个 Text 组件只能应用一个 TextRenderer 实现。若需复用独立效果,建议将相关逻辑从 TextRenderer 中分离出来,以便在多个文本组件中重用。
  • 转场动画的复杂性: 使用 TextRenderer 进行转场动画时,必须仔细考量元素的拆分数量、动画的持续时间与总时长之间的关系,以确保动画流畅执行。

总结与展望

TextRenderer 是 SwiftUI 提供的一个强大工具,使开发者能够自定义文本的渲染结果。这不仅增加了对文本渲染的控制,还提供了许多以前无法访问的信息,例如:通过 LayoutisTruncated 属性判断文本是否被截断、显示后的行数以及文本的具体布局数据等。

通过使用 TextRenderer,之前让开发者头疼的为 Text 内嵌标签的需求现在可以轻松解决。我已经更新了 在 SwiftUI 中用 Text 实现图文混排 一文,加入了基于 TextRenderer 的新方案。

我相信苹果将会继续引入更多这类高级功能,进一步释放 SwiftUI 的潜力。未来几年中,随着这些功能的不断完善和扩展,我们可以期待 SwiftUI 在应用开发中扮演更加重要的角色。

与全球开发者一同,每周探索 Swift 世界的精彩内容

如果文章对你有所帮助,可以请我喝杯