Cover Image for 続:Swift UI で青空文庫形式でルビが振られた文章を縦書きで表示する (横スクロール対応版)

続:Swift UI で青空文庫形式でルビが振られた文章を縦書きで表示する (横スクロール対応版)

概要

Swift UI で青空文庫形式でルビが振られた文章を縦書きで表示する で正常に表示できたかに見えたが、実は文章が長いと画面外にはみ出た領域が切れていた。 画面外にはみ出たものはスクロールできるように修正した。

出来上がったもの

プレビュー用のサンプルは 吾輩は猫である 冒頭の一文

基本的な部分は前回から変わっていない。 変更点は以下

  • 画面外にはみ出た部分にスクロールさせるために、ScrollViewCanvas を囲む
  • それだけだと左端始まりの右スクロールになってしまうので、 ScrollViewReader で囲んで onAppear のタイミングで右端までスクロールさせておく
  • ScrollView 内にスクロールできる形で Canvas を描画するには、frame にサイズ指定が必要
  • CTFramesetterSuggestFrameSizeWithConstraintsCTFramesetterCreateFrame に与えているのと同じ描画に必要な情報を渡して事前に 描画サイズを把握しておく。その上で、 Canvasframe にサイズを指定する必要がある
import SwiftUI
import UIKit


extension String {

    private func createRuby(string: String, ruby: String, textAttributes: [NSAttributedString.Key: Any]) -> NSAttributedString {
        var unmanage = Unmanaged.passRetained(ruby as CFString)
        defer { unmanage.release() }
        var text: [Unmanaged<CFString>?] = [unmanage, .none, .none, .none]
        let annotation = CTRubyAnnotationCreate(.auto, .auto, 0.5, &text)
        let attributedString = NSMutableAttributedString(string: string,
                                                         attributes: [kCTRubyAnnotationAttributeName as NSAttributedString.Key: annotation])
        attributedString.addAttributes(textAttributes, range: NSRange(location: 0, length: string.count))
        return attributedString
    }

    func createRubyText(
        font: UIFont = UIFont(name: "HiraMinProN-W3", size: 18.0)!,
        textColor: UIColor = .black
    ) -> NSAttributedString {

        let style = NSMutableParagraphStyle()
        style.minimumLineHeight = 24
        style.maximumLineHeight = 24

        let textAttributes: [NSAttributedString.Key: Any] = [
                .font: font,
                .verticalGlyphForm: true,
                .paragraphStyle: style,
                .foregroundColor: textColor,
        ]

        let workingAttributedText = NSMutableAttributedString(string: self, attributes: textAttributes)

        // 名前付き正規表現は iOS11+ じゃないと無理です
        let rubyRegex = try! NSRegularExpression(pattern: "\\|(?<string>.+?)《(?<ruby>.+?)》", options: [])

        for result in rubyRegex.matches(in: self, options: [], range: NSRange(location: 0, length: self.count)).reversed() {
            guard
                let stringRange = Range(result.range(withName: "string"), in: self),
                let rubyRange = Range(result.range(withName: "ruby"), in: self)
                else {
                continue
            }

            let string = String(self[stringRange])
            let ruby = String(self[rubyRange])
            workingAttributedText.replaceCharacters(in: result.range,
                                                    with: createRuby(string: string, ruby: ruby, textAttributes: textAttributes))
        }

        return workingAttributedText
    }
}

struct CTFramesetterContext {
    var framesetter: CTFramesetter
    var stringRange: CFRange
    var frameAttributes: CFDictionary
    var constraints: CGSize

    static func createFrom(attrString: CFAttributedString, hight: CGFloat) -> CTFramesetterContext {
        let setter = CTFramesetterCreateWithAttributedString(attrString)
        let frameAttrs = [
            kCTFrameProgressionAttributeName: CTFrameProgression.rightToLeft.rawValue,
        ] as CFDictionary
        let stringRange = CFRangeMake(0, 0)

        let restrictSize = CGSize(width: CGFloat.greatestFiniteMagnitude,
                                  height: hight)

        return CTFramesetterContext(framesetter: setter, stringRange: stringRange, frameAttributes: frameAttrs, constraints: restrictSize)
    }
}

struct TategakiText: View {
    var text: String
    var hight: CGFloat
    private var framesetterContext: CTFramesetterContext
    private var boundingBox: CGSize

    init(text: String, hight: CGFloat = 700) {
        self.text = text
        self.hight = hight
        self.framesetterContext = CTFramesetterContext.createFrom(attrString: text.createRubyText(), hight: hight)

        let (setter, frameAttrs, stringRange, restrictSize) = (self.framesetterContext.framesetter, self.framesetterContext.frameAttributes, self.framesetterContext.stringRange, self.framesetterContext.constraints)

        self.boundingBox = CTFramesetterSuggestFrameSizeWithConstraints(
            setter, stringRange, frameAttrs, restrictSize, nil)
    }

