【制作メモ】Kotlinで縦書き複数行対応のTextView作成!

結局複数行に対応した縦書きTextVIewが必要になった、、

どうでもいいですが、縦書きの場合って1行、2行じゃなくて、1列、2列って言わないといけないのかとか思ってモヤモヤしています。
いやでも縦書きでも普通に「1行目」とか言いますよね。
ただ昔、横は「行」で縦は「列」、覚え方は漢字の作りを見て判断するみたいな話があったような気がします。
まあ、その辺はどうでもいいですね。

失礼しました。

本題です。
前回投稿した縦書きTextVIewクラスですが、あれは1列のみに対応していましたが、そもそも今回使うにはやはり複数行(複数列)に対応する必要があることが発覚。
再びチャットGPTさんと協議しながら、以下のクラスを作成することとなりました。

■縦書き複数行クラスの前提条件
・座標(開始位置)を指定して縦書きTextViewを作りたかった。
・指定位置(サンプルではBR)で改行させたい
・行間を少し詰めたい
・オリジナルフォントを読み込んで縦書きにしたい
・特定文字で位置を調整できるようにしたい(フォント用)
・仕様したオリジナルフォントを縦書きにするとひらがな、カタカナが少しずれるので1文字ずつ中央合わせで表示

■縦書き複数行クラス

package com.kodama.machimikujiapp

import android.content.Context
import android.graphics.Canvas
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import android.graphics.Typeface

class VerticalTextViewX : AppCompatTextView {

    private val textPaint = TextPaint()
    private val customTypeface: Typeface?

    constructor(context: Context, x: Float, y: Float, customTypeface: Typeface?) : super(context) {
        this.x = x
        this.y = y
        this.customTypeface = customTypeface
        init()
    }

    private fun init() {
        textPaint.color = this.currentTextColor
        textPaint.textSize = this.textSize
        if (customTypeface != null) {
            this.typeface = customTypeface
        }
    }

    // テキストの高さを正確に計算するメソッド
    private fun calculateTextHeight(): Int {
        val textWithAdjustedPunctuation = adjustPunctuation(text.toString())
        val staticLayout = StaticLayout.Builder
            .obtain(textWithAdjustedPunctuation, 0, textWithAdjustedPunctuation.length, textPaint, height)
            .setAlignment(Layout.Alignment.ALIGN_CENTER)
            .setLineSpacing(lineSpacingExtra, 0.8f) //行間を調整、0.8にする。
            .setIncludePad(false)
            .build()

        return staticLayout.height
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val textWithAdjustedPunctuation = adjustPunctuation(text.toString())
        val desiredWidth = textPaint.measureText(textWithAdjustedPunctuation).toInt()
        val desiredHeight = calculateTextHeight()

        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        val heightSize = MeasureSpec.getSize(heightMeasureSpec)

        val width: Int
        val height: Int

        // Measure Width
        width = when (widthMode) {
            MeasureSpec.EXACTLY -> widthSize
            MeasureSpec.AT_MOST -> minOf(desiredWidth, widthSize)
            else -> desiredWidth
        }

        // Measure Height
        height = when (heightMode) {
            MeasureSpec.EXACTLY -> heightSize
            MeasureSpec.AT_MOST -> minOf(desiredHeight, heightSize)
            else -> desiredHeight
        }

        setMeasuredDimension(width, height)
    }


    override fun onDraw(canvas: Canvas) {
        canvas.save()

        // 縦書きの場合は行間を設定するとより自然に見えます
        val lineHeight = textSize + lineSpacingExtra

        // テキストに対して特定の文字に対して位置調整を行った後の文字列を取得
        val textWithAdjustedPunctuation = adjustPunctuation(text.toString())



        // 以下の行を追加してtextPaintにフォントを設定する
        if (customTypeface != null) {
            textPaint.typeface = customTypeface
        }

        var x = 0f // テキストのx座標の初期値を設定
        var y = 0f // テキストのy座標の初期値を設定

        // テキストの各行に対して処理を行う
        for (line in textWithAdjustedPunctuation.split("BR")) {
            // 各文字を1文字ずつ縦書きで描画

            for (i in 0 until line.length) {
                val character = line[i].toString() // テキストの1文字を取得
                val textWidth = textPaint.measureText(character) // テキストの幅を計測

                // テキストの1文字の中央に描画するための y 座標を計算
               // val lineBaseline = layout.getLineBaseline(0).toFloat()

                // テキストの1文字の中央に描画するための x 座標を計算
                val centerX = (width - textWidth) / 2
                val centerY = (height + textWidth) / 2
                // 特定の文字の場合は位置を右上にする
                if (character == "、" || character == "。") {
                    // 1文字をキャンバス上に描画
                    val offsetY = -lineHeight / 2 // さらに上に移動させる分のオフセット値
                    canvas.drawText(character, -x + centerX + textWidth / 2 , y + centerY + offsetY  , textPaint)

                } else {
                    // 1文字をキャンバス上に描画
                    canvas.drawText(character, -x + centerX, y + centerY , textPaint)
                }

                y += lineHeight // 次の文字の描画位置を調整
            }

            y = 0f // 次の行の描画位置の初期値を0にリセット
            x += lineHeight // 次の文字の描画位置を1文字分左にずらす
        }
        canvas.restore()
    }

