[Android] TextView の textScaleX を自動調整する

# 何を作ったか
限られたスペースしか使えない状況で TextView に長い文字列を表示させようとして、途中で文字列が切れてしまい見た目がよろしくない経験は多々あるでしょう。解決策はいろいろあるでしょうが、ここでは textScaleX と呼ばれる TextView の属性に注目します。表示する文字の横方向への伸縮倍率を表す値であり、1より大きい値なら文字列を伸ばし、逆に1より小さな値なら文字列を収縮させます。TextView の最大横幅に収まり切らない長さの文字列を表示するときに、この textScaleX の値を自動で調節して文字列の長さを圧縮する View を作りました。クラス名は ExpandableTextView とでも呼びましょう。

## デモ画像
分かりやすさのため、ExpandableTextView の背景を灰色にしてあります。
![expandableTextView_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/b7d1f051-24a0-11f2-3a1e-20d3a70fccab.gif)

## 何が出来るか
- View の最大横幅に合わせて表示する文字列を横方向へ伸縮する（textScaleX = (0.0, 1.0]）
- onLayout() や setText() で状況が変化するたびに自動で調整する
- textScaleX の下限値を用意して、文字列を最小倍率まで圧縮しても収まらない場合は代替文字列に置換する（末尾を削って"…"に置換）

## View 横幅の自動調整の挙動
LayoutParam の layout_width に関して場合分け。XMLで静的に定義する android:layout_width の値のことです。

- wrap_content
    - 文字列の幅 > View の最大幅：文字列を収縮（textScaleX < 1）させる
    - 文字列の幅 <= View の最大幅：文字列長さ（textScaleX = 1）に View 幅を合わせる
    - ただし、View の横幅の最大値 android:maxWidth を上限とする
- それ以外：LayoutParam から計算される値で横幅を決定し、この長さを超える文字列は横方向に縮小させて収める