    var body: some View {
        ScrollViewReader { proxy in
            ScrollView(.horizontal, showsIndicators: true) {
                Canvas { context, size in
                    context.withCGContext(content: { cgContext in
                        draw(context: cgContext, size: size)
                    })
                }
                    .frame(width: boundingBox.width, height: hight)
                    .id("canvas")
            }.onAppear {
                proxy.scrollTo("canvas", anchor: .trailing)
            }
        }
    }

    func draw(context: CGContext, size: CGSize) {
        context.scaleBy(x: 1, y: -1)
        context.translateBy(x: 0, y: -size.height)

        let (setter, frameAttrs, stringRange, restrictSize) = (self.framesetterContext.framesetter, self.framesetterContext.frameAttributes, self.framesetterContext.stringRange, self.framesetterContext.constraints)

        let path = CGPath(rect: CGRect(origin: CGPointZero, size: size), transform: nil)
        let ct = CTFramesetterCreateFrame(setter, stringRange, path, frameAttrs)
        CTFrameDraw(ct, context)
    }
}

#Preview {
    TategakiText(text: """
 |吾輩《わがはい》は猫である。名前はまだ無い。
 どこで生れたかとんと|見当《けんとう》がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番|獰悪《どうあく》な種族であったそうだ。この書生というのは時々我々を|捕《つかま》えて|煮《に》て食うという話である。しかしその当時は何という考もなかったから別段恐しいとも思わなかった。ただ彼の|掌《てのひら》に載せられてスーと持ち上げられた時何だかフワフワした感じがあったばかりである。掌の上で少し落ちついて書生の顔を見たのがいわゆる人間というものの|見始《みはじめ》であろう。この時妙なものだと思った感じが今でも残っている。第一毛をもって装飾されべきはずの顔がつるつるしてまるで|薬缶《やかん》だ。その|後《ご》猫にもだいぶ|逢《あ》ったがこんな|片輪《かたわ》には一度も|出会《でく》わした事がない。のみならず顔の真中があまりに突起している。そうしてその穴の中から時々ぷうぷうと|煙《けむり》を吹く。どうも|咽《む》せぽくて実に弱った。これが人間の飲む|煙草《たばこ》というものである事はようやくこの頃知った。
 この書生の掌の|裏《うち》でしばらくはよい心持に坐っておったが、しばらくすると非常な速力で運転し始めた。書生が動くのか自分だけが動くのか分らないが|無暗《むやみ》に眼が廻る。胸が悪くなる。|到底《とうてい》助からないと思っていると、どさりと音がして眼から火が出た。それまでは記憶しているがあとは何の事やらいくら考え出そうとしても分らない。
 ふと気が付いて見ると書生はいない。たくさんおった兄弟が一|疋《ぴき》も見えぬ。|肝心《かんじん》の母親さえ姿を隠してしまった。その上|今《いま》までの所とは違って|無暗《むやみ》に明るい。眼を明いていられぬくらいだ。はてな何でも|容子《ようす》がおかしいと、のそのそ|這《は》い出して見ると非常に痛い。吾輩は|藁《わら》の上から急に笹原の中へ棄てられたのである。
 ようやくの思いで笹原を這い出すと向うに大きな池がある。吾輩は池の前に坐ってどうしたらよかろうと考えて見た。別にこれという|分別《ふんべつ》も出ない。しばらくして泣いたら書生がまた迎に来てくれるかと考え付いた。ニャー、ニャーと試みにやって見たが誰も来ない。そのうち池の上をさらさらと風が渡って日が暮れかかる。腹が非常に減って来た。泣きたくても声が出ない。仕方がない、何でもよいから|食物《くいもの》のある所まであるこうと決心をしてそろりそろりと池を|左《ひだ》りに廻り始めた。どうも非常に苦しい。そこを我慢して無理やりに|這《は》って行くとようやくの事で何となく人間臭い所へ出た。ここへ|這入《はい》ったら、どうにかなると思って竹垣の|崩《くず》れた穴から、とある邸内にもぐり込んだ。縁は不思議なもので、もしこの竹垣が破れていなかったなら、吾輩はついに|路傍《ろぼう》に|餓死《がし》したかも知れんのである。一樹の蔭とはよく|云《い》ったものだ。この垣根の穴は|今日《こんにち》に至るまで吾輩が|隣家《となり》の三毛を訪問する時の通路になっている。さて|邸《やしき》へは忍び込んだもののこれから先どうして|善《い》いか分らない。
""")
}