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

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

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

open class InteractiveHelper(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
    }

    private var draggingFrom = -1

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

    // Leaking this in constructor of non-final class
    // init内でthisを参照して初期化をしてはいけない
    // 循環参照対策のつもり。もっと良い方法があるかも
    private val 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

                    if (viewHolder.foregroundKnobLayout.translationX >= viewHolder.backgroundLeftButtonLayout.width) {
                        repeat(viewHolder.backgroundLeftButtonLayout.childCount) {
                            val rect = Rect()
                            val button = viewHolder.backgroundLeftButtonLayout.getChildAt(it)
                            button.getGlobalVisibleRect(rect)
                            if (rect.contains(event.rawX.toInt(), event.rawY.toInt())) {
                                button.performClick()
                                return@setOnTouchListener true
                            }
                        }
                    }

                    if (viewHolder.foregroundKnobLayout.translationX <= -viewHolder.backgroundRightButtonLayout.width) {
                        repeat(viewHolder.backgroundRightButtonLayout.childCount) {
                            val rect = Rect()
                            val button = viewHolder.backgroundRightButtonLayout.getChildAt(it)
                            button.getGlobalVisibleRect(rect)
                            if (rect.contains(event.rawX.toInt(), event.rawY.toInt())) {
                                button.performClick()
                                return@setOnTouchListener true
                            }
                        }
                    }
                }
            }
            false
        }
    }

    private fun closeForAdapterPosition(position: Int) {
        val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) ?: return
        viewHolder as SwipeViewHolder

        viewHolder.foregroundKnobLayout.animate()
            .translationX(0f)
            .setDuration(300)
            .setInterpolator(FastOutSlowInInterpolator())
            .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 {
        return 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 {
        return 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()
            .translationZ(30f)
            .setDuration(20)
            .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になっているので、残りの幅とpaddingを加味する必要がある
        swipingStartingX = when {
            viewHolder.foregroundKnobLayout.translationX < 0f -> {
                val overlayWidth = viewHolder.itemView.width - viewHolder.backgroundRightButtonLayout.width
                overlayWidth + recyclerView.paddingLeft.toFloat()
            }
            viewHolder.foregroundKnobLayout.translationX > 0f -> {
                val overlayWidth = viewHolder.itemView.width - viewHolder.backgroundLeftButtonLayout.width
                overlayWidth + recyclerView.paddingRight.toFloat()
            }
            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)
        }
    }

    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

        // ボタンレイアウトの横幅分だけスワイプできる
        // フルスワイプ削除は未対応
        viewHolder.foregroundKnobLayout.translationX = if (dX < 0f) {
            min(0f, max(-viewHolder.backgroundRightButtonLayout.width.toFloat(), dX + swipingStartingX))
        } else {
            max(0f, min(viewHolder.backgroundLeftButtonLayout.width.toFloat(), dX - swipingStartingX))
        }
    }

    // Completion

    private fun onMoved(viewHolder: RecyclerView.ViewHolder) {
        if (draggingFrom > -1) {
            viewHolder.itemView.animate()
                .translationZ(0f)
                .setDuration(20)
                .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 value = if (viewHolder.foregroundKnobLayout.translationX < 0f) {
            val padding = if (swipingStartingX > 0f) recyclerView.paddingLeft else 0
            (0.5f * viewHolder.backgroundRightButtonLayout.width + padding) / recyclerView.width
        } else {
            val padding = if (swipingStartingX > 0f) recyclerView.paddingRight else 0
            (0.5f * viewHolder.backgroundLeftButtonLayout.width + padding) / recyclerView.width
        }

        // スワイプメニューを閉じる時は、反対方向からの割合に変えないといけない
        return if (swipingStartingX > 0f) 1f - value else value
    }

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

    /**
     *
     *  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() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setupRecyclerView()
    }

    private fun setupRecyclerView() {
        recyclerView.adapter = RecyclerAdapter((0..29).toMutableList())
        recyclerView.layoutManager = LinearLayoutManager(this)
        InteractiveHelper(recyclerView).build()
    }

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

        override fun onItemMove(fromPosition: Int, toPosition: Int) {
            Collections.swap(items, fromPosition, toPosition)
            // positionを表示させている場合など必要ならリロードする
            notifyDataSetChanged()
            Toast.makeText(applicationContext, "$fromPosition -> $toPosition moved!", Toast.LENGTH_SHORT).show()
        }

        override fun getItemCount() = items.size

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerViewHolder {
            val view = LayoutInflater.from(parent.context).inflate(R.layout.main_row, parent,false)
            val viewHolder = RecyclerViewHolder(view)

            // ここでviewHolderに対してまとめてクリックリスナーを設定する

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

            viewHolder.backgroundLeftButtonLayout.findViewById<ImageButton>(R.id.infoButton).setOnClickListener {
                val position = viewHolder.adapterPosition
                Toast.makeText(applicationContext, "$position info button clicked!", Toast.LENGTH_SHORT).show()
            }

            viewHolder.backgroundRightButtonLayout.findViewById<ImageButton>(R.id.shareButton).setOnClickListener {
                val position = viewHolder.adapterPosition
                Toast.makeText(applicationContext, "$position share button clicked!", Toast.LENGTH_SHORT).show()
            }

            viewHolder.backgroundRightButtonLayout.findViewById<ImageButton>(R.id.searchButton).setOnClickListener {
                val position = viewHolder.adapterPosition
                Toast.makeText(applicationContext, "$position search button clicked!", Toast.LENGTH_SHORT).show()
            }

            viewHolder.backgroundRightButtonLayout.findViewById<ImageButton>(R.id.deleteButton).setOnClickListener {
                val position = viewHolder.adapterPosition
                Toast.makeText(applicationContext, "$position delete button clicked!", Toast.LENGTH_SHORT).show()
            }

            return viewHolder
        }

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

    inner class RecyclerViewHolder(view: View): RecyclerView.ViewHolder(view), InteractiveHelper.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)

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

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

レイアウト付きでテストできるようにしたので、良かったらインストールして試してみて下さい。

GitHub