RecyclerView の各項目で、スライドしてメニューを表示する
概要
以下の動画の様に、RecyclerView の各項目で、スライドしたときにメニューを表示させる方法です。他のライブラリを導入せずに、ItemTouchHelper クラスを使って実現します。
サンプルでは削除ボタンしかありませんが、複数のボタンを設定できます。また、ドラッグ&ドロップにより、項目の入れ替えができます。
目次
確認環境
- Kotlin 1.2.71
- AndroidStudio 3.2.1
- minSdkVersion 16
- compileSdkVersion 28
参考情報
- Android swipe menu with RecyclerView | by Artur Grzybowski | codeburst
- 段階的に ItemTouchHelper の使い方を学べる
- Android recycler view item swipe with multiple context options | by Dalvin | Medium
- スワイプでオプションメニューを表示させるサンプル
解説
RecyclerView の設定
まず、RecyclerView と RecyclerView.Adapter を用意します。注意するポイントだけ記載しておきます。
- Recycler View を定義した Activity のレイアウト XML
- RecyclerView の id を content にしている。
- ViewHolder が保持する View のレイアウト XML
- background の View (ConstraintLayout) の id を background に、foreground の View (CardView) の id を foreground にしている。
- ConstraintLayout を使って foreground と background を重ねて表示している。
- ConstraintLayout を使わなくても重ねて表示できれば他のレイアウトでも構わない。
- foreground を上に表示するため、foreground をファイル内の下方で定義している。
- View の高さは background に合わせている。
- foreground に合わせることもできる。(詳しくはソースコード内のコメントを参照)
- ConstraintLayout における
android:layout_height="0dp"
は match_constraint を意味する。
- Activity のコード
- Kotlin Android Extensions の View Binding によって、RecyclerView が変数 content に 設定されている。
- ItemTouchHelper と RecyclerView を結びつけている (ItemMotionHandler は後述)
- RecyclerView.Adapter のコード
- ViewHolder から Adapter のプロパティを参照するために、ViewHolder は inner class にしている。
- ItemMotionHandler (後述) を使うために、LayeredViewHolder を継承している。
- LayeredViewHolder の抽象プロパティである foreground, background を実装している。
- ViewHolder では、Kotlin Android Extensions の View Binding によって、変数 textView, buttonDelete などに各 View を設定している。
- ViewHolder が保持する View のレイアウト の id と対応している。
- foregroundPane と backgroundPane は識別子が他と重複してしまうため、import で別名を定義している。
ItemMotionHandler
今回のサンプルの肝は ItemMotionHandler.kt です。 ItemTouchHelper.Callback を実装したクラスになっています。
移動中の表示処理
特に重要な部分は onChildDraw メソッドのオーバーライドです。各項目を操作(上下移動、左右移動)したときの表示を制御しています。制御の切り分けは、dX の値によって行っています。dX は、移動していない基準の位置からの移動量です。
dX が 0 になるのは、横移動が完了した時と、縦移動をしている最中です。これらの場合には、onDraw に onChildDraw の引数の値をそのまま渡すことで、ItemTouchHelper の標準動作を行っています。
右方向に動かす場合は、dX > 0 になります。この場合、background は移動せず、foreground だけを移動します。そのため、onDraw の x が異なっています。(dY は 0 になるので同じ)。foreground の x は background の横幅を超えない様になっています。
左方向に動かす場合は、dX < 0 になります。左に動かす必要があるのは、メニューが開いている項目だけです。mLockedForeground には、メニューが開いている項目の foregroud の View が設定されています。foreground の View を比較することでメニューが開いている項目かを判定し、移動が不要であればメソッドを終了します。左に移動させる場合には、foreground だけを移動させます。そのため、右方向同様に onDraw の引数が異なっています。x の範囲を background の範囲を超えない様にする点も同様です。
但し、ここで一つ注意があります。左方向に移動したときに dX < 0 となるのは、onSwiped メソッド内で mItemTouchHelper.onChildViewDetachedFromWindow
を行っているためです。この処理が無い場合、左方向の移動の場合でも dX > 0 になります。このあたりの理由は、onChildDraw の長いコメントを参照してください。
メニュー表示の解除
mLockedForeground には、メニューが表示されている項目の foreground の View が設定されています。これを使って、メニュー表示の解除を実現しています。
他の項目のメニューを表示する場合、項目を上下方向に移動する場合、メニューを閉じる場合には mLockedForeground に null を設定して、表示を基準の位置に戻します。これらは、unlockForeground メソッドの呼び出しや、onSwiped メソッド内の direction が ItemTouchHelper.START の場合の処理で実現しています。他の項目のメニューを表示する場合と項目を上下方向に移動する場合は、強制的に表示を元に戻すために clearView メソッドを呼び出しています。onSwiped メソッドが呼び出された場合には表示位置が基準の位置に戻っているので、clearView の呼び出しはしていません。
foreground.addOnLayoutChangeListener(mUnlockForeground)
は、RecyclerView をスクロールした時に foreground を元に戻すために行っています。設定しないと、foreground がリサイクルされた他の項目でメニューが表示されます。
ドラッグ&ドロップ
ドラッグの開始位置とドロップ位置を通知する仕組みを用意しています。onDrop プロパティにリスナーオブジェクトを設定するとドラッグ&ドロップの機能が有効になり、ドロップした時点でリスナーが呼び出されます。
onMove メソッドで notifyItemMoved メソッドを呼び出すと RecyclerView の項目が入れ替わります。onMove は項目の位置が一つ入れ替わる毎に呼び出されます。ドラッグの開始位置とドロップ位置を取得したい場合には、不都合です。一方、onSelectedChanged メソッドは、上下左右の移動が開始/終了したタイミングで呼び出されます。actionState が 2 の場合は、上下移動の開始を意味しています。上下移動が開始した時点で mMoveFrom を設定し、移動終了 (actionState が 0 になった) タイミングで onDrag のリスナーを呼び出します。左右移動が終了した場合には、mMoveFrom が null になっているため onDrag は呼び出されません。
ItemMotionHandler の生成
ItemMotionHandler のインスタンスは newInstance メソッドで生成します。コンストラクタは使えません。
ItemMotionHandler は mItemTouchHelper.onChildViewDetachedFromWindow
を行うために ItemTouchHelper のオブジェクトを保持する必要があります。ItemTouchHelper はコンストラクタに ItemTouchHelper.Callback (このサンプルでは ItemMotionHandler) を渡す必要があり、ItemMotionHandler のコンストラクタで ItemTouchHelper のオブジェクトを受け取ることができません。コンストラクタでの設定ができないため ItemTouchHelper を var にしたプロパティに設定しています。しかし、ItemTouchHelper を可変のままで public に公開するのは危ないため、プロパティ mItemTouchHelper は private var とし、newInstance メソッドで ItemTouchHelper と ItemMotionHandler を生成して関連づけています。ItemMotionHandler の生成は newInstance メソッドに限定したいため、ItemMotionHandler のコンストラクタは private にしています。
ItemTouchHelper の onChildViewDetachedFromWindow メソッドを呼び出す方法は、ItemTouchHelper の実装が変更された場合の影響を受けるため危険ではありますが、コード量をできる限り減らして実現する方法を模索した結果としての妥協案です。他にもっと良い実装がある場合には教えてください。