[Android]Viewの正確なサイズを取得する

投稿者:

Androidは様ざまなデバイス上で動いているため、画面の解像度がデバイスごとにまちまちである。なのでアプリ開発においてViewの大きさをピクセル単位で指定すると、ある端末ではちょうどに見えても、別の端末では端っこにちょろっと表示されたり、はたまた画面からはみ出したりする。だから「dp」という解像度に依存しない単位を使ってレイアウトをデザインするように推奨されている。

dpについては以下のサイトで解説されているので見て欲しい
https://qiita.com/nein37/items/0a92556a80c6c14503b2

でも、アプリをコーディングする上でViewの画面上での大きさを知る必要も出てくる。Viewが画面からはみ出ないように調整したり、大きさを揃えたい、といった場面である。

とりあえずサンプルアプリを作ったので以下にソースコードを示す。

レイアウトファイル
activity_main.xml(メインアクティビティのレイアウト)
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="20sp"
        android:textAlignment="center"
        android:text="*** テスト ***"/>

    <androidx.recyclerview.widget.RecyclerView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/main_recycler" />
</LinearLayout>
grid_item.xml(各アイテムのレイアウト)
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/grid_layout"

    android:padding="2dp">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/text1"
        android:textSize="20sp"
        android:textAlignment="center"
        android:background="#FF9800"
    />
</FrameLayout>
Javaファイル
MainActivity.java
public class MainActivity extends AppCompatActivity {
    final String[] items = {
            "1", "2", "3", "4", "5",
            "6", "7", "8", "9", "10",
            "11", "12", "13", "14", "15",
            "16", "17", "18", "19", "20",
            "21", "22", "23", "24", "25",
            "26", "27", "28"
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        final RecyclerView recyclerView = (RecyclerView)findViewById(R.id.main_recycler);
        RecyclerView.LayoutManager layoutManager = new GridLayoutManager(this, 5);
        recyclerView.setLayoutManager(layoutManager);
        MyAdapter adapter = new MyAdapter(items);
        recyclerView.setAdapter(adapter);
    }

    /*
        RecyclerViewのアダプター
     */
    class MyAdapter extends RecyclerView.Adapter {
        //メンバ変数
        String[] mItems;

        //コンストラクタ
        MyAdapter (String[] items) {
            this.mItems = items;
        }

        @NonNull
        @Override
        public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
            View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.grid_item, null);
            return new MyViewHolder(view);
        }

        @Override
        public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
            MyViewHolder myViewHolder = (MyViewHolder) holder;
            myViewHolder.text.setText(mItems[position]);
        }

        @Override
        public int getItemCount() {
            return mItems.length;
        }

        /*
            ViewHolder
         */
        class MyViewHolder extends RecyclerView.ViewHolder {
            //メンバ変数
            TextView text;

            //コンストラクタ
            MyViewHolder(@NonNull View itemView) {
                super(itemView);
                text = itemView.findViewById(R.id.text1);
            }
        }
    }
}

実行すると次のような画面が出てくる。

実行結果1

まあ、RecyclerViewのGridLayoutManagerで横5列の格子状にアイテムを並べている、それだけのアプリだ。オレンジ色のアイテムが上詰まりで表示されていて下のほうが空いているが、RecyclerViewのサイズは幅と高さ共にmatch_parentにしているためRecyclerView自体は画面の一番下まで広がっている。

これをGoogleのカレンダーアプリのように格子が画面いっぱいになるようにするにはどうすればいいだろうか?

Googleカレンダーの画面

普通に考えて、RecyclerViewの画面上のサイズを取得し、行数で割って、その値を子アイテムの高さに適用すれば上手くいくはずだ。

しかし、match_parentやwrap_content等、幅や高さを相対的に指定してあるViewは画面上の位置や大きさをあらかじめ知ることができない。画面に実際に表示されるまでは、getHeight()やgetWidth()で大きさを取得できないのだ。上のサンプルでは、アダプタのonCreateViewHolderメソッドでViewを生成しているが、この時点でgetHeight()やgetWidth()しても0が返ってきてしまう。

Viewの位置や大きさが決まるのは画面に描画される直前だ。そのタイミングはViewオブジェクト内にあるViewTreeObserverオブジェクトを使って掴むことができる。ViewTreeObserverオブジェクトの取得にはViewのgetViewTreeObserver()メソッドを使う。

ViewTreeObserverには複数のイベントリスナーがあるが、この中で使えそうなのは以下の三つだろうか……

