ItemTouchHelperを使ってスワイプメニューと並べ替えに対応してみる

優秀なライブラリは沢山ありますが自作してみました。フルスワイプ削除には対応していません。Android 7と11でテストしています。

ItemTouchHelper.Callbackをカスタマイズする


open class OneTouchHelperCallback(private val recyclerView: RecyclerView): ItemTouchHelper.Callback() {

    // ドラッグ移動に適合させる
    interface DragAdapter {

        fun onItemMove(fromPosition: Int, toPosition: Int)
    }

    // スワイプメニューに適合させる
    interface SwipeViewHolder {

        val foregroundKnobLayout: ViewGroup

        // 片側だけ無効にしたければボタンを Gone してレイアウトの横幅をゼロにすれば良い
        val backgroundLeftButtonLayout: ViewGroup
        val backgroundRightButtonLayout: ViewGroup

        // 最初のボタンの onClickListener が呼ばれる
        val canRemoveOnSwipingFromLeft: Boolean get() = false
        // 最後のボタンの onClickListener が呼ばれる
        val canRemoveOnSwipingFromRight: Boolean get() = false
    }

    private var draggingFrom = -1

    private var swipingStartingX = 0f
    private var swipingAdapterPosition = -1

    // Leaking this in constructor of non-final class
    private var helper: WeakReference<ItemTouchHelper?>? = null

    // helper が間に合わないので初期化した後で呼ぶ
    fun build() {
        helper = WeakReference(ItemTouchHelper(this))
        helper?.get()?.attachToRecyclerView(recyclerView)

        // スワイプメニュー表示中ならスクロール開始で閉じる
        // スクロールアウトした後で閉じるより安全そう
        recyclerView.addOnScrollListener(object: RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                when (newState) {
                    RecyclerView.SCROLL_STATE_DRAGGING -> {
                        if (swipingAdapterPosition > -1) {
                            val position = swipingAdapterPosition
                            swipingAdapterPosition = -1
                            closeForAdapterPosition(position)
                        }
                    }
                }
            }
        })

        // スワイプメニューのボタンは ItemTouchHelper が効いていて onClickListener が反応しないので onTouchListener を使ってボタンの境界を判定して発動させる
        recyclerView.setOnTouchListener { view, event ->
            when (event.action) {
                MotionEvent.ACTION_UP -> {
                    val childView = recyclerView.findChildViewUnder(event.x, event.y) ?: return@setOnTouchListener false
                    val adapterPosition = recyclerView.getChildAdapterPosition(childView)
                    val viewHolder = recyclerView.findViewHolderForAdapterPosition(adapterPosition) as? SwipeViewHolder ?: return@setOnTouchListener false
                    val tolerance = 3 * view.resources.displayMetrics.density.toInt()

                    if (viewHolder.foregroundKnobLayout.translationX >= viewHolder.backgroundLeftButtonLayout.width - tolerance) {
                        (0 until viewHolder.backgroundLeftButtonLayout.childCount).map(viewHolder.backgroundLeftButtonLayout::getChildAt).forEach { button ->
                            val rect = Rect()
                            button.getGlobalVisibleRect(rect)
                            if (rect.contains(event.rawX.toInt(), event.rawY.toInt())) {
                                button.performClick()
                                return@setOnTouchListener true
                            }
                        }
                    }

                    if (viewHolder.foregroundKnobLayout.translationX <= -viewHolder.backgroundRightButtonLayout.width + tolerance) {
                        (0 until viewHolder.backgroundRightButtonLayout.childCount).map(viewHolder.backgroundRightButtonLayout::getChildAt).forEach { button ->
                            val rect = Rect()
                            button.getGlobalVisibleRect(rect)
                            if (rect.contains(event.rawX.toInt(), event.rawY.toInt())) {
                                button.performClick()
                                return@setOnTouchListener true
                            }
                        }
                    }
                }
            }
            false
        }
    }

    /**
     *
     *  スワイプメニューを閉じるアニメーション中も onChildDraw が反応する
     *
     *  1、閉じる前に clearView すると onChildDraw は反応しなくなるが、アニメーションが効かなくなって半開きのまま再利用されてしまう
     *  2、アニメーション終了後に notifyItemChanged して再度開く時の onChildDraw の dX をリセットしておく必要がある
     *  3、notifyItemChanged を使うと、高速でスワイプさせた時に複数箇所を開けてしまい挙動が安定しない
     *
     *  アニメーション開始前に ItemTouchHelper でリセットしておくと、これらの問題を解消できる
     *  アニメーション中のフラグ isAnimating が無いので isClickable で代用する
     *
     */
    private fun closeForAdapterPosition(position: Int) {
        val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) ?: return
        viewHolder as SwipeViewHolder

        viewHolder.foregroundKnobLayout.animate()
            .setDuration(300)
            .setInterpolator(FastOutSlowInInterpolator())
            .translationX(0f)
            .withStartAction {
                viewHolder.foregroundKnobLayout.isClickable = false
                helper?.get()?.onChildViewDetachedFromWindow(viewHolder.itemView)
                helper?.get()?.onChildViewAttachedToWindow(viewHolder.itemView)
            }
            .withEndAction {
                viewHolder.foregroundKnobLayout.isClickable = true
            }
            .start()
    }

    override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
        // ItemTouchHelper.ACTION_STATE_IDLE なら onClickListener が効くようになる
        val drag = getDragDirs(recyclerView, viewHolder)
        val swipe = getSwipeDirs(recyclerView, viewHolder)
        return makeMovementFlags(drag, swipe)
    }

    open fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int = when {
        recyclerView.adapter !is DragAdapter -> ItemTouchHelper.ACTION_STATE_IDLE
        // スワイプメニュー表示中はドラッグを開始させてはいけない
        viewHolder is SwipeViewHolder && viewHolder.foregroundKnobLayout.translationX != 0f -> ItemTouchHelper.ACTION_STATE_IDLE
        recyclerView.layoutManager !is GridLayoutManager -> ItemTouchHelper.UP or ItemTouchHelper.DOWN
        else -> ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT or ItemTouchHelper.UP or ItemTouchHelper.DOWN
    }

    open fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int = when {
        viewHolder !is SwipeViewHolder -> ItemTouchHelper.ACTION_STATE_IDLE
        viewHolder.backgroundLeftButtonLayout.width == 0 -> ItemTouchHelper.LEFT
        viewHolder.backgroundRightButtonLayout.width == 0 -> ItemTouchHelper.RIGHT
        else -> ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
    }

    override fun canDropOver(recyclerView: RecyclerView, current: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean = current.itemViewType == target.itemViewType

    // Start

    override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
        super.onSelectedChanged(viewHolder, actionState)

        when (actionState) {
            ItemTouchHelper.ACTION_STATE_DRAG -> onSelectedChangedForDrag(viewHolder, actionState)
            // ACTION_STATE_IDLE: viewHolder is null
            // ACTION_STATE_IDLE も含めないとメニューボタンをタップした時に勝手に閉じてしまう
            ItemTouchHelper.ACTION_STATE_IDLE,
            ItemTouchHelper.ACTION_STATE_SWIPE -> onSelectedChangedForSwipe(viewHolder, actionState)
        }
    }

    private fun onSelectedChangedForDrag(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
        viewHolder ?: return

        // スワイプメニュー表示中なら閉じる
        closeForAdapterPosition(swipingAdapterPosition)

        draggingFrom = viewHolder.adapterPosition
        viewHolder.itemView.animate()
            .setDuration(20)
            .translationZ(30f)
            .start()
    }

    private fun onSelectedChangedForSwipe(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
        if (viewHolder !is SwipeViewHolder) return

        if (viewHolder.foregroundKnobLayout.isClickable == false) {
            return
        }

        if (swipingAdapterPosition != viewHolder.adapterPosition) {
            // スワイプメニュー表示中なら閉じる
            val position = swipingAdapterPosition
            swipingAdapterPosition = viewHolder.adapterPosition
            closeForAdapterPosition(position)
        }

        // スワイプメニューはリロードとスクロールで自動的に閉じられて状況が変わってしまうので、開閉の判断はスワイプ開始時に行う方が合理的
        // スワイプメニューを閉じる時は、全開状態の dX になっているので、半開き分を調整する必要がある
        swipingStartingX = when {
            viewHolder.foregroundKnobLayout.translationX < 0f -> recyclerView.width.toFloat() - viewHolder.backgroundRightButtonLayout.width
            viewHolder.foregroundKnobLayout.translationX > 0f -> recyclerView.width.toFloat() - viewHolder.backgroundLeftButtonLayout.width
            else -> 0f
        }
    }

    // Drawing

    override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) {
        when (actionState) {
            ItemTouchHelper.ACTION_STATE_DRAG -> onChildDrawForDrag(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
            ItemTouchHelper.ACTION_STATE_SWIPE -> onChildDrawForSwipe(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
        }
    }

    // canDropOver を参照して呼ばれる
    override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
        recyclerView.adapter?.notifyItemMoved(viewHolder.adapterPosition, target.adapterPosition)
        return true
    }

    private fun onChildDrawForDrag(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) {
        viewHolder.itemView.translationX = dX
        viewHolder.itemView.translationY = dY
    }

    private fun onChildDrawForSwipe(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) {
        if (swipingAdapterPosition != viewHolder.adapterPosition) {
            return
        }

        viewHolder as SwipeViewHolder

        if (viewHolder.foregroundKnobLayout.isClickable == false) {
            // 半開き状態までアニメーション中
            return
        }

        viewHolder.foregroundKnobLayout.translationX = if (dX < 0f) {
            if (viewHolder.canRemoveOnSwipingFromRight)
                min(0f,dX + swipingStartingX)
            else
                min(0f, max(-viewHolder.backgroundRightButtonLayout.width.toFloat(), dX + swipingStartingX))
        } else {
            if (viewHolder.canRemoveOnSwipingFromLeft)
                max(0f,dX - swipingStartingX)
            else
                max(0f, min(viewHolder.backgroundLeftButtonLayout.width.toFloat(), dX - swipingStartingX))
        }

        // フルスワイプした時に反対側のボタンが見えないように隠しておく
        viewHolder.backgroundLeftButtonLayout.visibility = if (dX < 0f) ViewGroup.INVISIBLE else ViewGroup.VISIBLE
        viewHolder.backgroundRightButtonLayout.visibility = if (dX < 0f) ViewGroup.VISIBLE else ViewGroup.INVISIBLE
    }

    // Completion

    private fun onMoved(viewHolder: RecyclerView.ViewHolder) {
        if (draggingFrom > -1) {
            viewHolder.itemView.animate()
                .setDuration(20)
                .translationZ(0f)
                .withStartAction {
                    val from = draggingFrom
                    val to = viewHolder.adapterPosition
                    if (from != to) {
                        (recyclerView.adapter as DragAdapter).onItemMove(from, to)
                    }
                }
                .withEndAction {
                    draggingFrom = -1
                }
                .start()
        }
    }

    override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
        viewHolder as SwipeViewHolder

        val dX = viewHolder.foregroundKnobLayout.translationX

        // フルスワイプ削除について
        // スワイプメニューを開く時は、閾値を返して onSwiped に任せる
        // スワイプメニューを閉じる時は、半開き状態が記憶されていて、閾値を返しても全開にできないのでここで処理する
        if (dX < 0f) {
            val x = viewHolder.itemView.width - (viewHolder.itemView.width - viewHolder.backgroundRightButtonLayout.width) / 2f
            if (dX < -x) {
                if (swipingStartingX > 0f) {
                    viewHolder.foregroundKnobLayout.animate()
                        .setDuration(250)
                        .setInterpolator(FastOutSlowInInterpolator())
                        .translationX(-recyclerView.width.toFloat())
                        .withStartAction {
                            viewHolder.foregroundKnobLayout.isClickable = false
                        }
                        .withEndAction {
                            onSwiped(viewHolder, ItemTouchHelper.LEFT)
                            viewHolder.foregroundKnobLayout.isClickable = true
                        }
                        .start()
                }
                return 0.1f
            }
        } else {
            val x = viewHolder.itemView.width - (viewHolder.itemView.width - viewHolder.backgroundLeftButtonLayout.width) / 2f
            if (dX > x) {
                if (swipingStartingX > 0f) {
                    viewHolder.foregroundKnobLayout.animate()
                        .setDuration(250)
                        .setInterpolator(FastOutSlowInInterpolator())
                        .translationX(recyclerView.width.toFloat())
                        .withStartAction {
                            viewHolder.foregroundKnobLayout.isClickable = false
                        }
                        .withEndAction {
                            onSwiped(viewHolder, ItemTouchHelper.RIGHT)
                            viewHolder.foregroundKnobLayout.isClickable = true
                        }
                        .start()
                }
                return 0.1f
            }
        }

        // スワイプメニューの半開き状態について
        // スワイプメニューを開く時に全開しないように、独自のアニメーションで堰き止める
        if (swipingStartingX == 0f) {
            var x = viewHolder.backgroundRightButtonLayout.width / 2f
            if (dX < -x) {
                viewHolder.foregroundKnobLayout.animate()
                    .setDuration(250)
                    .setInterpolator(FastOutSlowInInterpolator())
                    .translationX(-viewHolder.backgroundRightButtonLayout.width.toFloat())
                    .withStartAction {
                        viewHolder.foregroundKnobLayout.isClickable = false
                    }
                    .withEndAction {
                        viewHolder.foregroundKnobLayout.isClickable = true
                    }
                    .start()
            }

            x = viewHolder.backgroundLeftButtonLayout.width / 2f
            if (dX > x) {
                viewHolder.foregroundKnobLayout.animate()
                    .setDuration(250)
                    .setInterpolator(FastOutSlowInInterpolator())
                    .translationX(viewHolder.backgroundLeftButtonLayout.width.toFloat())
                    .withStartAction {
                        viewHolder.foregroundKnobLayout.isClickable = false
                    }
                    .withEndAction {
                        viewHolder.foregroundKnobLayout.isClickable = true
                    }
                    .start()
            }
        }

        // ボタンレイアウトの中心にする
        val value = if (dX < 0f) {
            viewHolder.backgroundRightButtonLayout.width / 2f / recyclerView.width
        } else {
            viewHolder.backgroundLeftButtonLayout.width / 2f / recyclerView.width
        }

        // スワイプメニューを閉じる時は、反対方向からの割合に変えないといけない
        return value.takeIf { swipingStartingX <= 0f } ?: 1 - value
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        viewHolder as SwipeViewHolder

        val dX = viewHolder.foregroundKnobLayout.translationX

        // 通常のフルスワイプ削除と getSwipeThreshold を経由しない高速フルスワイプ削除
        if (abs(dX) >= recyclerView.width.toFloat()) {
            if (direction == ItemTouchHelper.LEFT) {
                viewHolder.backgroundRightButtonLayout.getChildAt(viewHolder.backgroundRightButtonLayout.childCount - 1).performClick()
            }
            if (direction == ItemTouchHelper.RIGHT) {
                viewHolder.backgroundLeftButtonLayout.getChildAt(0).performClick()
            }
            // 重複しないように onClickListener 側で呼ぶ必要がある
            //recyclerView.adapter?.notifyItemRemoved(viewHolder.adapterPosition)
        }
    }

    /**
     *
     *  clearView はスワイプメニューを閉じる際にも呼ばれるので、純粋な後処理を検出したい場合は工夫が必要になる
     *
     *  notifyDataSetChanged を検出するには viewHolder.adapterPosition が -1 に変わったかチェックする
     *  notifyItemChanged では -1 に変わらず clearView も呼ばれない
     *
     *  スクロールアウトを検出するには viewHolder.foreground に View.OnLayoutChangeListener を設定する(このリスナーはリロードには反応しない)
     *  getDefaultUIUtil().clearView(viewHolder.foreground) を使うと、スクロールアウトとリロードでスワイプメニューを閉じてくれる
     *
     */
    override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
        if (viewHolder.adapterPosition == -1) {
            (viewHolder as? SwipeViewHolder)?.foregroundKnobLayout?.translationX = 0f
        }

        // ドラッグの完了は viewHolder を参照できるここで検出するのが良さそう
        onMoved(viewHolder)
    }
}

