Help us understand the problem. What is going on with this article?

Swiftでルビを表示させたい件について

どうでもいいこと

先日iOSDCに参加して以降ためこんでいた発信欲をそろそろ解放したいと思い、例によって備忘録でしかない内容をiOSエンジニア初心者の自分のために書きます。(ぜひ来年はLT枠とかで登壇したい...!)

Swiftでルビを表示させたい初心者へ

今回は仕事で少し触れそうなので、ルビ表示をSwiftで実装する、というテーマです。
ほとんど自分のためとはいえ、私のような初心者のためになることもあるのではという前向きな気持ちも抱きながら書いています。
以下の2つの記事を全面的に参考にさせていただきましたが、こちらで簡潔にまとめられている説明を、わざわざくどく、面倒くさく、まどろっこしく初心者向けにしたものがこの記事です。
(ルビ表示の実装が初心者に需要ある?という自分へのツッコミをスルーしながら書いてます。)

実装したコードがこちら

実装の主な方針は以下の通りです。

  • 前提として
    • 環境:Xcode10.3、Swift5
    • 青空文庫の表記(「紅玉《ルビー》」)をお借りしてルビ付文字のStringを表現
  • 正規表現を使ってString内のルビ付文字を特定 → StringExtension.swiftを参照
  • CTRubyAnnotationとNSAttributedStringを使ってルビを生成 → StringExtension.swiftを参照
  • カスタムUILabelクラスを作ってルビを表示 → RubyLabel.swiftとRubyViewController.swiftを参照

ではさっそく、正規表現を使ってルビ付文字を特定し、ルビを生成する実装です。

StringExtension.swift
import UIKit

extension String {
    // 文字列の範囲
    private var stringRange: NSRange {
        return NSMakeRange(0, self.utf16.count)
    }

    // 特定の正規表現を検索
    private func searchRegex(of pattern: String) -> NSTextCheckingResult? {
        do {
            let patternToSearch = try NSRegularExpression(pattern: pattern)
            return patternToSearch.firstMatch(in: self, range: stringRange)
        } catch { return nil }
    }

    // 特定の正規表現を置換
    private func replaceRegex(of pattern: String, with templete: String) -> String {
        do {
            let patternToReplace = try NSRegularExpression(pattern: pattern)
            return patternToReplace.stringByReplacingMatches(in: self, range: stringRange, withTemplate: templete)
        } catch { return self }
    }

    // ルビを生成
    func createRuby() -> NSMutableAttributedString {
        let textWithRuby = self
            // ルビ付文字(「|紅玉《ルビー》」)を特定し文字列を分割
            .replaceRegex(of: "(|.+?《.+?》)", with: ",$1,")
            .components(separatedBy: ",")
            // ルビ付文字のルビを設定
            .map { component -> NSAttributedString in
                // ベース文字(漢字など)とルビをそれぞれ取得
                guard let pair = component.searchRegex(of: "|(.+?)《(.+?)》") else {
                    return NSAttributedString(string: component)
                }
                let component = component as NSString
                let baseText = component.substring(with: pair.range(at: 1))
                let rubyText = component.substring(with: pair.range(at: 2))

                // ルビの表示に関する設定
                let rubyAttribute: [CFString: Any] =  [
                    kCTRubyAnnotationSizeFactorAttributeName: 0.5,
                    kCTForegroundColorAttributeName: UIColor.darkGray
                ]
                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
    }
}

ルビの表示に関する設定で出てきたコードに説明を加えます。

let rubyAttribute: [CFString : Any] =  [
    kCTRubyAnnotationSizeFactorAttributeName: // ベース文字(漢字など)に対するルビサイズの割合
    kCTForegroundColorAttributeName: // ルビの色
]
let rubyAnnotation = CTRubyAnnotationCreateWithAttributes(
    alignment: /* ベース文字に対するルビの整列形式を指定
               .auto = CoreTextが調整?(よくわかってないです...上手くやってくれるっぽい)
               .start = ベース文字と前を揃えて配置
               .center = ベース文字と中心を揃えて配置
               .end = ベース文字と後ろを揃えて配置
               .distributeLetter = ベース文字と前と後ろが揃うように均等配置
               .distributeSpace = ルビの文字間が均等になるように配置
               .lineEdge = ベース文字と前を揃えて文字間を詰めて配置 */
    overhang: /* ベース文字よりルビが長い場合のルビのはみ出し可否を指定
               .auto = ベース文字の前後へはみ出し可能
               .start = ベース文字の前へのはみ出しのみ可能
               .end = ベース文字の後ろへのはみ出しのみ可能
               .none = ベース文字からのはみ出し不可 */
    position: /* ベース文字に対するルビの位置を指定
               .before = ベース文字の上にルビを横表示
               .after = ベース文字の下にルビを横表示
               .inLine = ベース文字の後ろにルビを横表示
               .interCharacter = ベース文字の後ろにルビを縦表示 */
    string: // 表示するルビの文字列
    attributes: // 表示するルビの文字列に設定済みのattributes
)

これでルビの生成は終了です。

最後にUILabelをカスタムしてルビを表示させる実装です。

RubyLabel.swift
import UIKit

class RubyLabel: UILabel {
    // ルビを表示
    override func draw(_ 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 = self.attributedText else { return }
        let frame = CTFramesetterCreateFrame(
            CTFramesetterCreateWithAttributedString(attributedText),
            CFRangeMake(0, attributedText.length),
            CGPath(rect: rect, transform: nil),
            nil)

        // 描画に反映
        CTFrameDraw(frame, context)
    }
}

(グラフィックスコンテキスト周りの理解はまだまだ浅い...)
ルビ表示が途切れてしまう場合はUILabelのサイズを調整するなどします。

ルビが表示されました!

スクリーンショット 2019-09-29 19.02.43.png

参考までにViewController(UILabelは上記のカスタムクラスを継承)はこんな感じにしています。

RubyViewController.swift
import UIKit

class RubyViewController: UIViewController {
    @IBOutlet var rubyLabel: RubyLabel!

    override func viewDidLoad() {
        super.viewDidLoad()
        setUpLabel()
    }

    private func setUpLabel() {
        rubyLabel.attributedText = "|紅玉《ルビー》がほしい".createRuby()
        rubyLabel.textAlignment = .center
        rubyLabel.font = .systemFont(ofSize: 30.0)
    }
}
shintykt
人事 → iOSエンジニア → 色々エンジニア(メインはモバイルアプリ) / サウナ浴の日々、もはやサウナいらずの、そよ風でととのうくらいの境地が目標
https://github.com/shintykt
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした