一つのRecyclerViewの中でdrag&dropでの並べ替え機能は、ItemTouchHelperを利用すると簡単に実装することができます。
ただ、一つのRecyclerViewの中の並べ替えではなく、複数のRecyclerView間での項目移動もしたくなります。候補リストから取捨選択もできるような機能で見られる以下のような動きですね。
実装方法としてはいくつかあると思いますが、AndroidのDrag&Dropフレームワークを利用する方法で説明しようと思います。ドラッグ中の描画や、対象となるViewの判定処理などを実装する手間がいらないというメリットがありますが、本来想定されている使い方は、View間のDrag&Dropであって、View内の並べ替えではないので、融通が利かない部分もあります。
ベースとなるグリッドを作る
まずは並べ替え対象となる2つのグリッドを作ります。
これは非常にシンプルですね。並べ替えに供えてListAdapterで作っています。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.recyclerView1.adapter = ItemAdapter(this).also { adapter ->
adapter.submitList(List(10) { ItemData(it) })
}
binding.recyclerView2.adapter = ItemAdapter(this).also { adapter ->
adapter.submitList(List(10) { ItemData(it + 10) })
}
}
}
private data class ItemData(val data: Int)
private class ItemViewHolder(val binding: ItemViewBinding) : ViewHolder(binding.root)
private class ItemAdapter(context: Context) :
ListAdapter<ItemData, ItemViewHolder>(object : ItemCallback<ItemData>() {
override fun areItemsTheSame(oldItem: ItemData, newItem: ItemData): Boolean =
oldItem == newItem
override fun areContentsTheSame(oldItem: ItemData, newItem: ItemData): Boolean =
oldItem == newItem
}) {
private val inflater = LayoutInflater.from(context)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder =
ItemViewHolder(ItemViewBinding.inflate(inflater, parent, false))
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
val data = getItem(position)
holder.binding.text.text = data.data.toString()
}
}
これで2つのRecyclerViewでできたグリッドが表示されます。
startDragAndDropのコール
Drag&Dropを開始するにはstartDragAndDrop
をコールします。
アイコンのロングタップで発動させてみましょう。
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
val data = getItem(position)
holder.binding.text.text = data.data.toString()
holder.binding.root.setOnLongClickListener {
val view = holder.itemView
view.startDragAndDrop(EMPTY_CLIP_DATA, View.DragShadowBuilder(view), data, 0)
true
}
}
第一引数はClipDataで、本来はここでDrag&Dropで引き渡すコンテンツを渡すのですが、ここではこの値を使いません。ただ、nullにすることはできないので、適当な値を設定しています。
private val EMPTY_CLIP_DATA = ClipData.newPlainText("", "")
第二引数がDragShadowBuilderで、ドラッグ中のコンテンツを描画するためのインスタンスになります。ここで書いているようにドラッグされるViewを渡すと、それを透過させたものが描画されるようになります。ドラッグ中のコンテンツの描画を変更したい場合は、DragShadowBuilderをカスタムしたインスタンスを渡すなどします。
第三引数が同一Activity内でのDrag&Dropの場合に、各Viewに通知されるデータになります。今回はこちらにドラッグするデータを渡すようにしました。
第四引数はDrag&DropをマルチウィンドウのときにActivityをまたいで行わせるかどうかなどのフラグを設定しますが、今回不要なので0にしています。
OnDragListenerの実装
Drag&Dropフレームワークからのイベントを受け取るにはOnDragListener
を実装する必要があります。
ここでは簡単のため、並べ替えを行うAdapterに実装して、RecyclerViewに設定するようにしてみましょう。
private class ItemAdapter(context: Context) :
ListAdapter<ItemData, ItemViewHolder>(object : ItemCallback<ItemData>() {
...
}), OnDragListener {
...
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
this.recyclerView = recyclerView
recyclerView.setOnDragListener(this)
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
this.recyclerView = null
recyclerView.setOnDragListener(null)
}
override fun onDrag(v: View, event: DragEvent): Boolean {
val data = event.localState as? ItemData ?: return false
when (event.action) {
DragEvent.ACTION_DRAG_STARTED -> Unit
DragEvent.ACTION_DRAG_ENTERED -> dragEnter(data)
DragEvent.ACTION_DRAG_LOCATION -> dragMove(data, event.x, event.y)
DragEvent.ACTION_DRAG_EXITED -> dragExit(data)
DragEvent.ACTION_DROP -> drop(data)
DragEvent.ACTION_DRAG_ENDED -> dragEnd(data, event.result)
else -> Unit
}
return true
}
RecyclerView.AdapterにはonAttachedToRecyclerView``onDetachedFromRecyclerView
という、RecyclerViewへのアタッチデタッチでコールされるメソッドがあるので、これを利用して、setOnDragListener
をコールしています。
OnDragListener
のonDrag
メソッドには、上記のような各状態でのイベントが通知されるので、それに応じた処理を実装していきます。DragEvent.ACTION_DRAG_STARTED
で、このDrag&Dropイベントの対象であるかどうかを戻り値で返します。falseを返すと以降のイベントが通知されなくなります。
DragEvent.localState
にはstartDragAndDrop
の第三引数で渡したデータが入っているので、これがItemDataかどうかで対象イベントかどうかの判定をしています。
ドラッグの座標はDragEvent.ACTION_DRAG_LOCATION
の場合にのみ通知され、それ以外のイベントでは0になっている点は注意です。
並べ替えのための仕込み
ListAdapterでDrag&Dropに伴う並べ替え処理を頻繁に書くことになるので、仕込み実装をいれておきます。
binding.recyclerView1.itemAnimator?.moveDuration = 100L
binding.recyclerView2.itemAnimator?.moveDuration = 100L
本質的ではないですが、DefaultItemAnimator
の並べ替えアニメーションだと、時間がかかりすぎてしまうので短縮してます。
private val handler = Handler(Looper.getMainLooper())
private var submitting = false
override fun submitList(list: List<ItemData>?) {
submitting = true
super.submitList(list) {
handler.post { submitting = false }
}
}
private fun submitList(block: (MutableList<ItemData>) -> Unit) {
currentList.toMutableList().also(block).let { submitList(it) }
}
private fun swap(from: Int, to: Int) {
if (from == to) return
submitList { it.add(to, it.removeAt(from)) }
}
submitList
をoverrideしてsubmit中を判定できるようにしています、ドロップ位置の計算で並べ替え途中の状態を拾ってしまって位置がずれるのを防ぐのに利用します。
また、submitList
で現在のリストを変更して反映という処理が頻繁に出てくるので、変更処理だけをかけるようにオーバーロードしたメソッドを用意しています。
その応用例の一つで、2つデータの位置を入れ替えるswapメソッドを実装しています。swapのロジックは他にも考えられますが玉突きで動かすものを採用しています。from/toが一致している場合は何もしないという判断もメソッド内で行うようにしてます。
ドラッグ状態パラメータ
ドラッグの状態を扱う上で以下の3つのパラメータを使います。
private var dragData: ItemData? = null
private var dragState: State = State.NONE
private var dragStartPosition: Int = -1
private enum class State {
NONE,
MOVING, // 自コンテンツが自View内をドラッグされている
EXIT, // 自コンテンツが外に出て行った
ENTER, // 他コンテンツが自View内をドラッグされている
}
dragData
はドラッグ中のデータを保持します、ドラッグ中の項目は非表示にするか薄く表示するなどの変更を加える必要があるためです。
dragState
はEnumでドラッグの状態を表現。
dragStartPosition
はその名の通りドラッグの開始位置を保持します。
本質的には必須ではないのですが、画面外へドラッグしていった場合など、ドロップ先がない場合に戻るアニメーションの位置をDrag&Dropフレームワークでは変更できないため、このパラメータを使って、アニメーションが不自然にならないように細工します。
ドラッグ中のコンテンツ表示とドラッグ開始
ドラッグ中、ドロップされる候補の位置にはAlpha 0.5で表示、画面外に出た場合は位置だけ確保して非表示となるようにしています。
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
val data = getItem(position)
holder.binding.text.text = data.data.toString()
holder.binding.text.alpha =
if (data == dragData) if (dragState == State.EXIT) 0f else 0.5f else 1f
holder.binding.root.setOnLongClickListener {
dragStart(holder, data)
true
}
}
private fun dragStart(holder: ItemViewHolder, data: ItemData) {
dragData = data
dragState = State.MOVING
dragStartPosition = holder.adapterPosition
val view = holder.itemView
view.startDragAndDrop(EMPTY_CLIP_DATA, View.DragShadowBuilder(view), data, 0)
}
ドラッグ開始では各パラメータを変更してから開始します。
ACTION_DRAG_ENTERED
Drag&Dropでドラッグされたコンテンツが自View内に入ってきたイベントの処理です。
private fun dragEnter(data: ItemData) {
if (dragState == State.NONE) {
dragState = State.ENTER
dragData = data
return
}
if (dragState == State.EXIT) {
dragState = State.MOVING
notifyItemChanged(currentList.indexOf(data))
}
}
外部のコンテンツが入ってきた場合、そのコンテンツを自View内に表示させるのですが、この時点ではまだ座標情報が無いため、入ってきたという記録だけで終わっています。
自コンテンツが、外に出てから入ってきた場合は非常時になっていた候補を透過表示に変えるため、notifyItemChanged
をコールします
ACTION_DRAG_LOCATION
Drag&Dropでドラッグ中の座標変化が通知されます。
座標からその位置にあるViewHolderを見つけるメソッドを用意します。
RecyclerViewのfindChildViewUnder
で指定座標にあるViewを見つけることができ、findContainingViewHolder
でそのViewを保持するViewHolderを見つけることができるので、これを組み合わせます。
private fun findViewHolder(x: Float, y: Float): ViewHolder? {
val recyclerView = recyclerView ?: return null
return recyclerView.findChildViewUnder(x, y)?.let {
recyclerView.findContainingViewHolder(it)
}
}
移動に伴う処理は以下のようになります
private fun dragMove(data: ItemData, x: Float, y: Float) {
if (submitting) return
if (recyclerView?.isAnimating != false) return
val fromPosition = currentList.indexOf(data)
if (fromPosition < 0) {
val target = findViewHolder(x, y)
if (target == null) {
submitList { it.add(data) }
} else {
submitList { it.add(target.adapterPosition, data) }
}
return
}
val target = findViewHolder(x, y) ?: return
swap(fromPosition, target.adapterPosition)
}
リストを変化させた直後やアニメーション中は余計な並べ替えが発生しないように制限をかけています。
現在のリスト中に対象データが含まれない場合は、それをリスト内に追加します。既存のアイテムの上にいる場合は、その位置に挿入、そうでない場合は末尾に挿入としています。単純に末尾とせずに座標からより適切な位置を計算するなどしても良いでしょう。
現在のリスト中にすでに対象データがある場合は、ドラッグ位置に応じて並べ替えを行います。
DragEvent.ACTION_DRAG_EXITED
ドラッグ中にViewの外に出て行ったイベントです。
先に説明しましたが、Drag&Dropフレームワークでは、画面の外などへドラッグされた場合、どこにもドロップされず、失敗扱いになりますが、その場合、DragShadowが元の位置に戻るようなアニメーションが描画されてしまい、これをOFFにする方法がどうやらなさそう。。
ということで、自コンテンツが外に出て行った場合は、そのコンテンツが元あった位置に戻して非表示にしておくような対応を入れています。自コンテンツ出ない場合は気にする必要が無いので単純にリストから削除してしまいます。
private fun dragExit(data: ItemData) {
val position = currentList.indexOf(data)
if (dragState == State.MOVING) {
dragState = State.EXIT
if (position < 0) {
submitList { it.add(dragStartPosition, data) }
return
}
notifyItemChanged(position)
swap(position, dragStartPosition)
return
}
dragState = State.NONE
dragData = null
if (position < 0) return
submitList { it.removeAt(position) }
}
DragEvent.ACTION_DROP
ドロップ発生、これはコンテンツがドロップされたViewにのみ発生するイベントです。
ドラッグ状態を終了して、ドロップターゲットとして透過表示させていたコンテンツを確定表示に変更します。
private fun drop(data: ItemData) {
dragState = State.NONE
dragData = null
val position = currentList.indexOf(data)
if (position < 0) {
submitList { it.add(data) }
} else {
notifyItemChanged(position)
}
}
コンテンツが自Viewに入ってきてから実際に配置されるまでの隙間が存在するため、Dropされて、自View内にあるはずだがまだリストに追加されていない場合は末尾に追加します。
DragEvent.ACTION_DRAG_ENDED
ドラッグイベントの終了で、全Viewに通知されます。また、結果としてどこかにドロップされた場合はtrue、どこにもDropされなければfalseが渡されます。
private fun dragEnd(data: ItemData, succeed: Boolean) {
val position = currentList.indexOf(data)
if (position < 0) return
if (succeed && dragState == State.EXIT) {
submitList { it.removeAt(position) }
} else {
notifyItemChanged(position)
}
dragState = State.NONE
dragData = null
dragStartPosition = -1
}
自コンテンツが外に出た状態でどこかにdropされた場合、自コンテンツを削除します。
一つ一つは比較的小さな単純な処理ですが、考えないといけないことが結構多くて大変です。できるだけ整理したつもりですが、無駄だったり、考慮漏れがあったりするかもしれません。
以上です。