MainActivity

Adapter、ViewHolder、OnClickListener、を設定します。通常はviewHolder.itemViewに設定するクリックリスナーですが、背面にスワイプメニューがあるので、前面のforegroundに設定します。


class MainActivity: AppCompatActivity(R.layout.activity_main) {

    private fun setupRecyclerView() {
        val recyclerView: RecyclerView = findViewById(R.id.recyclerView)
        recyclerView.adapter = RecyclerAdapter((0..29).toMutableList())
        OneTouchHelperCallback(recyclerView).build()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setupRecyclerView()
    }

    private inner class RecyclerAdapter(private val items: MutableList<Int>): RecyclerView.Adapter<RecyclerViewHolder>(), OneTouchHelperCallback.DragAdapter {

        override fun onItemMove(fromPosition: Int, toPosition: Int) {
            Collections.swap(items, fromPosition, toPosition)
            notifyDataSetChanged()
            Toast.makeText(applicationContext, "$fromPosition -> $toPosition moved!", Toast.LENGTH_SHORT).show()
        }

        override fun getItemCount(): Int = items.size

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerViewHolder {
            val view = LayoutInflater.from(parent.context).inflate(R.layout.row, parent,false)
            return RecyclerViewHolder(view).also { viewHolder ->
                viewHolder.foregroundKnobLayout.setOnClickListener {
                    val position = viewHolder.adapterPosition
                    Toast.makeText(applicationContext, "$position knob clicked!", Toast.LENGTH_SHORT).show()
                }

                viewHolder.infoButton.setOnClickListener {
                    val position = viewHolder.adapterPosition
                    Toast.makeText(applicationContext, "$position info button clicked!", Toast.LENGTH_SHORT).show()
                }

                viewHolder.shareButton.setOnClickListener {
                    val position = viewHolder.adapterPosition
                    Toast.makeText(applicationContext, "$position share button clicked!", Toast.LENGTH_SHORT).show()
                }

                viewHolder.searchButton.setOnClickListener {
                    val position = viewHolder.adapterPosition
                    Toast.makeText(applicationContext, "$position search button clicked!", Toast.LENGTH_SHORT).show()
                }

                viewHolder.deleteButton.setOnClickListener {
                    val position = viewHolder.adapterPosition
                    items.removeAt(position)
                    notifyItemRemoved(position)
                }
            }
        }

        override fun onBindViewHolder(viewHolder: RecyclerViewHolder, position: Int) {
            viewHolder.bind(items[position], position)
        }
    }

    private class RecyclerViewHolder(view: View): RecyclerView.ViewHolder(view), OneTouchHelperCallback.SwipeViewHolder {

        override val foregroundKnobLayout: ViewGroup = view.findViewById(R.id.foregroundKnobLayout)
        override val backgroundLeftButtonLayout: ViewGroup = view.findViewById(R.id.backgroundLeftButtonLayout)
        override val backgroundRightButtonLayout: ViewGroup = view.findViewById(R.id.backgroundRightButtonLayout)
        override val canRemoveOnSwipingFromRight: Boolean get() = true

        val infoButton: ImageButton = view.findViewById(R.id.infoButton)
        val shareButton: ImageButton = view.findViewById(R.id.shareButton)
        val searchButton: ImageButton = view.findViewById(R.id.searchButton)
        val deleteButton: ImageButton = view.findViewById(R.id.deleteButton)

        val textView: TextView = view.findViewById(R.id.textView)

        fun bind(item: Int, position: Int) {
            textView.text = position.toString() + ". " + item.toString()
        }
    }
}

ライブラリとしてアプリに導入できます。レイアウト付きでテストできますので、試してみて下さい。

GitHub