# ソースコード
[Github](https://github.com/Seo-4d696b75/MyAndroidLibrary/blob/master/customview/src/main/java/jp/ac/u_tokyo/t/seo/customview/ExpandableTextView.java) に上げておきます。

# 実装の説明
SDK version = 26 で確認

## TextView を継承する
楽に実装したいので TextView を継承して弄り回します。いくつかのメソッドをオーバーライドして振る舞いを改変。

- setText(CharSequence,BufferType)  
いくつかオーバロードが存在しますが setText(char[],int,int) 以外はすべてここを経由して呼び出されます。残念ながら setText(char[],int,int) は final でオーバーライドできないので、諦めて対象外とします。
- setMaxWidth(int)  
親クラスのメンバ mMaxWidth が private なので、この setter を通じて指定される値を自身でも記憶しておきます。
- setLines(int)  
長い文字列を伸縮しつつ１行で表示するための View ですから、行数は1に強制します。
- setTextScaleX(float)  
View 側で自動で調節するのでユーザには弄らせないように変更。

## 表示する文字列の制御
許される最大横幅に対し、必要に応じて文字列を伸縮させたり代替文字列に置換する制御を用意

```java:ExpandableTextView#updateText

private Paint mTextPaint;           // 文字を描画するオブジェクト
private CharSequence mDisplayedText // 実際に表示している文字列

private void updateText(CharSequence text, BufferType type, int widthSize){
    mTextPaint.setTextScaleX(1f);
    float length = mTextPaint.measureText(text, 0, text.length());
    int padding = getCompoundPaddingLeft() + getCompoundPaddingRight();
    if ( length + padding > widthSize ){
        float scale = (widthSize - padding) / length;
        text =  modifyText(scale, text, widthSize - padding);
    }
    mDisplayedText = text;
    super.setText(text, type);
}

/**
 * 指定された最大幅に合うように文字列を横方向へ収縮させる.
 * 収縮率が{@link #mMinTextScaleX}を下回る場合は、その最小比率でちょうど最大幅に合うような代替文字列に置換する
 * @param scale     rough value, with which text width can be adjusted to maxLength
 * @param text      raw text
 * @param maxLength max length of text
 * @return modified text
 */
private CharSequence modifyText(float scale, CharSequence text, float maxLength){
    // ソースコード参照
}
```

具体的に文字列を収縮したり、代替文字列に置換するのは modifyText() 以下になります。注意すべき点として、Paint#measureText で測定される文字列の長さは、

- Paint#setTextScaleX で指定した比率とは**比例しない**
- 文字列の文字数 String#length とは**比例しない**

ですから、目的の横幅になる倍率 textScaleX を探索するために、適当な当たりをつけて倍率を少しずつ変化させ最善の値を採択します。同様に、最大横幅に文字列が収まる代替文字列を決定するために、１文字ずつ末端から削りながら探索する、という残念な実装方法になってしまいました。コードは汚いのでここには載せません。

## View#onMeasure(int,int) で View の横幅を制御する
親の View がこの View を配置するとき、その大きさを計測するために呼び出されるメソッドです。onMeasure()の役割に関しては [[Qiita]onMeasureとonLayoutについて理解する](https://qiita.com/naodroid/items/d685a0113342edbb7587) が詳しいです。今回興味があるのは横幅に関してのみであり、width に関する measureSpec に要求を追加します。onMeasure() をすべて自前で実装するのは面倒なので、横幅以外はそのまま super#onMeasure() に渡して親クラスに丸投げします。

```java:ExpandableTextView#onMeasure
private Paint mTextPaint;           // 文字を描画するオブジェクト
private CharSequence mCurrentText;  // 表示したい文字列
private CharSequence mDisplayedText;// 実際に表示している文字列
private int mMaxWidth;              // 最大の横幅
private int mRawWidthMeasureSpec;   // 親から指定されたこのViewの大きさに関する要求

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
    // 幅に関してのみ要求を追加 
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);

    CharSequence text = mCurrentText;
    mTextPaint.setTextScaleX(1f);
    float length = mTextPaint.measureText(text, 0, text.length());

    int padding = getCompoundPaddingLeft() + getCompoundPaddingRight();
    int requestedWidth = 0;

    switch ( widthMode ){
        case MeasureSpec.EXACTLY:
            // 表示する文字列に関わらず指定された横幅で固定
            requestedWidth = widthSize;
            // 固定された横幅に合わせて表示する文字列を調整
            updateText(mCurrentText, BufferType.NORMAL, widthSize);
            break;
        case MeasureSpec.AT_MOST:
            int max = Math.min(widthSize, mMaxWidth);
            if ( length + padding > max ){
                // 最大の横幅に収まり切らないなら圧縮
                requestedWidth = max;
                float scale = (max - padding) / length;
                CharSequence modified = modifyText(scale, text, max - padding);
                if ( !mDisplayedText.equals(modified) ){
                    mDisplayedText = modified;
                    super.setText(modified, BufferType.NORMAL);
                }
            }else{
                // 収まるなら文字列の幅にViewの横幅を合わせる
                requestedWidth = (int)Math.ceil(length + padding);
            }
            break;
        case MeasureSpec.UNSPECIFIED:
            // 指定がないなら勝手に決める
            requestedWidth = (int)Math.ceil(length + padding);
            mTextPaint.setTextScaleX(1f);
            mDisplayedText = text;
            break;
    }
    
    mRawWidthMeasureSpec = widthMeasureSpec;

    // 横幅は固定値として要求を追加する
    int calcWidthMeasureSpec = MeasureSpec.makeMeasureSpec(requestedWidth, MeasureSpec.EXACTLY);
   
    // onMeasure()の細かい実装は親に任せる
    super.onMeasure(calcWidthMeasureSpec, heightMeasureSpec);
}
```

## 表示する文字列・環境の変化に対応する
setText() で表示したい文字列が変化した場合などでも意図したとおり振る舞うための実装。ここで重要な点は TextView が layout() を呼び出すタイミング。通常の TextView はその大きさが中身に依存する場合（LayoutParamでwrap_contentを指定するなどして onMeasure()で MeasureSpec.AT_MOST が指定される場合）、

- setText() で表示する文字列が変化する
- setMaxWidth() で最大横幅が変化する
- setTextScaleX() で textScaleX が変化する

のタイミングで layout() を呼び出し、View の大きさを再計算します。今回は上記に加えて、

- setMinTextScaleX() で textScaleX の最小値が変化する

のタイミングでも layout() を呼び出すように実装します。  

以上は View の大きさが中身に依存する場合の話です。親から MeasureSpec.EXACTLY で横幅が指定されている場合は、updateText() で表示する文字列・伸縮倍率を固定幅に合わせて調節します。

```java:ExpandableTextView

private CharSequence mCurrentText;  // 表示したい文字列
private CharSequence mDisplayedText // 実際に表示している文字列
private int mMaxWidth;              // 最大の横幅
private int mRawWidthMeasureSpec;   // 親から指定されたこのViewの大きさに関する要求
private float mMinTextScaleX;       // textScalex の最小値

@Override
public void setMaxWidth(int width){
    // super#mMaxWidth は private だから、自身でも記録しておく
    if ( width <= 0 ) return;
    if ( width != mMaxWidth ){
        mMaxWidth = width;
        mRequestMeasure = true;
        super.setMaxWidth(width);
        // maxWidth はこのViewの横幅が中身に依存する設定（wrap_contentなど）の場合のみ有効
        // そのような場合では親クラスが layout() を呼び出す
    }
}

/**
 * 文字列の横方向のScaleの最小値を指定する.
 * @param scale in range of (0,1]
 * @see TextPaint#setTextScaleX(float)
 */
public void setMinTextScaleX(float scale){
    if ( scale <= 0 || scale > 1 ) return;
    if ( mMinTextScaleX != scale ){
        mMinTextScaleX = scale;
        if ( MeasureSpec.getMode(mRawWidthMeasureSpec) == MeasureSpec.EXACTLY ){
            // layout() する必要なし
            updateText(mCurrentText, BufferType.NORMAL, MeasureSpec.getSize(mRawWidthMeasureSpec));
        }else{
            // layout() して大きさを再計算
            requestLayout();
        }
    }
}


//#setText(char[], int, int) <- finalでオーバーライド不可
//           以外はここを経由している
@Override
public void setText(CharSequence text, BufferType type){
    if ( text == null ) return;
    if ( mCurrentText != null && mCurrentText.equals(text) ) return;

    mCurrentText = text;

    if ( MeasureSpec.getMode(mRawWidthMeasureSpec) == MeasureSpec.EXACTLY ){
        // 親クラスはlayout()しないし、する必要もない
        updateText(text, type, MeasureSpec.getSize(mRawWidthMeasureSpec));
    }else{
        // 親クラスがlayout()する
        super.setText(text, type);
    }
}
```

## ２つの setTextScaleX() の落とし穴
今回の実装では、TextView#getPaint() で取得した文字描画に使う Paint オブジェクトに setTextScaleX() で文字列の伸縮倍率を指定しています。getPaint() には Paint のプロパティを弄るなと注意書きがありますが（原文：[Use this only to consult the Paint's properties and not to change them.](https://developer.android.com/reference/android/widget/TextView.html#getPaint())）、textScaleX に関しては以下のような実装箇所あり。

```java:TextView
public void setTextScaleX(float size) {
    if (size != mTextPaint.getTextScaleX()) {
        mUserSetTextScaleX = true;
        mTextPaint.setTextScaleX(size);
        if (mLayout != null) {
            nullLayouts();
            requestLayout();
            invalidate();
        }
    }
}

private void setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen) {
    /* 中略 */
    if (!mUserSetTextScaleX) mTextPaint.setTextScaleX(1.0f);   
    /* 中略 */
}
```
TextView の方の setTextScaleX() から指定しないと textScaleX = 1.0f で***上書きされてしまう！***  
はじめ嵌りました。解決策：一度でも TextView#setTextScaleX() を呼び出せばいい。

```java:ExpandableTextView
public class ExpandableTextView extends AppCompatTextView{

    public ExpandableTextView(Context context, AttributeSet attr, int defaultAttr){
        super(context, attr, defaultAttr);

        /* 中略 */

        // 現在の値と異なる値を指定すればいいので適当に getTextScaleX() / 2f
        super.setTextScaleX(getTextScaleX() / 2f);
    }
```