🤖

TextViewのAutoSizeの実装をみてみた

2020/12/10に公開

この記事はFOLIO Advent Calendarの10日目の記事です。

先日アプリ内で、TextViewのAutoSizeを使用する機会があり、どういう実装になっているのかが気になったので、アドベントカレンダーの季節ということもあったので、実際に見てみることにしました!
(実際に見てみたところ実装が複雑ではなかったのでかなり短い内容になりましたが、、、)

AutoSizeとは

まずは、TextViewのAutoSizeについて、簡単に確認していこうと思います。

TextViewのAutoSizeは、TextViewの幅、高さ、文字列の長さに応じて、自動でTextSizeを調整してくれる機能です。
AutoSizeに必要な設定は、以下4つの要素になります。
autoSizePresetSizesという、予め決めたサイズでTextSize調整してくるものもありますが、今回はとりあえず決めた範囲内で指定したステップごとにサイズを変更してくれる以下の設定方法をメインに扱います。

android:autoSizeMinTextSize
自動調整される際の最小TextSize

android:autoSizeMaxTextSize
自動調整される際の最大TextSize

android:autoSizeStepGranularity
ここに設定したTextSizeごとに大きくしたり小さくしたりする

android:autoSizeTextType
uniformを設定すると自動調整をしてくれる, noneを設定すると自動調整をしない

実際に設定してみると、↓のような動きになります。
GIF載せれたら載せたい

上記4つの要素を設定するだけで、TextViewのAutoSize自体は簡単に設定することができます。

AutoSizeの内部実装

ここから本題ですが、AutoSizeの内部実装はどのようになっているかを見てみたいと思います。

TextViewクラスからたどってみたところAppCompatTextViewAutoSizeHelperというクラスがありました。
このクラスのコメントには、

Utility class which encapsulates the logic for the TextView auto-size text feature
と記載がありました。
このクラスでAutoSize関連の処理をしているようなので、このクラスの内容をみてみれば、どのような実装をしているかわかりそうです。

setupAutoSizeText()

AppCompatTextViewAutoSizeHelperをザッと見てみたところ、setupAutoSizeText()といういかにもセットアップを行っていそうなメソッドがあったので、まずはここから見てみます。

    private boolean setupAutoSizeText() {
        if (supportsAutoSizeText()
                && mAutoSizeTextType == TextViewCompat.AUTO_SIZE_TEXT_TYPE_UNIFORM) {
            // Calculate the sizes set based on minimum size, maximum size and step size if we do
            // not have a predefined set of sizes or if the current sizes array is empty.
            if (!mHasPresetAutoSizeValues || mAutoSizeTextSizesInPx.length == 0) {
                // Calculate sizes to choose from based on the current auto-size configuration.
                final int autoSizeValuesLength = ((int) Math.floor((mAutoSizeMaxTextSizeInPx
                        - mAutoSizeMinTextSizeInPx) / mAutoSizeStepGranularityInPx)) + 1;
                final int[] autoSizeTextSizesInPx = new int[autoSizeValuesLength];
                for (int i = 0; i < autoSizeValuesLength; i++) {
                    autoSizeTextSizesInPx[i] = Math.round(
                            mAutoSizeMinTextSizeInPx + (i * mAutoSizeStepGranularityInPx));
                }
                mAutoSizeTextSizesInPx = cleanupAutoSizePresetSizes(autoSizeTextSizesInPx);
            }
            mNeedsAutoSizeText = true;
        } else {
            mNeedsAutoSizeText = false;
        }

        return mNeedsAutoSizeText;
    }

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-master-dev:appcompat/appcompat/src/main/java/androidx/appcompat/widget/AppCompatTextViewAutoSizeHelper.java;l=572?q=AutoSizeHelper&hl=ja

まず設定されたautoSizeMaxTextautoSizeMinTextSizeの差をautoSizeStepGranularityで割り、まずは設定できるTextSizeの個数を計算しています。
そして、autoSizeMinTextSizeを初期値としてautoSizeStepGranularityを順に足していくことで、設定できる範囲のTextSizeをすべて配列に格納しているようです。
その後、計算した値をcleanupAutoSizePresetSizesに渡して、値がPositiveか、重複している値がないかをチェックしています。

そして、計算した値をmAutoSizeTextSizesInPxに入れて保持しているようです。

なので、AutoSizeのセットアップとしては、単純に設定できる範囲のTextSizeをすべて計算して保持しておく処理を行っているようです。

autoSizeText()

次は本命っぽいautoSizeTextというメソッドを見ていきます。

   void autoSizeText() {
        if (!isAutoSizeEnabled()) {
            return;
        }

        if (mNeedsAutoSizeText) {
            if (mTextView.getMeasuredHeight() <= 0 || mTextView.getMeasuredWidth() <= 0) {
                return;
            }

            final boolean horizontallyScrolling = mImpl.isHorizontallyScrollable(mTextView);
            final int availableWidth = horizontallyScrolling
                    ? VERY_WIDE
                    : mTextView.getMeasuredWidth() - mTextView.getTotalPaddingLeft()
                            - mTextView.getTotalPaddingRight();
            final int availableHeight = mTextView.getHeight() - mTextView.getCompoundPaddingBottom()
                    - mTextView.getCompoundPaddingTop();

            if (availableWidth <= 0 || availableHeight <= 0) {
                return;
            }

            synchronized (TEMP_RECTF) {
                TEMP_RECTF.setEmpty();
                TEMP_RECTF.right = availableWidth;
                TEMP_RECTF.bottom = availableHeight;
                final float optimalTextSize = findLargestTextSizeWhichFits(TEMP_RECTF);
                if (optimalTextSize != mTextView.getTextSize()) {
                    setTextSizeInternal(TypedValue.COMPLEX_UNIT_PX, optimalTextSize);
                }
            }
        }
        // Always try to auto-size if enabled. Functions that do not want to trigger auto-sizing
        // after the next layout pass should set this to false.
        mNeedsAutoSizeText = true;
    }

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-master-dev:appcompat/appcompat/src/main/java/androidx/appcompat/widget/AppCompatTextViewAutoSizeHelper.java;l=602?q=AutoSizeHelper&hl=ja

