3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

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

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

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

TextRenderer の使い方

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

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

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

TextRendererに準拠した型を定義

TextRendererに準拠するには、drawメソッドを定義する必要があります。

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

struct SampleTextRenderer: TextRenderer {

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

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

Text.Layoutとは

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

Text.Layoutは以下のような構成になっています。

※ 大変わかりやすかったので、こちらの記事より引用させていただきました。

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

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

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

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 を指定することで任意の単位でテキストを描画できます。

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

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

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 を設定しました。

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

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

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

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

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

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 がループ毎に重複されるので、後になるほど影が濃くなる

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

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

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

おわり

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

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?