リスナー説明
onPreDrawListener描画直前に呼ばれる。描画をキャンセルすることもできる。
onDrawListener描画されたら呼ばれる?
onGlobalLayoutListener画面に表示されたら呼ばれる?

どのイベントでも呼ばれた時点でViewの幅、高さ、位置が全てピクセル単位で決まっているため、ここでgetHeight()やgetWidth()すれば正確なサイズを取得することができる。

じゃあどのイベントを利用すればいいのかというと、情報を取得するだけなら下の二つ。情報を取得してViewに変更を加えるならいちばん上のonPreDrawListenerが良さそうである。

というわけでonPreDrawListenerでRecyclerViewの高さを取得し、子アイテムの高さを変更するようプログラムを書き換えると以下のようになる。
(レイアウトファイルは変更無しなので省略)

Javaファイル
MainActivity.java
public class MainActivity extends AppCompatActivity {
    final String[] items = {
            "1", "2", "3", "4", "5",
            "6", "7", "8", "9", "10",
            "11", "12", "13", "14", "15",
            "16", "17", "18", "19", "20",
            "21", "22", "23", "24", "25",
            "26", "27", "28"
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        final RecyclerView recyclerView = (RecyclerView)findViewById(R.id.main_recycler);
        recyclerView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
            @Override
            public boolean onPreDraw() {
                //高さの変更は1回だけでいいのでリスナーを解除しておく
                //解除しないと画面に変更があるたびに呼ばれてしまう
                recyclerView.getViewTreeObserver().removeOnPreDrawListener(this);
                //RecyclerViewの高さ取得
                int parentHeight = recyclerView.getHeight();
                //子アイテムの数
                int childCount = recyclerView.getChildCount();
                //行数
                int rowNum = recyclerView.getChildCount() / 5;
                if (childCount % 5 > 0) { rowNum++; }
                //子アイテムの高さを計算
                int childHeight = parentHeight / rowNum;

                //高さを変更
                for (int i=0; i < childCount; i++) {
                    //子アイテムの高さを変更
                    ViewGroup.LayoutParams layoutParams = recyclerView.getChildAt(i).getLayoutParams();
                    layoutParams.height = childHeight;
                    recyclerView.getChildAt(i).setLayoutParams(layoutParams);
                }
                return false;   //描画を中断するためfalseを返す
            }
        });

        RecyclerView.LayoutManager layoutManager = new GridLayoutManager(this, 5);
        recyclerView.setLayoutManager(layoutManager);
        MyAdapter adapter = new MyAdapter(items);
        recyclerView.setAdapter(adapter);
    }

    /*
        RecyclerViewのアダプター
     */
    class MyAdapter extends RecyclerView.Adapter {
        //メンバ変数
        String[] mItems;

        //コンストラクタ
        MyAdapter (String[] items) {
            this.mItems = items;
        }

        @NonNull
        @Override
        public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
            View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.grid_item, null);
            return new MyViewHolder(view);
        }

        @Override
        public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
            MyViewHolder myViewHolder = (MyViewHolder) holder;
            myViewHolder.text.setText(mItems[position]);
        }

        @Override
        public int getItemCount() {
            return mItems.length;
        }

        /*
            ViewHolder
         */
        class MyViewHolder extends RecyclerView.ViewHolder {
            //メンバ変数
            TextView text;

            //コンストラクタ
            MyViewHolder(@NonNull View itemView) {
                super(itemView);
                text = itemView.findViewById(R.id.text1);
            }
        }
    }
}

ハイライトした部分が最初のコードに追加した部分だ。

実行結果は次のようになる

実行結果2

onPreDraw()のreturnでfalseを返したのは描画を中断するためだ。trueを返すと変更前の情報で描画が済んでから変更後のデータで再描画される。このサンプルだとアイテムが上詰めで表示された後に画面いっぱいまでビヨーンと伸びるように見える。どっちを良しとするかは、アプリ製作者のセンス次第だ。

また、onPreDraw()内でリスナーを解除しているが、そうしておかないと子アイテムの高さを変えるたびに再帰的にonPreDraw()が呼ばれてしまい、最悪の場合アプリが落ちてしまう。この点は注意しておこう。

というわけでViewの実際のサイズを取得する方法がわかっただろうか? ほかにも方法があるかもしれないが、こういうやり方もあるということで、理解していただきたい。

参考資料:
ViewTreeObserverのGoogle公式ドキュメント
https://developer.android.com/reference/android/view/ViewTreeObserver

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください