LoginSignup
3
4

More than 1 year has passed since last update.

SwiftUIでルビを振るために必要なこと

Posted at

概要

ルビを振ったUILabelをSwiftUIから利用する実装についてまとめました。

【参考】
https://qiita.com/mlmlykt/items/c8a09d6801b5aac7f24e
https://qiita.com/negi0205/items/6c73128ff2cf680df47c
https://qiita.com/woxtu/items/284369fd2654edac2248

ルビを振ったNSAttributedStringの生成

extension String {
    func createRuby(color: UIColor = .black) -> NSAttributedString {
        let textWithRuby = replacingOccurrences(of: "(|.+?《.+?》)", with: ",$1,", options: .regularExpression)
            .components(separatedBy: ",")
            .map { component -> NSAttributedString in
                guard component.range(of: "|(.+?)《(.+?)》", options: .regularExpression, range: nil, locale: nil) != nil else {
                    return NSAttributedString(string: component)
                }

                let baseText = component.replacingOccurrences(of: "|(.+?)《.+?》", with: "$1", options: .regularExpression)
                let rubyText = component.replacingOccurrences(of: "|.+?《(.+?)》", with: "$1", options: .regularExpression)

                let rubyAttribute: [CFString: Any] = [
                    kCTRubyAnnotationSizeFactorAttributeName: 0.5,
                    kCTForegroundColorAttributeName: color
                ]
                // 必要に応じて属性を設定する
                let rubyAnnotation = CTRubyAnnotationCreateWithAttributes(
                    .auto, .auto, .before, rubyText as CFString, rubyAttribute as CFDictionary
                )

                return NSAttributedString(string: baseText,
                                          attributes: [kCTRubyAnnotationAttributeName as NSAttributedString.Key: rubyAnnotation])
            }
            .reduce(NSMutableAttributedString()) {
                $0.append($1)
                return $0
            }

        return textWithRuby
    }
}

UILabelattributedTextに設定するルビ付き文字列はCTRubyAnnotationCreateWithAttributesを使ったNSAttributedStringによって実現できます。
CTRubyAnnotationCreateWithAttributesについては公式ドキュメントか上記の参考記事あたりを見ながら必要に応じて設定してください。
調べてみると参考記事ではNSRegularExpressionを使うコードが多いようですが、普通にStringから該当箇所を抽出するコードの方が新しい書き方になるかなと思ってそのように修正してあります。
ルビの振り方のルールについては参考記事を踏襲してあります。

ルビを振ったUILabelの生成

class RubyLabel: UILabel {
    override func drawText(in rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        context.translateBy(x: 0, y: rect.height)
        context.scaleBy(x: 1.0, y: -1.0)

        guard let attributedText = attributedText else { return }
        let frame = CTFramesetterCreateFrame(
            CTFramesetterCreateWithAttributedString(attributedText),
            CFRangeMake(0, attributedText.length),
            CGPath(rect: rect, transform: nil),
            nil
        )
        CTFrameDraw(frame, context)
    }

    override var intrinsicContentSize: CGSize {
        let size = super.intrinsicContentSize
        let intrinsicContentWidth = size.width
        guard let attributedText = attributedText else {
            let newHeight = size.height * 1.5
            return CGSize(width: intrinsicContentWidth, height: newHeight)
        }
        // 適切な高さになるように計算する
        let setter = CTFramesetterCreateWithAttributedString(attributedText)
        let range = CFRange()
        let constraints = CGSize(width: intrinsicContentWidth, height: CGFloat.greatestFiniteMagnitude)
        let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(setter, range, nil, constraints, nil)
        return CGSize(width: intrinsicContentWidth, height: frameSize.height)
    }
}

参考記事では説明がないですが、以下のコード部分については説明が必要かなと思うので説明しておきます。

context.translateBy(x: 0, y: rect.height)
context.scaleBy(x: 1.0, y: -1.0)