ここでは、最初にAutoSizeが設定されているTextViewのwidthとheightを取得しているようです。取得したTextViewのサイズはfindLargestTextSizeWhichFitsというメソッドに渡されます。

   private int findLargestTextSizeWhichFits(RectF availableSpace) {
        final int sizesCount = mAutoSizeTextSizesInPx.length;
        if (sizesCount == 0) {
            throw new IllegalStateException("No available text sizes to choose from.");
        }

        int bestSizeIndex = 0;
        int lowIndex = bestSizeIndex + 1;
        int highIndex = sizesCount - 1;
        int sizeToTryIndex;
        while (lowIndex <= highIndex) {
            sizeToTryIndex = (lowIndex + highIndex) / 2;
            if (suggestedSizeFitsInSpace(mAutoSizeTextSizesInPx[sizeToTryIndex], availableSpace)) {
                bestSizeIndex = lowIndex;
                lowIndex = sizeToTryIndex + 1;
            } else {
                highIndex = sizeToTryIndex - 1;
                bestSizeIndex = highIndex;
            }
        }

        return mAutoSizeTextSizesInPx[bestSizeIndex];
    }

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-master-dev:appcompat/appcompat/src/main/java/androidx/appcompat/widget/AppCompatTextViewAutoSizeHelper.java;l=696?q=AutoSizeHelper&hl=ja

そして、findLargestTextSizeWhichFitsでは、TextViewに収まる最大のTextSizeを計算しています。
内容としては、lowIndexhighIndexを用意して、lowIndexには初期値として、1を, highIndexには、setupAutoSizeTextで計算したmAutoSizeTextSizesInPxのlength - 1(要するに最大TextSizeのIndex)を代入しています。
そして、lowIndexhighIndexを足して2で割ったIndexのサイズをsuggestedSizeFitsInSpaceに渡し、サイズが大きいか、小さいかを判定していきます。

サイズが大きい(TextViewからはみ出す)場合 -> highIndexに現在試しているサイズのIndex - 1に更新して、bestSizeIndexにその値を保持する。

サイズが小さい(TextViewに収まっている)場合 -> bestSizeIndexに現在のlowIndexを保持し、lowIndexを現在試しているサイズのIndex + 1に更新します。

これを、lowIndex <= highIndexが成り立っている間、繰り返します。

例.

mAutoSizeTextSizesInPx = [1, 2, 3, 4]
mAutoSizeTextSizesInPx.length = 4
の場合、
lowIndex = 1
highIndex = 3
でスタート。

(1 + 3) / 2 = 2

TextViewに収まる場合
lowIndex = 2 + 1 = 3
highIndex = 3
=> 収まる場合TextSizeは4(mAutoSizeTextSizesInPx[3])
=> 収まらない場合TextSizeは3(mAutoSizeTextSizesInPx[2])

TextViewに収まらない場合
highIndex = 2 - 1 = 1
lowIndex = 1
=> 収まる場合TextSizeは2(mAutoSizeTextSizesInPx[1])
=> 収まらない場合TextSizeは1(mAutoSizeTextSizesInPx[0])

というようにTextSizeを判定していって、AutoSizeを実現しているようです。

AutoSizeが実行されるタイミング

AutoSizeがどのようにTextSizeを調整しているかはわかったので、最後にどのタイミングでAutoSizeが実行されるかを確認したいと思います。

autoSizeText()から参照元をさかのぼって見たところ、AppCompatTextView#onTextChanged()にたどり着きました。
TextViewにセットされている文字列が変わったタイミングで、毎回AutoSizeが実行されているようです。

まとめ

AppCompatTextViewAutoSizeHelperには、もちろん今回見たもの以外にも、たくさんのメソッド、処理があるのですが、今回は基本の部分だけ見てみたところ、意外とシンプルに実装されていて驚きました。

そして、AutoSizeが実行されるタイミングを調べているときに気がついたのですが、ButtonでもautoSize使えるようですね。(ButtonでAutoSizeを使うケースなかなかないと思いますが)

元々実装を見てみたいと思ったきっかけは、LinearLayoutで横並びに置いているTextViewを組み合わせてAutoSizeしてくれないかなぁと思ったところがきっかけでしたが、今回実装を見てみたところカスタマイズしたりすることはできそうだなぁと思いました。
横並びのTextViewを両方AutoSizeできるようにしたり、高さにwrap_contentを指定できるようにするなどにも挑戦してみたいと思ったので、今度やってみようと思います!

Discussion