AndroidでViewの高さを変えるとき、そこまでこだわらずにシンプルに実装したいときは、
Transition(android.support.transition.Transition
)を使うことが多いと思います。
RecyclerViewのアイテムをタップしてそのアイテムの高さを変える、詳細部分の表示非表示を切り替える、 いわゆるExpand/Collapseと言われているような効果を、いい感じのアニメーションで実現したいときにやり方を少し調べたので説明します。
この方法はGoogle I/O 2016のセッション「Material improvement」で、Nick Butcher氏が説明しています。
https://www.youtube.com/watch?v=EjTJIDKT72M&feature=youtu.be&t=5m50s
氏のOSSプロジェクトであるPlaidで実際のコードを見ることが出来ます。
該当のアニメーション効果の実装が見られるのは以下のクラスです。
実際のコードから見て取れるポイントは以下です。
- 開閉アニメーションを実行しているときにRecyclerViewのデフォルトアニメーションが効かないようにする
- アニメーションさせているときにスクロールさせないようにタッチイベントを奪う
- アニメーションさせたいときに
TransitionManager#beginDelayedTransition(View)
を親View=RecyclerViewに対して呼ぶ - パフォーマンスを向上させるために
RecyclerView.Adapter#notifyItemChanged(int, Object)
を使って差分更新する
PlaidのコードはAndroidのコードにしては比較的大きいので、最低限の実装を追ってみます。
RecyclerViewのItemAnimatorをカスタマイズします。 RecyclerViewがアイテムの更新を通知して変更を反映するときにデフォルトのItemAnimatorがいい感じにアニメーション効果をつけてくれます。 しかし、今回実現したいのはアイテムの高さを変えることなので、一つのアイテムだけにアニメーション効果をつけると変な感じになってしまいます。 なので、開閉アニメーションを実施している間はデフォルトの挙動を抑えるようなItemAnimatorを作ります。
private static class PreventableAnimator extends DefaultItemAnimator {
private boolean animateMoves = false;
PreventableAnimator() {
super();
}
void setAnimateMoves(boolean animateMoves) {
this.animateMoves = animateMoves;
}
@Override
public boolean animateMove(
RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) {
if (!animateMoves) {
dispatchMoveFinished(holder);
return false;
}
return super.animateMove(holder, fromX, fromY, toX, toY);
}
}
これをRecyclerViewにセットします。
itemAnimator = new PreventableAnimator();
binding.recyclerView.setItemAnimator(itemAnimator);
次の準備としてアニメーションしているときにスクロールさせないようにタッチイベントをうばうリスナを用意します。 これはPlaidで使われているワークアラウンドです。
private final View.OnTouchListener touchEater = (v, event) -> true;
まずTransitionオブジェクトを作ります。
Transition expandCollapse;
expandCollapse = new AutoTransition();
expandCollapse.setDuration(120);
expandCollapse.setInterpolator(AnimationUtils.loadInterpolator(context,
android.R.interpolator.fast_out_slow_in));
先程つくったタッチイベントリスナと、ItemAnimatorをアニメーションの始まりと終わりでセットします。
(ここでのTransitionUtil.TransitionListenerAdapter
はTransition.TransitionListener
を実装しただけの抽象クラスで、
コードを見やすくするためにこうしています。)
expandCollapse.addListener(new TransitionUtil.TransitionListenerAdapter() {
@Override public void onTransitionStart(Transition transition) {
binding.recyclerView.setOnTouchListener(touchEater);
}
@Override public void onTransitionEnd(Transition transition) {
itemAnimator.setAnimateMoves(true);
binding.recyclerView.setOnTouchListener(null);
}
});
つくったTransitionオブジェクトは、アイテムのOnClickListener内で利用します。
TransitionManager.beginDelayedTransition
メソッドの第一引数にRecyclerViewを指定して、
複数のアイテムを協調的にアニメーションさせます。
// in RecyclerView.Adapter#onBindViewHolder
boolean isExpanded = expandProvider.isExpanded(position);// 何らかの方法で開閉状態を知る
expandView.setVisibility(isExpanded ? GONE : VISIBLE);
// 実際のコードではonBindViewHolder内で毎回リスナを生成してセットするべきではありません。
itemView.setOnClickListener(v -> {
boolean isExpanded = expandProvider.isExpanded(position);
TransitionManager.beginDelayedTransition(binding.recyclerView, expandCollapse);
itemAnimator.setAnimateMoves(false);
expandProvider.setExpand(position, !isExpanded);
adapter.notifyItemChanged(position, PAYLOAD_EXPAND_COLLAPSE);
});
これは必須ではありませんが、上記のコードで、notifyItemChanged(int, Object)
を利用しています。
実際のデータ自体は変更せず、Viewの表示状態だけ切り替えたいときなどに、これを使うと描画の一部を節約できます。
RecyclerView.Adapter<VH>#onBindViewHolder(VH, int, List<Object>)
で第三引数に指定したオブジェクトが含まれているかどうかをチェックします。
payloadの中身はPlaidのコードでは整数値を利用していました。
boolean isPartialChange = payloads.contains(PAYLOAD_EXPAND_COLLAPSE);
if (!isPartialChange) {
itemView.setData(data);
}
expandView.setVisibility(isExpanded ? GONE : VISIBLE);