初めまして。
Java歴実働約2年、Android開発およびKotlin歴約2週間の駆け出しエンジニアです。
お盆休暇を使ってKotlinでAndroid開発の勉強をした際に、表題の件で躓いてしまったため、ここに記録しようかと思います。
中級者以上の方からしたら既知のことしか書いてない記事ですが、同じような初級者の方の参考になれば幸いです。
コードの記述は少ないですがKotlinで書いています。
もともとやろうとしていたこと
以下のようなHorizonalなLinearLayout(in ScrollView)の中に画像を並べたVerticalなLinearLayoutを動的に追加する処理を行おうとしました。
私が作ったのは縦に並んだ画像の列を追加していくというものでしたが、縦横が逆の場合でも大差ないと思います。電子書籍アプリの本棚なども縦横を逆にした感じのレイアウトになっているのでは?と思っているので、それをイメージしてもらえるとわかりやすいと思います。
これを実装するにあたって、「タイトルバーやメニューバーを除いた画面のサイズを取得し、そのパラメータを使ってViewを生成し配置したい」と思いました。その場合、あらかじめ配置したViewのサイズを取得して利用するという方法があります。
しかし、onCreate()などが呼び出されるタイミングではViewの描画がなされていないため、ここでViewの幅や高さを取得しようとしても0が帰ってきてしまいます。
画面のサイズを動的に取得する方法としては、onWindowFocusChanged()を利用するという手法の紹介がなされている記事をしばしば見かけます。
ですが、このメソッドで画面サイズを取得しその値を用いてViewを作成するという処理を実装すると、マルチウィンドウ(SplitScreen)を考慮した場合に少し問題が発生します。以下に詳細を記述します。Freedomの場合は未確認です。
マルチウィンドウ(SplitScreen)実行時のonWindowFocusChanged()の呼び出され方
onWindowFocusChanged()はonCreate()よりも後に呼び出されます。Boolean値を引数にとり、画面にフォーカスされた場合はtrueが、画面からフォーカスが外れた場合はfalseを引数として実行されます。
このonWindowFocusChanged()ですが、マルチウィンドウ(Split Screen)を起動、画面サイズ変更、終了したときには以下のようになります。
起動する時
onCreate()から新たに呼び出されonWindowFocusChanged(false)が実行されます。上画面(実装したアプリ側)をタッチしない限りonWindowFocusChanged(true)は呼び出されません。
上画面(実装したアプリ側)にフォーカスが当たっている状態で、中央のバー(正式名称不明)でサイズを変更する時
onCreate()から新たに呼び出されますが、そのままでも上画面をタッチしてもonWindowFocusChanged()が呼び出されません。下画面をタッチすることでonWindowFocusChanged(false)が呼び出され、その後上画面をタッチすることでonWindowFocusChanged(true)が呼び出されます。
上画面(実装したアプリ側)にフォーカスが当たっている状態でマルチウィンドウを解除する時
onCreate()から新たに呼び出されますが、画面をタッチしてもonWindowFocusChanged()が呼び出されません。そのため、右下のボタンを押すなどでアプリから一度遷移するなどしてonWindowFocusChanged(false)を呼び出さないとonWindowFocusChanged()で実装した処理を行うことができません。
マルチウィンドウ機能自体を不可にしてしまえば問題はないのですが、マルチウィンドウでも使用したかったため、別の方法を考える必要が出てきました。
改めて、私がやりたいことは、画面起動時およびマルチウィンドウ起動時に「ViewGroupの正確なサイズを取得すること」と「ViewGroupの値を使用して子ViewGroupを新たに作成し追加すること」です。考えたり調べたりして色々解決策を探しました。
- LinearLayoutを継承したカスタムビューを作成し、onMeasure()とonLayout()とonDraw()で処理をする。
- ViewTreeObserverを使用する。
結論としては、2で実装しました。1でかなり時間をかけたのですがうまくいきませんでした。
解決方法
ViewTreeObserverを使用する。
こちらを参考に
view_target.viewTreeObserver.addOnGlobalLayoutListener(object: ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
makeView(view_target.width,view_target.height)
}
})
を実装したところ、正確にViewのサイズが取得できました。
ただしこのままだと何度も呼び出されてしまうため、
removeOnGlobalLayoutListener()
を実行したいのですが、Kotlinでの書き方がよくわからず…。
ですので、こちらに書いてあった拡張関数を用いた書き方を使わせてもらいました。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
〜
view_target.afterMeasured {
makeView(view_target.width,view_target.height)
}
}
inline fun <T : View> T.afterMeasured(crossinline f: T.() -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (width > 0 && height > 0) {
viewTreeObserver.removeOnGlobalLayoutListener(this)
f()
}
}
})
}
ちなみに上記のサイトではif文にmeasuredWidth,measuredHeightを使っていますが、私の環境では上手く行かなかったためwidth,heightを使っています。
【蛇足】上手く行かなかったやり方:カスタムビュー内で描画処理
onMeasure()で描画
Viewの生成時に呼び出されるメソッドの一つである、onMeasure()をオーバーライドする方法です。
主にView自身のサイズを決定するために用いられる処理、と認識しています。
ここで
widthSize = MeasureSpec.getSize(widthMeasureSpec)
heightSize = MeasureSpec.getSize(heightMeasureSpec)
をつかって以下のように画面サイズを取得しているサンプルをよく見かけます。
ただし、この処理は複数回呼び出されるため、重たい処理を行うのは不適切のようです。あくまで画面サイズの取得に限定した方がよさそうです。
後述しますが、onLayout()に記述する場合でもうまくいかなかったため、フラグを立ててここで一度だけ描画処理をする実装をしてみたことがあります。ですが、最初に呼び出されるMeasureSpec.getSize(widthMeasureSpec)、MeasureSpec.getSize(heightMeasureSpec)で取得できる画面サイズは、実際のViewよりも小さい間違ったものでした。これが仕様なのかバグなのか、ScrollViewを使用しているからなのかは、私が調べた範囲ではわかりませんでした。1,2回実行されたのち(ライフサイクルの特定の処理まで終わったのち?)には正確な値を取得するようになるのですが、よくわかりませんでした…。
onLayout()で描画
Viewの生成時に呼び出されるメソッドの一つである、onLayout()をオーバーライドする方法です。
主に子ビューの配置を行う際に呼び出す処理、と認識しています。
onMeasure()で画面サイズを取得し、ここに描画処理を実装した場合には、エミュレータ・実機ともに想定通りの動きをしてくれました。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
widthSize = MeasureSpec.getSize(widthMeasureSpec)
heightSize = MeasureSpec.getSize(heightMeasureSpec)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
makeView(widthSize,heightSize)
}
ただし、
requestlayout() improperly called by LinearLayout...
...
requestlayout() improperly called by LinearLayout...
という警告文が、追加した子LinearLayoutの数だけ表示されてしまっていました。
色々ググってみたのですが、原因がわからず警告文を消すことができませんでした。
ただ、onLyaout()は一度しか呼ばれない処理のようですが、ログを見ると複数回呼び出されているように思います。起動時に一度実行された後にどこかでrequestlayout()でonLayout()が呼び出されており、その際にonLayout()に書かれている描画処理が再び呼び出されることに対して警告を発しているのではないか?と思っていますが、実際のところはわかりません…。
onDraw()で描画
Viewの生成時に呼び出されるメソッドの一つである、onDraw()をオーバーライドする方法です。
onLayout()でダメなのだからここでもダメなのでは?と思いながらダメ元で実装してみたところ、そもそもこの処理を呼び出してもらえませんでした。
本題とは逸れますが、呼び出されない理由としては
- カスタムビューではonDraw()がデフォルトで呼び出されないようになっている。呼び出すにはsetWillNotDraw(false)が必要。
- ViewGroupではonDraw()ではなくdispatchDraw()が呼び出される。
とのことでした。
そこで、setWillNotDraw(false)をコンストラクタに追加し、dispatchDraw()をオーバーライドして実装しましたが、呼び出されることはありませんでした。念のためonDraw()もオーバーライドしましたが変わらずでした。現時点ではこのせいで困っていることはありませんが、もしかしたら躓く日が来てしまうかもしれないと思うと心配です。
あとがき
終わってみると「なんだこれだけでいいのか…。」という感じですが、検索に使えるワードにたどり着くまでに随分と苦労しました。このあたりの技術も身に着けていかないと、開発を続けていけないんでしょうね。
API_27だと画像が表示されるのにAPI_24だとなされない(onLayoutに記述した場合はできた)という新たな問題に直面したのでデバッグはまだまだ続きそうです…。