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);
}
}
}
}
実行すると次のような画面が出てくる。
まあ、RecyclerViewのGridLayoutManagerで横5列の格子状にアイテムを並べている、それだけのアプリだ。オレンジ色のアイテムが上詰まりで表示されていて下のほうが空いているが、RecyclerViewのサイズは幅と高さ共にmatch_parentにしているためRecyclerView自体は画面の一番下まで広がっている。
これを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);
}
}
}
}
ハイライトした部分が最初のコードに追加した部分だ。
実行結果は次のようになる
onPreDraw()のreturnでfalseを返したのは描画を中断するためだ。trueを返すと変更前の情報で描画が済んでから変更後のデータで再描画される。このサンプルだとアイテムが上詰めで表示された後に画面いっぱいまでビヨーンと伸びるように見える。どっちを良しとするかは、アプリ製作者のセンス次第だ。
また、onPreDraw()内でリスナーを解除しているが、そうしておかないと子アイテムの高さを変えるたびに再帰的にonPreDraw()が呼ばれてしまい、最悪の場合アプリが落ちてしまう。この点は注意しておこう。
というわけでViewの実際のサイズを取得する方法がわかっただろうか? ほかにも方法があるかもしれないが、こういうやり方もあるということで、理解していただきたい。
参考資料:
ViewTreeObserverのGoogle公式ドキュメント
https://developer.android.com/reference/android/view/ViewTreeObserver