Android

Google検索のサジェストみたいなViewを作りたい

More than 1 year has passed since last update.

Google検索するときに出てくるサジェスト、横方向に長さの違うテキストを詰め込むあのView。
要するにこれです。

こういうのを作りたい。と思い立ちました。

で、できたのがこれです。
screen.gif

これ、標準的なパーツでありそうでない。
近いところで言うと、RecyclerViewを使い、LayoutManaerとしてStaggeredGridLayoutManagerを使用した場合に近いでしょうか?StaggeredGridLayoutManagerは横方向を均一割り当てで分割し、縦方向の高さがバラバラのViewをいい感じに詰め込んでくれるLayoutManagerです。
惜しい、でも違う。いや、本当に惜しいのか?

簡単そうに見えて実は難しい

さて、どうやって実現するべきか、そもそもこれはどういう仕組みなのかと考えていくと、意外なほど難しいことが分かってきます。StaggeredGridLayoutManagerは横方向の分割数は固定で、縦方向には可変であるため、レイアウトする前に自分の大きさが確定している必要はありません。
しかし、今回実現しようとしているものの場合は違う。最初に自分のViewの幅が決まっている必要がある。
そして、個別のViewの大きさはバラバラながらも最小サイズを求めることができ、決められた横幅に収まる最大数を詰め込み、それぞれの最小サイズの比率で、横幅を分割する。
つまり、各要素を配置してから大きさが分かるのではだめで、配置する前から大きさが分かる必要がある。
という要素が分かってきました。

LayoutManagerを作ったことはなく、その仕組みをよく分かっていないけど、LayoutManagerでは難しいのではないか・・・
ということで、まずは泥臭くてもいいから知っている範囲で実現方法を探ってみることにします。

現実的な実現方法を考える

泥臭く、美しくなくてもかまわない、まずは実現可能にすることから始めます。

縦方向の分割

まず簡単なところで、縦方向の分割。これは全部同じ高さのものを詰め込むだけなので何でも良さそうです。
しかし、それぞれの中身が事前に分からなさそうなので、AdapterView系で実現するのはややこしい、というか、Adapterで作った後にガチャガチャ中身をいじることになりそうなのであまりやる意味がなさそうな気がしてきます。
手軽なところでLinearLayoutを使ってみることにしました。

UnevennessTextGridView という名前のクラスを作るので、LinearLayoutをextendsしておきます。

public class UnevennessTextGridView extends LinearLayout

横方向の分割

横方向の配置は、等分ではなく、それぞれ子要素の幅の比率で全体を分割する必要がある。これはよく見かける処理ですね。これもLinearLayoutのlayout_weightで実現できるので、横方向の配置もLinearLayoutで作ってみることにしました。

<merge
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    >

    <LinearLayout
        android:id="@+id/linear_layout1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        />

    <View
        android:id="@+id/divider1"
        android:layout_width="match_parent"
        android:layout_height="@dimen/divider"
        android:background="@color/divider"
        />

    <LinearLayout
        android:id="@+id/linear_layout2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        />
....
</merge>

LinearLayoutの入れ子、実に泥臭い。

Dividerも高さ1dpのViewを挟むだけという横着ぶりです。

子要素のコンテンツ

さて、子要素をどうするか考える、最低の横幅は表示するコンテンツしだいで変化するが、あらかじめ長さを取得できるもの・・・
(といっても、どんな要素であっても頑張って計算すればできますが)
ひとます簡単なところで、表示するコンテンツは簡単な折り返しなしのStringのみに限定し、任意個のStringを渡された場合に、与えられたエリアに表示できるだけ表示する。あふれた場合は無視、足りない場合は表示エリアを切り詰めるという感じにすることにしました。

子要素の長さの算出

子要素のコンテンツはStringのみなので、TextViewのフォントサイズと、左右のマージンだけ決めれば計算できます。
左右のマージンはdimens.xmlにでも定義しして、レイアウトファイルで指定、 Resources#getDimension() でコード上から参照すればいけますね。
文字列そのものの大きさについては折り返しがない前提なら単に Paint#measureText(java.lang.String) で求めることができます。

実際にできたもの

ということで、文字列の長さに応じて幅分に割り当てる処理がこんな感じになりました。
実に泥臭い。

※引数としての入力は配置する文字列のリストで、mLinearLayoutsは縦方向の分割、行数分あらかじめ用意したLinearLayoutのList、PairにIntegerとともに格納しているのはここで計算した最小幅を後でweightとして使用するため。

    @NonNull
    private List<List<Pair<Integer, String>>> assignToLine(@NonNull final List<String> list) {
        final List<List<Pair<Integer, String>>> results = new ArrayList<>();
        for (final LinearLayout layout : mLinearLayouts) {
            results.add(new ArrayList<>());
        }

        final int viewWidth = getWidth();
        final Iterator<String> iterator = list.iterator();
        int line = 0;
        int width = 0;
        List<Pair<Integer, String>> textList = results.get(line);
        while (iterator.hasNext()) {
            String text = iterator.next();
            final int itemWidth = (int) (mPaint.measureText(text) + mMargin);
            if (width + itemWidth > viewWidth) {
                line++;
                if (line >= results.size()) {
                    break;
                }
                width = 0;
                textList = results.get(line);
            } else {
                width += itemWidth;
                textList.add(new Pair<>(itemWidth, text));
            }
        }
        return results;
    }

割り当てができてしまえばあとはViewを動的に作成して流し込めばOK。
Viewの数が固定ではなく、随時更新するような使い方を考えると都度作成するのは無駄なので使い回しをしています。

    public void setList(@NonNull final List<String> list) {
        if (getWidth() == 0) {
            execOnLayoutOnce(() -> setList(list));
            return;
        }
        for (LinearLayout layout : mLinearLayouts) {
            layout.removeAllViews();
        }
        mTextCache.recycle();
        mDividerCache.recycle();
        final List<List<Pair<Integer, String>>> assignment = assignToLine(list);

        for (int i = 0; i < mLinearLayouts.size(); i++) {
            final LinearLayout layout = mLinearLayouts.get(i);
            final List<Pair<Integer, String>> line = assignment.get(i);
            if (line.size() == 0) {
                layout.setVisibility(GONE);
                if (i != 0) {
                    mDividers.get(i - 1).setVisibility(GONE);
                }
            } else {
                layout.setVisibility(VISIBLE);
                if (i != 0) {
                    mDividers.get(i - 1).setVisibility(VISIBLE);
                }
            }
            for (final Pair<Integer, String> pair : line) {
                if (layout.getChildCount() != 0) {
                    layout.addView(mDividerCache.obtain(), createDividerLayoutParam());
                }
                final TextView textView = mTextCache.obtain();
                textView.setText(pair.second);
                textView.setOnClickListener(v -> {
                    if (mOnTextClickListener != null) {
                        mOnTextClickListener.onTextClick(pair.second);
                    }
                });
                layout.addView(textView, createTextLayoutParam(pair.first));
            }
        }
        mTextCache.shrink();
        mDividerCache.shrink();
    }

これを含んだサンプルコードは以下にあります。
https://github.com/ohmae/UnevennessTextGridSample

screen.gif

サンプルでは表示させるものとしてEditTextに入力したひらがなに該当する文字列を変換したときに出てくる候補のような形で、IMEの辞書にマッチした候補文字列を何も考えずに流し込むようにしています。

なお、現時点では単に似たような表示が実現できた、に過ぎません。全然洗練されていないし、汎用性も実に低いです。
その辺を突っ込まれても思いつきを書き殴っただけだし~(・3・)