【Android】Viewが表示された時のイベントを設定する
Viewが表示される瞬間のイベントを設定したいことがあります。
例えば、ListViewのフッタが表示されたら自動で次のデータを取りに行く、なんて処理。
世の中の人はこれを「最後尾までスクロールされたかどうか」を調べて実現するんですが、ちょっとクールじゃないです。
と言うのも、「ListViewのアイテムの総数」とか「スクロールで表示されたアイテムの総数」とか「そもそもListViewが持ってるアイテムの総数じゃスクロールされないケースの処理」とか、色々面倒臭いことを考えないといけないからです。
もっとシンプルに考えましょう。Viewが表示される瞬間に起こるイベントをフックしてしまえばいいのです。
Viewが表示されるタイミングのイベントを調べる
と言うわけで、Viewレベルでのライフサイクルについてちょっとだけ勉強しましょう。
ここの記事がよくまとまっているので参考にしたいと思います。
Viewが描写され、また非表示になるまでのイベントとして
- View#onFinishInflate
- View#onAttachedToWindow
- View#onMeasure
- View#onLayout
- View#onDraw
- View#onDetachedFromWindow
があります。大体この順番通りに流れてると考えていいでしょう。非表示になるタイミングはonDetachedFromWindowだけみたいです。何らかの理由によってレイアウトが変更されるとonMeasureからやり直すみたいですね。
さて、それじゃあどのイベントをフックするか決めましょう。
まず、onDetachedFromWindowはありえません。消えちゃってるし。
onFinishInflateとonAttachedToWindowはまずいです。と言うのも、これはViewが表示されるタイミングと言うよりは「ViewがActivityに格納される」タイミングのイベント(厳密には違うんだけどそんなイメージ)なので、Activityが表示された瞬間に呼び出されます。
onMeasureもちょっと微妙です。Viewのサイズを決定するタイミングなので、それよりもうちょっと後でやりたいですね。
と言うわけでonLayoutかonDrawでやることになると思います。が、最終的にViewの描画が行われるのがonDrawであり、逆に言うとこれが呼ばれないとViewは画面上に表示されません。何らかのデータを読み込み中であることを示す必要もあるので、一旦Viewを描写してから読み込み処理を行いたいです。なので今回はView#onDrawをフックします。
View#onDrawをフックする
とりあえず適当にViewを継承し、onDrawをOverrideしましょう。
public class LoadView extends View { private boolean _isOnDrawed; public LoadView(Context context) { super(context); } public LoadView(Context context, AttributeSet attrs) { super(context, attrs); } public LoadView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if(!_isOnDrawed) { _isOnDrawed = true; // TODO:何らかの読み込み処理 } } }
super#onDraw(canvas)を呼んでおけばとりあえず描写はされます。
また、onDrawはかなりの頻度で呼ばれます。何度も何度も読み込み処理を行わないようにとりあえずフラグで管理しておきましょう。
次に読み込み処理なんですが、これは委譲せざるを得ないでしょう。インターフェースを作るか、いつものクロージャちゃんで渡すようにします。
そしてもう一つ、View#onDrawが何らかの読み込み処理によってブロックされてしまうのはかなりまずいです。実質フリーズしているのと同じです。と言うわけで、AsyncTaskに渡してしまい、onDraw自体は終わらせてしまいます。
(2014/07/29 コードを微修正しました。)
public class LoadView<TReturn, TParam> extends View { private LoadViewTask task; private TParam _arg; private boolean _isOnDrawed; // 各コンストラクタでAsyncTaskを初期化しておく public LoadView(Context context) { super(context); task = new LoadViewTask(); } public LoadView(Context context, AttributeSet attrs) { super(context, attrs); task = new LoadViewTask(); } public LoadView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); task = new LoadViewTask(); } // LoadViewにイベントを持たせるふりをしつつ、実際にはAsyncTaskにイベントをそのまま渡す public LoadView<TReturn, TParam> setOnBackground(R1<TReturn, TParam> event) { task.setOnBackground(event); return this; } public LoadView<TReturn, TParam> setOnPostExecute(V1<TReturn> callback) { task.setOnPostExecute(callback); return this; } public LoadView<TReturn, TParam> setOnError(V1<Exception> onError) { task.setOnError(onError); return this; } public LoadView<TReturn, TParam> setArg(TParam arg) { _arg = arg; return this; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if(!_isOnDrawed) { _isOnDrawed = true; // 読み込み処理自体はAsyncTaskに任せる new LoadViewTask().execute(_arg); } } // 実際の読み込み処理は非同期で行う private class LoadViewTask extends AsyncTask<TParam, Void, TReturn> { private R1<TReturn, TParam> _onBackground; private V1<TReturn> _onPostExecute; private V1<Exception> _onError; private Exception _error; public void setOnBackground(R1<TReturn, TParam> event) { _onBackground = event; } public void setOnPostExecute(V1<TReturn> callback) { _onPostExecute = callback; } public void setOnError(V1<Exception> onError) { _onError = onError; } @Override protected TReturn doInBackground(TParam... params) { if(_onBackground == null) return null; try { return _onBackground.call(params[0]); } catch(Exception e) { _error = e; return null; } } @Override protected void onPostExecute(TReturn result) { if(_error != null) { if(_onError != null) { _onError.call(_error); return; } else { throw new RuntimeException(_error); } } if(_onPostExecute != null) _onPostExecute.call(result); } } }
[2014/10/10追記]
継承しているものがViewであればこれで問題ないんですが、いくつかの派生ViewはonDrawが呼ばれないよう設定されていることがあります。
View#setWillNotDrawにfalseを渡すことで確実にonDrawを呼び出すよう指定できるので、他の用途(Canvasをいじるとか)であってもonDrawをオーバーライドするような処理がある場合は入れておいた方が無難です。
[2014/10/10追記ここまで]
まとめ
後は適当なレイアウトをinflateしてイベントと引数を設定し、ListView#addFooterViewに渡してやれば前書きでの処理は実現できます。
が、何度も次のレコードを読み込みたい場合はこのままだと出来ません。読み込み終わった後に_isOnDrawedのフラグを降ろすためのsetterを作ったり、LoadView#setArgで次の引数を渡してやる必要があります。
さぁ、再帰のお勉強です。その辺は自分で考えてみましょう。
[2014/07/29追記]
ViewTreeObserverのListenerをセットする
もっとスマートな方法を見つけました。
onDraw時のListenerはない、と思っていたんですが、ViewTreeObserver経由でViewTreeObserver.OnDrawListenerもしくはViewTreeObserver.OnPreDrawListenerを設定することで可能です。
このViewTreeObserverはView#getViewTreeObserver経由で取得することが可能です。つまり、上記のような独自Viewを作らずとも、Viewを継承したものであればListenerをセット可能と言うことです。
名前的にOnDrawListenerの方を使いたくなるんですが、対応しているAPI Levelが16からと結構高いのと、「An ViewTreeObserver.OnDrawListener listener cannot be added or removed from this method.」と言うのが結構ネックなのでOnPreDrawListenerの方を使います。
OnPreDrawListener#onPreDrawでは必ずtrueを返すようにします。また、当然ながらこれも重たい処理をやらせるとフリーズ状態になってしまうので、必ず非同期処理を噛ませるか、AsyncTaskに委譲するOnPreDrawListenerを実装したクラスを作っておくようにしましょう。
public class TestActivity extends ListActivity { private View _footer; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // LinearLayoutの中にListViewだけがあるXML setContentView(R.layout.activity_test); _footer = new LinearLayout(this); _footer.setGravity(Gravity.CENTER_HORIZONTAL); ProgressBar p = new ProgressBar(this); _footer.addView(p); getListView().addFooterView(_footer); _footer.getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { @Override public boolean onPreDraw() { // このタイミングでListenerを解除しておかないと何度もイベントが発生しうる _footer.getViewTreeObserver().removeOnPreDrawListener(this); // TODO:View描写時のイベント // trueを返さないと描写されない return true; } }); } }
ViewTreeObserver.OnPreDrawListener#onPreDrawのSee Alsoを見てもらえればわかると思いますが、実際にはonDrawだけでなくonMeasureやonLayoutでもイベントが発生します。
onDrawだけをフックした独自Viewとは違って、この辺のことをしっかり考慮しておかないと何度も何度もイベントが発生してしまいます。
例えば先ほどのコードをこんな風に書き換えると、何度もイベントが発生してしまいます。onPreDraw内でremoveOnPreDrawListenerを呼び出してもダメです。
public class TestActivity extends ListActivity { private View _footer; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // LinearLayoutの中にListViewだけがあるXML setContentView(R.layout.activity_test); _footer = new LinearLayout(this); _footer.setGravity(Gravity.CENTER_HORIZONTAL); _footer.getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { @Override public boolean onPreDraw() { Debug.d("Footer", "onPreDraw called."); // このタイミングでListenerを解除しておかないと何度もイベントが発生しうる _footer.getViewTreeObserver().removeOnPreDrawListener(this); // TODO:View描写時のイベント // trueを返さないと描写されない return true; } }); ProgressBar p = new ProgressBar(this); // _footerにaddViewされたのでonMeasureとonLayoutが発生 _footer.addView(p); // ListViewに_footerがaddViewされたのでonMeasureとonLayoutが発生 getListView().addFooterView(_footer); } }
AsyncTaskLoaderを併用した再帰処理
別の記事で作成したAsyncTaskLoaderと併用すると再帰的にデータを読み込む処理がそこそこ簡単に記述できます。
public class TestActivity extends FragmentActivity { private View _footer; private OnPreDrawListener _footerListener = new OnPreDrawListener() { @Override public boolean onPreDraw() { _footer.getViewTreeObserver().removeOnPreDrawListener(this); // _footerが表示される直前にLoader#forceLoadを呼び出す getSupportLoaderManager().getLoader(0).forceLoad(); return true; } }; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // LinearLayoutの中にListViewだけがあるXML setContentView(R.layout.activity_test); getSupportLoaderManager().initLoader(0, null, new LoaderObserver<List<String>>(new Action1<List<String>>(){ // 初回読み込み時 @Override public void call(List<String> x) { ListView listView = getListView(); _footer = new LinearLayout(TestActivity.this); _footer.setGravity(Gravity.CENTER_HORIZONTAL); ProgressBar p = new ProgressBar(TestActivity.this); _footer.addView(p); listView.addFooterView(_footer); // なんか適当なArrayAdapter listView.setAdapter(new TestAdapter(getApplicationContext(), x)); // Adapterをセットする前にListenerをセットすると多分イベントが発生すると思う…。 _footer.getViewTreeObserver().addOnPreDrawListener(_footerListener); } }) { @Override public ReactiveAsyncLoader<List<String>> onCreate(int id, Bundle args) { // なんか適当なAsyncTaskLoader return new TestLoader(getApplicationContext()); } @Override public void onNext(List<String> data) { TestAdapter adapter = (TestAdapter) ((WrapperListAdapter) getListView().getListAdapter()).getWrappedAdapter(); for(String d : data) { adapter.add(d); } adapter.notifyDataSetChanged(); // _footerのイベントを再設定 _footer.getViewTreeObserver().addOnPreDrawListener(_footerListener); } @Override public void onComplete() { Toast.makeText(getApplicationContext(), "データの読み込みが完了しました" , Toast.LENGTH_SHORT).show(); // removeFooterViewでも当然イベントが発生するので事前にListenerを削除する _footer.getViewTreeObserver().removeOnPreDrawListener(_footerListener); getListView().removeFooterView(_footer); _footer = null; } @Override public void onError(Exception e) { e.printStackTrace(); Toast.makeText(getApplicationContext(), "エラー発生", Toast.LENGTH_SHORT).show(); } @Override public void onReset(ReactiveAsyncLoader<String> loader) { // Not implement } }); } private ListView getListView() { return (ListView) findViewById(R.id.listView); } }
[2014/07/29追記ここまで]