【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追記ここまで]

参考