    private fun adjustPunctuation(text: CharSequence): String {
        // テキストの前後に[ ]を追加して改行の有無を確認する
        val adjustedText = text.toString()

        return adjustedText
    }


}

で、このクラスを呼び出す時は以下です。

        val customFontResourceId = R.font.apricotjapanesefont 
        val customTypeface = ResourcesCompat.getFont(this, customFontResourceId)

        val verticalTextView = VerticalTextView(this, 500f, 200f, customTypeface)
        verticalTextView.text = "こだまみくじでBR毎日にちょっとだけBR大吉を♪"
        val parentLayout = findViewById<LinearLayout>(R.id.parent_layout)
        parentLayout.addView(verticalTextView)

捨て仮名(小文字)対応編 2023.08.08追記

「ぁ」とか「ャ」とかは縦書きにした際に位置の調整が必要になることがわかりました。
あと、場所によって行間、文字サイズを変更したかったので、その辺を改善しました。
今回はその辺を改善したコードを貼っておきます。
おそらく指定するフォトによって微調整は必要・・・なはず。
そのまま使えるかは謎ですが、Kotlin使用時の参考になれば幸いです。


import android.content.Context
import android.graphics.Canvas
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import android.graphics.Typeface
import android.view.ViewGroup

class VerticalTextView : AppCompatTextView {

    private var textPaint = TextPaint()
    private var customTypeface: Typeface? = null
    private var textSize: Float = 0f
    // コンストラクタで受け取った横幅と高さを保持するための変数
    private var desiredWidth: Float = 0f
    private var desiredHeight: Float = 0f
    private var textWithAdjustedPunctuation: String = ""
    private var lineSpacingExtraDp: Float = 0f
    constructor(context: Context, x: Float, y: Float, customTypeface: Typeface?, textSize: Float, width: Int, height: Int, lineSpacingExtraDp: Float)
            : super(context) {
        this.x = x
        this.y = y
        this.customTypeface = customTypeface
        this.textSize = textSize

        // Convert dp to pixels using dpToPx() method
        this.lineSpacingExtraDp = lineSpacingExtraDp
        // Set the layout parameters including the calculated lineSpacingExtra
        this.layoutParams = ViewGroup.LayoutParams(width, height)

        init()
    }

    private fun init() {
        textPaint.color = this.currentTextColor
        textPaint.textSize = textSize
        if (customTypeface != null) {
            this.typeface = customTypeface
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // あらかじめ指定した横幅と高さを使用する
        val desiredWidth = this.layoutParams.width
        val desiredHeight = this.layoutParams.height




        setMeasuredDimension(desiredWidth, desiredHeight) // View の幅と高さを設定
       // setMeasuredDimension(1520, 870)
    }
    
    override fun onDraw(canvas: Canvas) {
        canvas.save()


        // 文字間を狭める(-0.1fを調整して適宜変更してください)
        textPaint.letterSpacing = 0.4f // 負の値で文字間を狭くする


        // 行間を調整する(lineSpacingExtraを適宜変更してください)
        val lineSpacingExtra = dpToPx(lineSpacingExtraDp) // 行間を10dpに設定
        val fontSpacingExtra = dpToPx(-2.40f) // 行間を10dpに設定

        // 縦書きの場合は行間を設定するとより自然に見えます
        val lineHeight = textSize + lineSpacingExtra
        val textHeight = textSize + fontSpacingExtra
        // テキストに対して特定の文字に対して位置調整を行った後の文字列を取得
        val textWithAdjustedPunctuation = adjustPunctuation(text.toString())

        // 以下の行を追加してtextPaintにフォントを設定する
        if (customTypeface != null) {
            textPaint.typeface = customTypeface
        }

        var x = 0f // テキストのx座標の初期値を設定
        var y = 0f // テキストのy座標の初期値を設定

        // 特殊文字のリストを定義
        val specialCharacters = setOf("、", "。", "【", "】", "ー", "っ", "ッ", "ァ", "ィ", "ゥ", "ェ", "ォ", "ャ", "ュ", "ョ", "ヮ", "ぁ", "ぃ", "ぅ", "ぇ", "ぉ", "ゃ", "ゅ", "ょ")
        

        // テキストの各行に対して処理を行う
        for (line in textWithAdjustedPunctuation.split("BR")) {
            // 各文字を1文字ずつ縦書きで描画

            for (i in 0 until line.length) {
                val character = line[i].toString() // テキストの1文字を取得
                val textWidth = textPaint.measureText(character) // テキストの幅を計測

                val textSizeDifference = (textSize - textWidth) / 2 // サイズの差分を計算
                
                // フォントによってcenterYは調整が必要
                val centerX = (this.layoutParams.width - textWidth)
                val centerY = textWidth

                // 特殊文字の場合は位置を右上にする
                if (character in specialCharacters) {
                    val offsetY = -textWidth / 2 // さらに上に移動させる分のオフセット値
                    val pivotX = -x + centerX
                    val pivotY = y + centerY

                    if (character == "【") {
                        // 【の場合は回転処理を行う
                        canvas.save()
                        canvas.rotate(90f, pivotX, pivotY) // View の左上を中心に時計回りに90度回転させる
                        canvas.drawText(character, -x + centerX - textWidth / 6 * 4, y + centerY + offsetY/ 4 * 3  , textPaint)
                        canvas.restore()
                    } else if (character == "】") {
                        // 】の場合も回転処理を行う
                        canvas.save()
                        canvas.rotate(90f, pivotX, pivotY ) // View の左上を中心に反時計回りに90度回転させる
                        canvas.drawText(character, -x + centerX - textWidth / 5 * 4  , y + centerY + offsetY/ 4 * 3      , textPaint)
                        canvas.restore()
                    }  else if (character == "ー") {
                    // 小さい「ー」の場合も回転処理を行う
                    canvas.save()
                    canvas.rotate(90f, pivotX, pivotY ) // View の左上を中心に反時計回りに90度回転させる
                    canvas.drawText(character, -x + centerX  + offsetY / 2 * 3    , y + centerY + offsetY / 4 * 3      , textPaint)
                    canvas.restore()
                }
                    else if (character == "。" || character == "、") {
                        // 小さい「ー」の場合も回転処理を行う


                        canvas.drawText(character, -x + centerX + textWidth / 2 , y + centerY + offsetY / 3, textPaint)
                    }
                else {
              // 通常の文字は回転せずに描画
                        canvas.drawText(character, -x + centerX + textWidth / 4 , y + centerY + offsetY / 4, textPaint)

                    }

                } else {
                    // 1文字をキャンバス上に描画
                    canvas.drawText(character, -x + centerX - textSizeDifference, y + centerY , textPaint)
                }

                y += textHeight // 次の文字の描画位置を調整
            }

            y = 0f // 次の行の描画位置の初期値を0にリセット
            x += lineHeight // 次の文字の描画位置を1文字分左にずらす
        }
        canvas.restore()
    }

    private fun dpToPx(dp: Float): Int {
        val scale = resources.displayMetrics.density
        return (dp * scale + 0.5f).toInt()
    }

    private fun adjustPunctuation(text: CharSequence): String {
        // テキストの前後に[ ]を追加して改行の有無を確認する
        val adjustedText = text.toString()

        return adjustedText
    }


}
           // 縦書きテキストサイズを計算
            val scaledDensity = context.resources.displayMetrics.scaledDensity
            val textSize = 18f * scaledDensity // 16sp として指定

            val vTextView_listname = VerticalTextView(
                context,
                x = 0f,
                y = -30f,//Y軸のずれを修正(フォント毎に変わりそう)
                customTypeface, // 任意のフォントを指定
                textSize = textSize, // テキストサイズを指定
                width = 300.dpToPx(), // 横幅をdpからピクセルに変換して指定
                height = 125.dpToPx(), // 縦幅をdpからピクセルに変換して指定
                lineSpacingExtraDp =6.0f
            )

            // 縦書きテキストを設定
            val listname = "街みくじでBRあなたの行く場所にBR大吉を♪"
            vTextView_listname.text = listname

            // 縦書きテキストを追加する
            val machiMikjiList = findViewById<FrameLayout>(R.id.MachiMikjiList)

            machiMikjiList.addView(vTextView_listname)

以上です。
本件で何かありましたら「@kodamabiyori」までご連絡ください!
縦書きTextViewが早く公式対応されることを期待しつつ、本件はこれにて終了です。
(まあ需要がないので無理だと思いますが)