[わ]

SwiftUIでルビをふる

おはようございます。waturaです。新しいmac miniがほしいなぁと思っているんですが,やっぱり、独立した画面ほしいよね。机の上にもう1セットキーボードとかおくのいやだよね。とかって考えると、ほしいのはノートパソコンでは?となっています。

note Mobile Tech Talk #1で発表した内容になります。

note Mobile Tech Talk #1 (2024/11/20 12:00〜) # 📝概要 note株式会社 AppチームによるモバイルアプリエンジニアLTイベントです。 noteのiOS / An pieceofcake.connpass.com

ルビをふりたい

ルビをふりたい noteでルビってどうやってふるんだろう?って検索しないといけないくらい、ルビの使い方がわからなかったんですが、noteでもちゃんとルビはふれるようです。

ルビ(ふりがな)をふる

アプリに入力補助がほしいな!と思いました。が閑話休題。

iOSアプリ上ではルビはふれるのだろうか?

**結論:ふれるけど、完璧とはいいがたい。**さらに、SwiftUIのみでルビをふる機能は2024年11月時点ではなさそうです。
表題が「SwiftUIでルビをふる」という記事なので、これで終了ですといきたいところですが,SwiftUIからUIKitなどを呼び出せるので,それをつかってルビをふるという方法を説明します。

まず、AttributedStringにはルビAttributeはないようです。NSAttributedStringにもルビAttributeはないようです。CoreTextまでおりてくるとルビAttributeがあります。

CTRubyAnnotationCreateWithAttributes(_:_:_:_:_:) | Apple Developer Documentation Creates an immutable ruby annotation object with the specifie developer.apple.com

CTRubyAnnotationCreateWithAttributes(::::_:)をつかうと

などが設定でき、ベースのフォントサイズに対する相対サイズでのサイズ指定や文字色の指定などができます。

このCTRubyAnnotationCreateの戻り値をNSAttributedStringのAttributeに指定してあげると、ルビ表示ができるようになります。

let rubyAttribute: [CFString: Any] = [
    kCTRubyAnnotationSizeFactorAttributeName: 0.5
]

let rubyAnnotation = CTRubyAnnotationCreateWithAttributes(
    .auto,
    .auto,
    .before,
    rubyText as CFString,
    rubyAttribute as CFDictionary
)

NSAttributedString(string: baseText, attributes: [
     kCTRubyAnnotationAttributeName as NSAttributedString.Key: rubyAnnotation
])

簡単!これでUITextViewとかでルビが表示できます!

これを、AttributedStringに変換してTextに渡してあげれば、SwiftUIでもルビが!って少し期待したのですが、だめでした。
AttributedStringにした時点で、AttributeからkCTRubyAnnotationAttributeNameが消失してしまうようでした。

SwiftUIアプリでルビを表示する

ざっくり、以下の4つの方法を試してみました。

残念なことにどの方法もちゃんとSwiftUIだよ!といいきれる実装ではなく、実際に実装している内容はUIKitだったりCoreTextだったりします。
残念です。

表示する文章

だれもが創作をはじめ、
続けられるようにする。

本来であれば、このnoteルビ記法をパースして表示できるようにしたほうがよかったのですが、簡単のためはぶいています。

func rubyAnnotation(text: String, ruby: String) -> NSAttributedString {
    let rubyAttribute: [CFString: Any] = [
        kCTRubyAnnotationSizeFactorAttributeName: 0.5
    ]
    let rubyAnnotation = CTRubyAnnotationCreateWithAttributes(
        .auto,
        .auto,
        .before,
        ruby as CFString,
        rubyAttribute as CFDictionary
    )

    return NSAttributedString(
        string: text,
        attributes:
            [
                kCTRubyAnnotationAttributeName as NSAttributedString.Key: rubyAnnotation
            ]
    )
}

var dummyAttributedString: NSAttributedString {
    let txt = NSMutableAttributedString(string: "だれもが")
    txt.append(rubyAnnotation(text: "創作", ruby: "そうさく"))
    txt.append(.init(string: "をはじめ、\n"))
    txt.append(rubyAnnotation(text: "続", ruby: "つづ"))
    txt.append(.init(string: "けられるようにする。"))
    
    // ルビが範囲外に表示されてしまうので行間を広げる
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.lineHeightMultiple = 1.5

    txt.addAttributes([
        .font: UIFont.systemFont(ofSize: 16),
        .paragraphStyle: paragraphStyle,
    ], range: .init(location: 0, length: txt.length))
  
    return txt
}

1. SwiftUIは諦めてUIViewControllerでUITextView/UILabelを使う

