Skip to content

Instantly share code, notes, and snippets.

@yatatsu
Last active December 5, 2021 00:11
Show Gist options
  • Save yatatsu/6a60e1a0c384bb91ddec68951341f561 to your computer and use it in GitHub Desktop.
Save yatatsu/6a60e1a0c384bb91ddec68951341f561 to your computer and use it in GitHub Desktop.
AndroidのRecyclerViewでアイテムのExpand/Collapseのアニメーションをいい感じにする

問題

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で実際のコードを見ることが出来ます。

該当のアニメーション効果の実装が見られるのは以下のクラスです。

https://github.com/nickbutcher/plaid/blob/2048e69ef2/app/src/main/java/io/plaidapp/ui/DribbbleShot.java

実際のコードから見て取れるポイントは以下です。

  1. 開閉アニメーションを実行しているときにRecyclerViewのデフォルトアニメーションが効かないようにする
  2. アニメーションさせているときにスクロールさせないようにタッチイベントを奪う
  3. アニメーションさせたいときにTransitionManager#beginDelayedTransition(View)を親View=RecyclerViewに対して呼ぶ
  4. パフォーマンスを向上させるためにRecyclerView.Adapter#notifyItemChanged(int, Object)を使って差分更新する

PlaidのコードはAndroidのコードにしては比較的大きいので、最低限の実装を追ってみます。

1. 開閉アニメーションを実行しているときにRecyclerViewのデフォルトアニメーションが効かないようにする

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);

2. アニメーションさせているときにスクロールさせないようにタッチイベントを奪う

次の準備としてアニメーションしているときにスクロールさせないようにタッチイベントをうばうリスナを用意します。 これはPlaidで使われているワークアラウンドです。

private final View.OnTouchListener touchEater = (v, event) -> true;

3. アニメーションさせたいときにTransitionManager#beginDelayedTransition(View)をRecyclerViewに対して呼ぶ

まずTransitionオブジェクトを作ります。

Transition expandCollapse;
expandCollapse = new AutoTransition();
expandCollapse.setDuration(120);
expandCollapse.setInterpolator(AnimationUtils.loadInterpolator(context,
    android.R.interpolator.fast_out_slow_in));

先程つくったタッチイベントリスナと、ItemAnimatorをアニメーションの始まりと終わりでセットします。 (ここでのTransitionUtil.TransitionListenerAdapterTransition.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);
});

4. パフォーマンスを向上させるためにRecyclerView.Adapter#notifyItemChanged(int, Object)を使って差分更新する

これは必須ではありませんが、上記のコードで、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);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment