Android TextView で複数行の Ellipsize に対応する (Multiline Ellipsize)

Android TextView の Ellipsize は表示テキストが1行である場合のみ動作します。
2行以上のテキスト表示で Ellipsize も動作させるには TextView のカスタム実装が必要になります。

## 実装

```kotlin
class MultilineEllipsizingTextView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
    private var originalText: CharSequence = ""
    private var _ellipsize: TextUtils.TruncateAt? = null
    override fun setText(text: CharSequence?, type: BufferType?) {
        originalText = text ?: ""
        super.setText(text, type)
        doOnNextLayout {
            val layout = this.layout
            val ellipsize = this.ellipsize
            if (layout != null && ellipsize != null && maxLines in 1 until lineCount) {
                val avail = (0 until maxLines).map { line ->
                    // 行末が \n である
                    val hasNewLine = (originalText[layout.getLineVisibleEnd(line)] == "\n"[0])
                    val newLineWidth = FloatArray(1).apply {
                        layout.paint.getTextWidths("\n", this)
                    }[0]
                    val lastLine = (line == (maxLines - 1))
                    layout.getLineMax(line) + when {
                        // 該当行が改行で終わる場合は \n の width を足す
                        hasNewLine && !lastLine -> newLineWidth
                        // 最終行が改行で終わる場合は \n の width に余裕を持たせて足す
                        // ここで余裕を持たせると \n が … に置き換えられる
                        // 余裕を持たせない場合は \n の手前の文字が … に置き換えられる
                        hasNewLine && lastLine -> newLineWidth * 2
                        else -> 0f
                    }
                }.sum()
                super.setText(TextUtils.ellipsize(originalText, paint, avail, ellipsize), type)
            }
        }
    }

    override fun getText(): CharSequence {
        return originalText
    }

    override fun setEllipsize(where: TextUtils.TruncateAt?) {
        _ellipsize = where
    }

    override fun getEllipsize(): TextUtils.TruncateAt? {
        return _ellipsize
    }
}
```

## 解説

TextView の本来のレイアウトが完了したあとに、表示されているテキストと行を取得し、[TextUtils#ellipsize](https://developer.android.com/reference/kotlin/android/text/TextUtils#ellipsize) で正しい Ellipsize 結果のテキストを再設定します。

[TextUtils#ellipsize](https://developer.android.com/reference/kotlin/android/text/TextUtils#ellipsize) は avail で描画領域の横幅を指定することで描画領域からテキストがはみ出す場合に Ellipsize 処理をしてくれます。avail はテキストを1行で表示すると想定した場合の横幅を指定する必要があります。

テキストに改行が含まれるとき、 `\n` は目に見えない文字ですが、avail の計算では **`\n` も横幅を持ちます**。

TextView のレイアウトが完了すると、各行ごとのテキストの横幅は [Layout#getLineMax](https://developer.android.com/reference/kotlin/android/text/Layout#getlinemax) で取得できます。 [Layout#getLineMax](https://developer.android.com/reference/kotlin/android/text/Layout#getlinemax) は末尾の改行を無視してしまうため、末尾が改行である場合は `\n` の横幅を足して計算を調整します。

TextView に表示されているすべての行でこの計算をすると正しい avail を得られますが、このままでは最終行の `\n` の一つ手前の文字が　`…` に置き換えられてしまいます。

```
あいう\n
えお\n
```

↓

```
あいう\n
え…
```

このようになるはっきりとした理由は分かりませんが、最終行が改行で終わる場合だけ avail に余裕を持たせることで `\n` を `…` に置き換えることができます。

最終行だけ `newLineWidth * 2` (とりあえず2倍としたが1.5倍などでも動くかもしれない) を足すようにすると、以下のように Ellipsize されます。最終行に表示できるだけの領域があるので、こちらの動作の方が直感的に正しい動作に近くなります。

```
あいう\n
えお\n
```

↓

```
あいう\n
えお…
```

## 制限事項

この実装では Ellipsize 後のテキストを setText() しているため、TextView autoLink は正しく動作しません。

テキスト内の URL をタップ可能としたい場合は autoLink に頼らずに、事前に URLSpan などを設定したテキストを setText() で設定してください。SpannableString であれば、Ellipsize で URL が途中で切れてしまってもタップイベントを拾うことができます。