見出し画像

SwiftUI TextRenderer

先日、LT大会でルビをSwiftUIで表示するぞ!という発表をしました。
そのLTの中で,iOS 18から使えるようになったTextRendererも試してみたよ!でも疲れたからアドベントカレンダーに書くよと書いていたのですが、これがその記事になります。

※この記事はnote株式会社Advent Calendar 2024 12日目の記事です。

Text Renderer

SwiftUI.Textのレンダリングをデフォルトのものから置き換えるために使うものになります。.textRenderer(_:)にTextRendererに準拠したstructを渡してあげるとそれを使って描画してくれるようになります。

やっていくぞ

まずは、SwiftUIでTextを表示します。Textを使ってメッセージを表示するだけの最小のものです。

struct ChristmasView: View {
    var body: some View {
        Text("Merry Christmas")
        Text("and Happy New Year")
    }
}

#Preview {
    ChristmasView()
}
寂しいね

寂しいですね。TextRendererを使ってクリスマスに近づけていきましょう。
func draw(layout: Text.Layout, in ctx: inout GraphicsContext)を実装することで、文字列の描画ができるようになります。GraphicsContextはCoreGraphicsのSwiftUI Wrapperみたいなものです。

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

最小限の実装です。Textの情報はText.Layout→Line→Runs→Sliceといった感じで、全体的なレイアウト、段落、行、文字のような感じで分割されて収納されています。が、具体的な文字自体をどうのこうのするではなく、N文字目の文字をどうのこうのするというのがTextRendererの運用になるようです。

struct ChristmasView: View {
    var body: some View {
        VStack {
            Text("Merry Christmas")
            Text("and Happy New Year")
        }
        .textRenderer(CustomTextRenderer())
    }
}

VStack にいれてTextRendererをセットしました。表示自体はなにも変わらないのですが,レンダリングがオレオレレンダリングに変わりました。
このように、TextRendererはSwiftUI.Text以外に対してセットできます。セットされた子要素にTextがあれば,描画をのっとって書き換えられます。

クリスマス

では、唐突ですがクリスマスっぽくしていきましょう。Claudeにクリスマスっぽいカラーコードを作ってもらいました。

extension Color {
    // クリスマスの基本カラー
    static let christmasRed = Color(red: 170/255, green: 19/255, blue: 19/255)
    static let christmasGreen = Color(red: 34/255, green: 111/255, blue: 84/255)
    static let christmasGold = Color(red: 212/255, green: 175/255, blue: 55/255)

    // クリスマスの追加カラー
    static let wreathGreen = Color(red: 21/255, green: 71/255, blue: 52/255)
    static let christmasBurgundy = Color(red: 128/255, green: 0/255, blue: 32/255)
}
クリスマスっぽいですよね!!

では、これを文字に適用してみます。一文字ずつカラフルにうるさくしていきたいですね。こういう時にTextRendererは便利です。

struct ChristmasTextRenderer: TextRenderer {
    let colors: [Color] = [.christmasRed, .christmasGreen, .christmasGold, .wreathGreen, .christmasBurgundy]

    func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
        for line in layout {
            for run in line {
                for (i, slice) in run.enumerated() {
                    var copy = ctx
                    copy.addFilter(.colorMultiply(colors[i % colors.count]))
                    copy.draw(slice)
                }
            }
        }
    }
}

struct ChristmasView: View {
    var body: some View {
        VStack {
            Text("Merry Christmas")
            Text("and Happy New Year")
        }
        .kerning(4)
        .fontWeight(.black)
        .foregroundStyle(Color.white)
        .textRenderer(ChristmasTextRenderer())
    }
}

なお、 注意点が二つあります。

  • GraphicsContextはcopyする

    • copyしなかった場合、前のループの状態を引き継ぐため、カオスになっていきます

  • TextでForegroundをColor.whiteとかにしておく

    • もともとのText色の影響を受けるので、whiteにしておくとよいです

カラフルになりました。フォントまわりとかも変えています。

どんどんいくよ

次に、もっとうるさくしてみようかと思います。
アニメーションしてもらいましょう。ボタンをタップすると文字がアニメーションするようにします。
細かいところをちょこちょこ変えていますが,ほぼWWDC 24のセッションのコードなので最重要なところだけを書いています。

AnimationにはTransitionを使います。このTransitionも単独で記事にしてすっげーってできるようなものなのですが、さくっと説明もなしに使います。

struct TextTransition: Transition {
    static var properties: TransitionProperties {
        TransitionProperties(hasMotion: true)
    }

    func body(content: Content, phase: TransitionPhase) -> some View {
        let duration = 1.0
        let elapsedTime = phase.isIdentity ? duration : 0
        let renderer = ChristmasTextRenderer(
            elapsedTime: elapsedTime,
            totalDuration: duration
        )

        content.transaction { transaction in
            if !transaction.disablesAnimations {
                transaction.animation = .linear(duration: duration)
            }
        } body: { view in
            view.textRenderer(renderer)
        }
    }
}


お祭り

さて、Happy New Yearはクリスマス色というよりはお正月色にしたいですね。こういった場合はどう設定すべきでしょうか。

