概要
ルビを振った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
}
}
UILabel
のattributedText
に設定するルビ付き文字列は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
}
}
基本的には、RubyLabel
をUIViewRepresentable
でラップするだけになります。
使うときに必要になるパラメータは全て引数としてセットできるようにしておくと便利です。
使うときにサイズ指定しなくても済むようにするためには以下の記述が必要になります。
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
)