見出し画像

SwiftUIでルビをふる

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

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


ルビをふりたい

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

ルビ(ふりがな)をふる

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

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

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

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

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は諦めてUIViewControllerでUITextView/UILabelを使う

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

  • Canvasを使う

  • TextRendererを使う

残念なことにどの方法もちゃんと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をつかった描画ができます。

すなわち、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は別記事を書いています。アドベントカレンダーとかそういう系で出したいなぁって思っています。

その他思いついた方法

  • UIViewControllerの中でCoreTextを使う

  • 気合いのText地獄

一つ目は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

  • SwiftUIじゃないのがいやだよね

  • LineHeightとかの調整が必要

  • SwiftUIアプリだったら、SwiftUI向けにつくった資産が使えない

  • UITextView/UILabelだからみんないろいろ知見もってるよね!

  • UIKit画面としてつくるなら一番いいのでは?

  • ScrollViewに埋め込むときの対応

Canvas

  • CoreGraphics由来なので高度な描画機能が使える

  • CoreGraphics由来なのでルビ表示がきれいにできる

  • 座標系がかわってしまうので、ややこしい時がある

  • ScrollViewに埋め込むときの対応

  • CoreGraphicsようわからん問題

TextRenderer

  • 他にも面白いことができそう

  • 下書きにはTextRendererの記事が控えている

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

ソースコード

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