SwiftUIとUIKitは共存できます。なので、UIViewControllerで表示するというのも1手です。表示は以下のコードをAutoLayoutごにょごにょしたりして表示するだけです。
しかし、残念なことにただそのまま表示するだけではだめでした。

let view = UITextView()
view.attributedText = dummyAttributedString

単純に表示したパターン

1行目はともかく2行目が1行目とかぶってしまい読めなくなっています。
また、背景色を指定するとわかりやすいのですが1行目のふりがなも範囲外になってしまっています。

なので、NSAttributedStringを作るときに、lineHeightMultipleを指定するといい感じになります。

let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineHeightMultiple = 1.5

1行しかない場合は、TextView自体のcontentInsetやtextContainerInsetなどをいい感じに設定してあげると表示できます。こちらの指定だけだと、2行目以降でかぶってしまうので、ある程度以上のlineHeightMultipleが設定されている前提でのデザイン組がいいと思われます。

lineHeightMultipleを1.6に指定

2. UITextViewをUIViewRepresentableでくるんでSwiftUIから呼び出す

基本的にはUIViewControllerから呼び出すのと変わりません。

struct RubyTextView: UIViewRepresentable {
    func makeUIView(context _: Context) -> UITextView {
        let view = UITextView()
        view.attributedText = dummyAttributedString
        return view
    }

    func updateUIView(_: UITextView, context _: Context) {}
}

これをSwiftUIのScrollViewにいれるなどしたりするとおもいます。そのさいに表示される領域のサイズを考えたりとかっていうのが必要になってくるかとおもいます。

一応、別画像だよ

3. Canvas

CanvasはSwiftUIでGraphicsContextを扱うためのViewです。GraphicsContextは2Dのお絵書きをするためのstructです。GraphicsContextはCoreTextをつかった描画ができます。

Canvas | Apple Developer Documentation A view type that supports immediate mode drawing. developer.apple.com

GraphicsContext | Apple Developer Documentation An immediate mode drawing destination, and its current state. developer.apple.com

すなわち、kCTRubyAnnotationAttributeNameがちゃんと仕事をするのです!また、Textをつかった描画もできます。

注意点としては、
Core Graphicsの座標系(左上が原点)をCore Textの座標系(左下が原点)なので、座標を変換する必要があります。

context.scaleBy(x: 1, y: -1)
context.translateBy(x: 0, y: -size.height)

また、Canvasをスクロールする場合にはScrollViewでframeをいい感じにする必要があったりするので,めっちゃ楽っす!!!っていうかんじまではいきません。

kCTRubyAnnotationがCoreTextの機能であるため,1や2のようにLineHeightを調製しないと表示がくずれるとかはありません。

CanvasだとLineHeightの調整がいらない

4. TextRenderer

TextRendererはiOS 18から使えるようになったTextRendererというものがあります。Textのレンダリング時に介入できる機能になります。

TextRenderer | Apple Developer Documentation A value that can replace the default text view rendering beha developer.apple.com

レンダリングに介入できる→本来表示する文字の上にルビもついでにレンダリングしてあげたらいいんじゃない???という考えでやってみました。

TextRendererは別記事を書いています。アドベントカレンダーとかそういう系で出したいなぁって思っています。

その他思いついた方法

一つ目はSwiftUIを信じていくとい点で、SwiftUIでも同じようなことができるしまあ、いいかっていうかんじでやっていません。

気合いのText地獄

LazyVStack {
    HStack(alignment: .bottom, spacing: 0) {
        Text("だれもが")
        VStack(spacing: -2) {
            Text("そうさく")
                .font(.caption2)
            Text("創作")
        }
        Text("をはじめ、")
    }
    HStack(alignment: .bottom, spacing: 0) {
        VStack(spacing: -2) {
            Text("つづ")
                .font(.caption2)
            Text("続")
        }
        Text("けられるようにする。")
    }
}

このコードをClaudeになげていろいろ相談していたら、CoreTextもUIKitもつかわないで、それっぽく動くものが出来てしまいました。

改行ができなかった

↑の雑Textの組みあわせたくらいで、ほとんどClaudeがつくって私はコピペ・デバッグ係に徹した出力結果です。表示している文言もClaudeがつくったよ!普通に嘘かいてあるよ!!!

Claudeさんがつくったnote活用ガイド

まとめ

UIViewController, UIViewRepresentable

Canvas

TextRenderer

新しく作るならば,SwiftUIをメインでやっていきたいなぁと思っているのでUIViewControllerやUIViewRepresentableではなくCanvasかClaude先生が生み出したものをより深掘っていこうかと思っています。

ソースコード

最後のClaude先生に作ってもらったもの以外のサンプルコードです。

#note