一つのTextには一つのTextRendererしか適用できないようです。
ここまでのコードでも複数のTextに対して、一つのRendererというコードになっていました。
アニメーションするRenderer、文字色を変えるRendererとするのではなく、一つの強いオレオレRendererを作り,それを使うという風になります。実際問題、複数のCanvas操作を別々にやるとかさっぱりわからないですしね。。。

iOS17以上対象で,TextAttributeというprotocolも追加されました。

StringAttributeのようなかんじで、Textに対してAttributeを設定できるものになります。Text全体にセットされてしまうので、範囲を区切る場合は工夫が必要そうです。
これまで雑に全体をわちゃわちゃさせていたのですが,これでわちゃわちゃさせる部分を指定出来るようになります。 

struct ChristmasAttribute: TextAttribute {}
struct AnimationAttribute: TextAttribute {}
struct NewyearAttribute: TextAttribute {}

AnimationさせたいTextにはAnimationAttributeを、クリスマス色、正月色にする場所にはそれぞれのAttributeをセットします。

VStack {
    Text("Merry Christmas")
        .customAttribute(ChristmasAttribute())
        .customAttribute(AnimationAttribute())
    Text("and Happy New Year")
        .customAttribute(AnimationAttribute())
        .customAttribute(NewyearAttribute())
}
Button { isVisible.toggle() } label: {
    Text("Animation")
        .customAttribute(ChristmasAttribute())
}

TextRendererの中で

if slice[AnimationAttribute.self] != nil {
}

といったコードでAttributeがセットされているかを確認できます。なので、色を変えるFilterの追加やアニメーションの処理などはこの中にいれていく感じになります。Animationボタンも含めて、TextRendererで装飾してみました。

Merry Christmas and Happy New Year

よいお年をお迎えください。






ちなみに、ルビの表示もできまして、こちらにはちょっと工夫が必要になります。ルビの文字数>ベースの文字数となった場合の隙間のあつかいですね。
いいかんじに割り付けてあげる必要があります。
上で求めた、フレームサイズはフレームサイズであり部分的な文字のサイズとかではありません。そこを調整してあげる必要があるのですが、TextRendererだけでがんばることが出来ませんでした。

方針としては、Textに対してtrackingやText(" ")をあいだにつっこんだりして、文字の余白を調整します。
そして、Rendererで文字の上に小さなフォントサイズでルビを別途印字する。そのさい、余白等は事前に準備しているので、Rendererは知らなくてもなんとかなる。みたいな感じになります。

struct RubyAttributes: Hashable {
    let text: String
    let font: Font
    let tracking: CGFloat
    let verticalOffset: CGFloat?

    init(text: String, font: Font, tracking: CGFloat = 0, verticalOffset: CGFloat? = nil) {
        self.text = text
        self.font = font
        self.tracking = tracking
        self.verticalOffset = verticalOffset
    }
}

/// ルビ
struct RubyAttributeKey: TextAttribute {
    static let name = "RubyAnnotation"
    let attributes: RubyAttributes
}


// Runとかから↑Structが帰ってきています。
if let attribute = run[RubyAttributeKey.self] {
  // Ruby情報を使って描画
} 
extension CustomTextRenderer {
    private func drawRubyAnnotations(for line: Text.Layout.Line, in ctx: inout GraphicsContext) {
        for run in line {
            guard let attribute = run[RubyAttributeKey.self] else { continue }
            let rubyText = createRubyText(with: attribute.attributes)
            let resolvedText = ctx.resolve(rubyText)
            let rect = calculateRubyRect(for: run, resolvedText: resolvedText, tracking: attribute.attributes.tracking, verticalOffset: attribute.attributes.verticalOffset)
            ctx.draw(resolvedText, in: rect)
        }
    }

    private func createRubyText(with attributes: RubyAttributes) -> Text {
        Text(attributes.text).font(attributes.font)
    }

    private func calculateRubyRect(for run: Text.Layout.Run, resolvedText: GraphicsContext.ResolvedText, tracking: CGFloat, verticalOffset: CGFloat?) -> CGRect {
        let textSize = resolvedText.measure(in: CGSize(width: .max, height: .max))
        let x = if textSize.width < run.typographicBounds.rect.width {
            run.typographicBounds.rect.midX - (textSize.width / 2)
        } else {
            run.typographicBounds.origin.x - tracking
        }
        return CGRect(
            x: x,
            y: run.typographicBounds.rect.minY - (textSize.height * (verticalOffset ?? 1.1)),
            width: textSize.width,
            height: textSize.height
        )
    }
}

TextAttributeをセットするとText型ではなくなるというなんでだ!っていう動作もあるんですが、TextとTextAttributeを組み合わせて装飾を変えていけるというのは、AttributedStringとぶつかるところがある気もします。
SwiftUIでの文字装飾が今後どうなっていくのか気になります。

フレームサイズと描画の座標をごにょごにょすると縦書き風もできますが、句読点の位置や括弧類とかなどでうーん :thinking_face: みたいな感じになりました。
NSAttributedStringでは縦書きattributeがdeprecatedなのでなんとかならないかな後思ったのですがだめでした。