TextRendererでSwiftUIのTextを装飾を試してみる

# はじめに
try! Swift Tokyo 2025で以下youtubeにも公開されているセッションにて`TextRenderer`が紹介されていました。
これを見て、テキストに装飾をつけるのかっこいい！と思い、自分でも試したみたくなったので、今回は`TextRenderer`を試したみた内容を記事にしてみました。

https://www.youtube.com/watch?v=zbnYo_46kS8&list=PLCl5NM4qD3u_Azg7gKw5CK_DqSLeb4QMY&index=7

`TextRenderer` は SwiftUI の`Text`の描画処理を制御することで Effect を付与できる APIです。

以下のように、簡単に`Text`を装飾することができます。

```swift
import SwiftUI

struct ColorfulTextRenderer: TextRenderer {

    func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
        for line in layout {
            for run in line {
                for (index, grif) in run.enumerated() {
                    var copy = ctx
                    let degree = Angle.degrees(360 / Double(index + 1))
                    copy.addFilter(.hueRotation(degree))
                    copy.draw(grif)
                }
            }
        }
    }

    struct Attribute: TextAttribute {}
}

#Preview {
    Text("Hello World!!!")
        .font(.largeTitle)
        .foregroundStyle(.red)
        .textRenderer(ColorfulTextRenderer())
}
```

<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/7aafb3b8-102f-459b-9753-6ce0c14d5c97.png" />

# TextRenderer の使い方

ざっくりの手順は以下の通りです。
こう見ると`TextRenderer`さえ定義してしまえば、あとは`Text`に modifier をつけるだけなので簡単そうです。

1. `TextRenderer`に準拠した型を定義
2. `Text`に対して、定義したカスタムの`TextRenderer`を`textRenderer`modifier を使って指定する

https://developer.apple.com/documentation/swiftui/view/textrenderer(_:)

順に詳細に解説していきます。

## `TextRenderer`に準拠した型を定義

`TextRenderer`に準拠するには、[draw](<https://developer.apple.com/documentation/swiftui/textrenderer/draw(layout:in:)>)メソッドを定義する必要があります。

最低限、以下のようにすれば、`TextRenderer`に準拠したカスタムの`TextRenderer`は定義できます。

```swift
struct SampleTextRenderer: TextRenderer {

    func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {}
}
```

`draw`メソッドには`Text.Layout`と`GraphicsContext`という型の引数をとり、この引数を使うことで、テキストを描画したり描画するテキストに Effect を付与することができる。

### `Text.Layout`とは

`Text.Layout`にはレンダリングするテキストの情報が格納されている。

`Text.Layout`は以下のような構成になっています。
<img width="500px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/44d5dc68-a1ce-413b-b876-1aac07ceb095.png" />

※ 大変わかりやすかったので、[こちらの記事](https://fatbobman.com/en/posts/creating-stunning-dynamic-text-effects-with-textrender/#fundamental-concepts-explained)より引用させていただきました。

Line、Run、RunSlice の内容は以下の通りです。

- Line: テキストレイアウト内の 1 行を表す
- Run: 同じ属性（フォント、色、スタイルなど）を持つ文字の連続した並びを表す
- RunSlice: 文字(テキストグリフ)を表す

以下のようにすることで、Line、Run、RunSlice を取得することができます。

```swift
func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
    for line in layout { // lineの取得
        for run in line { // runの取得
            for runSlice in run { // runSliceの取得

            }
        }
    }
}
```

### `GraphicsContext`とは
その名の通り、画像描画のコンテキストを表す構造体で、描画先の状態（座標変換・フィルタ・描画スタイルなど）を保持しています。
`TextRenderer`において`GraphicsContext`には、テキストを描画したり描画するテキストに Effect を与える役割を持ちます。

テキストを描画するときは、以下のように`draw`メソッドを呼び出します。
以下例では`draw`メソッドに line を指定しているが、Line 単位だけでなく、Run や RunSlice を指定することで任意の単位でテキストを描画できます。

```swift
func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
    for line in layout {
        ctx.draw(line)
    }
}
```

以下のようにしても、上記と同じく全てのテキストが描画されます。

```swift
func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
    for line in layout {
        for run in line {
            for runSlice in run {
                ctx.draw(runSlice)
            }
        }
    }
}
```

描画するテキストに Effect を与えるには、`draw`メソッドでテキストを指定する前に、`GraphicsContext`に適用した Effect を以下のように設定します。

以下例では、後の文字になる程文字の Y 軸に拡大させて、影をつける Effect を設定しました。

```swift
func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
    for line in layout {
        for run in line {
            for (index, runSlice) in run.enumerated() {
                var copy = ctx
                copy.scaleBy(x: 1, y: (CGFloat(index) * 0.1 + 1.0))
                copy.addFilter(.shadow(radius: 2, x: 3, y: 3))
                copy.draw(runSlice)
            }
        }
    }
}
```

上記を実際に使用した例は以下の通りです。

```swift
struct SampleTextRenderer: TextRenderer {

    func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
        for line in layout {
            for run in line {
                for (index, runSlice) in run.enumerated() {
                    var copy = ctx
                    copy.scaleBy(x: 1, y: (CGFloat(index) * 0.1 + 1.0))
                    copy.addFilter(.shadow(radius: 2, x: 3, y: 3))
                    copy.draw(runSlice)
                }
            }
        }
    }
}

#Preview {
    Text("Hello World!!!")
        .font(.largeTitle)
        .textRenderer(SampleTextRenderer())
}
```

<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/f364ef38-93b9-4afe-b569-18467bd4dc5a.png">


ちなみに`var copy = ctx`とわざわざ`draw`メソッドの引数の`ctx`を`copy`に代入しているのかというと、副作用を防ぐためです。
`GraphicsContext`には状態（座標変換・フィルタ・描画スタイルなど）を保持しており、`ctx`は`inout`で渡されるため for ループで全ての Effect 設定を`ctx`で行うと全てのテキストに重複して設定されてしまうため、意図した表示になりません。

例えば、以下で表示のされ方が変わります。

```swift
func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
    for line in layout {
        for run in line {
            for (index, runSlice) in run.enumerated() {
                ctx.opacity = CGFloat(index) * 0.1 + 0.1
                ctx.addFilter(.shadow(radius: 1, x: 2, y: 2))
                ctx.draw(runSlice)
            }
        }
    }
}
```

影の Effect がループ毎に重複されるので、後になるほど影が濃くなる
<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/151d4dbc-6447-4b6f-add7-a8eb8bfb8f48.png">


```swift
 func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
    for line in layout {
        for run in line {
            for (index, runSlice) in run.enumerated() {
                var copy = ctx
                copy.opacity = CGFloat(index) * 0.1 + 0.1
                copy.addFilter(.shadow(radius: 1, x: 2, y: 2))
                copy.draw(runSlice)
            }
        }
    }
 }
```

<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/ce7c3f55-9184-43b4-9c8e-ff840eeda9b1.png">


テキストに Effect を設定する例として、今回は一部のものしか使用しておらず、他にも Effect を付与するためのメソッドがあるので、手元でも色々試してもらえると楽しくなると思います！

`GraphicsContext`の API ドキュメントより、どんな Effect の設定ができるのかが確認できます。

https://developer.apple.com/documentation/swiftui/graphicscontext

# おわり
いかがだったでしょうか！少しでも`TextRenderer`によるテキストの装飾に興味を持っていただけたら幸いです。
また内容に誤りなどありましたら、ご指摘いただけると幸いです！