CGRectの公式ドキュメントを読むと、Core Graphicsの座標系はデフォルトでは原点が左下であるという記載があります。
しかし、ご存じの通りiOSの世界(UIKit)では通常座標系の原点は左上になります。
つまり、UILabelを継承してdrawする処理において、UIGraphicsGetCurrentContextで得られたCGContextは左上が原点の座標系になると思われます。
一方で、CTFrameDrawでの描画では特に何の指定もなければiOSの世界に紐づくわけでもないので、左下が原点の座標系になります。
なので、CTFrameDrawを使ってCore Textを描画する場合はy軸を反転させる必要がある、ということから上記のコードが必要になると思われます。
参考記事:https://qiita.com/wheel/items/a26f7cb1464aff60940a

続いて、intrinsicContentSizeを算出する処理が必要になります。
参考記事ではルビを振ったUILabelの高さを1.5倍の高さにしているコードしか見当たらなかったのですが、これはkCTRubyAnnotationSizeFactorAttributeNameで指定したルビの高さ分だけ高くしていると思われます。
しかし、単に1.5倍の高さにしてしまうと、複数行あるテキストのうち1行だけルビが振ってあるようなケースで高さ計算が狂ってしまいます。
そこでintrinsicContentSizeは以下のコードによって適切な高さを算出して返却するようにしています。

let setter = CTFramesetterCreateWithAttributedString(attributedText)
let range = CFRange()
let constraints = CGSize(width: intrinsicContentWidth, height: CGFloat.greatestFiniteMagnitude)
let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(setter, range, nil, constraints, nil)
return CGSize(width: intrinsicContentWidth, height: frameSize.height)

CTFramesetterSuggestFrameSizeWithConstraintsについてはあまり参考になる記事もなかったので、公式のドキュメントを見るのが一番わかりやすいと思います。

framesetter: frameの測定対象となるsetter
stringRange: 適用される文字列の範囲範囲の長さが0の場合はテキストまたはスペースがなくなるまで行を追加し続けるなので全文適用したい場合は0指定しておけばよさそう
frameAttributes: frameの塗りつぶしプロセスを制御する追加属性
constraints: frameのサイズ制約width/heightのどちらかにCGFLOAT_MAXを設定すれば制約なしとして扱われると記載があるがCGFLOAT_MAXはdeprecatedなのでCGFloat.greatestFiniteMagnitudeを使う
fitRange: ポインタで返ってくる返り値実際に制約されたサイズに収まる文字列の範囲が返却される

ルビを振ったSwiftUIのViewの生成

struct RubyLabelRepresentable: UIViewRepresentable {
    let attributedText: NSAttributedString
    let font: UIFont
    let textColor: UIColor
    let textAlignment: NSTextAlignment

    private let rubyLabel = RubyLabel()

    func makeUIView(context: Context) -> UILabel {
        rubyLabel.numberOfLines = 0
        rubyLabel.setContentHuggingPriority(.required, for: .horizontal)
        rubyLabel.setContentHuggingPriority(.required, for: .vertical)
        return rubyLabel
    }

    func updateUIView(_ uiView: UILabel, context: Context) {
        rubyLabel.attributedText = attributedText
        rubyLabel.font = font
        rubyLabel.textColor = textColor
        rubyLabel.textAlignment = textAlignment
    }
}

基本的には、RubyLabelUIViewRepresentableでラップするだけになります。
使うときに必要になるパラメータは全て引数としてセットできるようにしておくと便利です。
使うときにサイズ指定しなくても済むようにするためには以下の記述が必要になります。

rubyLabel.setContentHuggingPriority(.required, for: .horizontal)
rubyLabel.setContentHuggingPriority(.required, for: .vertical)

この記述はViewの大きくなりにくさを示すPriorityで、私の場合は縦横ともに可能な限り自身のサイズを優先するように指定しています。
あとはnumberOfLinesを0に設定しておけば複数行にも対応できていい感じです。
このViewを使う場合は以下のように呼び出せば利用可能です。

RubyLabelRepresentable(
  attributedText: "|試《ため》しの|文字列《もじれつ》".createRuby(),
  font: .systemFont(ofSize: 24),
  textColor: .black,
  textAlignment: .center
)
3
4
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
4