6
9

More than 1 year has passed since last update.

複数のRecyclerView間での移動を含むドラッグドロップ並べ替え機能を実装する

Last updated at Posted at 2022-12-28

一つの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をコールしています。
OnDragListeneronDragメソッドには、上記のような各状態でのイベントが通知されるので、それに応じた処理を実装していきます。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された場合、自コンテンツを削除します。


一つ一つは比較的小さな単純な処理ですが、考えないといけないことが結構多くて大変です。できるだけ整理したつもりですが、無駄だったり、考慮漏れがあったりするかもしれません。

以上です。

6
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
9