RecyclerViewでFastScrollを実装する

この記事は[Androidその２ Advent Calendar 2016](http://qiita.com/advent-calendar/2016/android_second) の13日目の記事です。
2018/08/17リファクタリングとKotlin化しました。

## はじめに
みなさん、RecyclerViewでもListViewと同じようにFastScrollしたいですよね？
ListViewでFastScrollをするためには、 `setFastScrollEnabled()` を `true` にすすだけで良いですが、RecyclerViewにはそのようなメソッドは用意されていないため自分で実装する必要があります。
そこで、この記事ではRecyclerViewにFastScrollを実装するベストプラクティスをまとめました。
![](https://qiita-image-store.s3.amazonaws.com/0/43128/2a0869e6-b27d-5cf4-d43a-7b78d9b2c9ac.png)

## 実装方法
```RecyclerFastScroller.kt
class RecyclerFastScroller @JvmOverloads constructor(context: Context,
                                                     attrs: AttributeSet? = null,
                                                     defStyleAttr: Int = 0)
    : LinearLayout(context, attrs, defStyleAttr) {

    private val onFastScrollListener: OnFastScrollListener = OnFastScrollListener()

    private var scrollerHeight: Int = 0
    private var recyclerView: RecyclerView? = null
    private var currentAnimator: ObjectAnimator? = null

    companion object {
        private const val BUBBLE_ANIMATION_DURATION: Long = 100
        private const val TRACK_SNAP_RANGE: Int = 5
    }

    init {
        LayoutInflater.from(context).inflate(R.layout.item_fast_scroller, this, true)
        bubble.visibility = View.GONE

        orientation = HORIZONTAL
        clipChildren = false
    }

    override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
        super.onSizeChanged(width, height, oldWidth, oldHeight)

        scrollerHeight = height
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        val action = event?.action ?: return false
        when (action) {
            MotionEvent.ACTION_DOWN -> {
                if (event.x < handle.x - ViewCompat.getPaddingStart(handle)) return false
                currentAnimator?.cancel()

                if (bubble.visibility == View.GONE) {
                    showBubble()
                }
                handle.isSelected = true
                val y = event.y
                setFastScrollerPosition(y)
                setRecyclerViewPosition(y)
                return true
            }
            MotionEvent.ACTION_MOVE -> {
                val y = event.y
                setFastScrollerPosition(y)
                setRecyclerViewPosition(y)
                return true
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                handle.isSelected = false
                hideBubble()
                return true
            }
        }
        return super.onTouchEvent(event)
    }

    override fun onDetachedFromWindow() {
        recyclerView?.removeOnScrollListener(onFastScrollListener)
        super.onDetachedFromWindow()
    }

    fun setRecyclerView(recyclerView: RecyclerView) {
        this.recyclerView = recyclerView
        this.recyclerView?.addOnScrollListener(onFastScrollListener)
    }

    private fun showBubble() {
        bubble.visibility = View.VISIBLE
        currentAnimator?.cancel()
        currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 0f, 1f).apply {
            duration = BUBBLE_ANIMATION_DURATION
            start()
        }
    }

    private fun hideBubble() {
        currentAnimator?.cancel()
        currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 1f, 0f).apply {
            duration = BUBBLE_ANIMATION_DURATION
            addListener(object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator?) {
                    super.onAnimationEnd(animation)

                    hide()
                }

                override fun onAnimationCancel(animation: Animator?) {
                    super.onAnimationCancel(animation)

                    hide()
                }

                private fun hide() {
                    bubble.visibility = View.GONE
                    currentAnimator = null
                }
            })

            start()
        }
    }

    private fun updateFastScrollerPosition() {
        if (handle.isSelected) return
        recyclerView?.let {
            val verticalScrollOffset = it.computeVerticalScrollOffset()
            val verticalScrollRange = it.computeVerticalScrollRange()

            val proportion = verticalScrollOffset.toFloat() / verticalScrollRange
            setFastScrollerPosition(scrollerHeight * proportion)
        }
    }

    private fun setFastScrollerPosition(y: Float) {
        val handleHeight = handle.height
        handle.y = getValueInRange(0, scrollerHeight - handleHeight, (y - (handleHeight / 2)).toInt())

        val bubbleHeight = bubble.height
        bubble.y = getValueInRange(0, scrollerHeight - bubbleHeight - handleHeight / 2, (y - bubbleHeight).toInt())
    }

    private fun setRecyclerViewPosition(y: Float) {
        recyclerView?.let {
            val itemCount = it.adapter.itemCount
            val proportion = when {
                handle.y == 0f -> 0f
                handle.y + handle.height >= scrollerHeight - TRACK_SNAP_RANGE -> 1f
                else -> y / scrollerHeight.toFloat()
            }

            val targetPosition = getValueInRange(0, itemCount - 1, (proportion * itemCount.toFloat()).toInt()).toInt()
            (it.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPosition, 0)

            if (it.adapter is FastScrollable) {
                val bubbleText = (it.adapter as FastScrollable).setBubbleText(targetPosition)
                bubble.text = bubbleText
            }
        }
    }

    private fun getValueInRange(min: Int, max: Int, adjust: Int): Float {
        val minimum = Math.max(min, adjust)
        return Math.min(minimum, max).toFloat()
    }

    private inner class OnFastScrollListener : RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
            updateFastScrollerPosition()
        }
    }

    interface FastScrollable {
        fun setBubbleText(position: Int): String
    }
}
```

```item_fast_scroller.xml
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">

    <TextView
        android:id="@+id/bubble"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/background_fast_scroller_bubble"
        android:gravity="center"
        android:textColor="@android:color/white"
        android:textSize="36sp"
        android:visibility="gone"/>

    <ImageView
        android:id="@+id/handle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:paddingStart="8dp"
        android:src="@drawable/background_fast_scroller_handle"/>

</merge>
```

```background_fast_scroller_bubble.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
       android:layout_width="50dp"
       android:layout_height="50dp"
       android:shape="rectangle">

    <corners
        android:bottomLeftRadius="44dp"
        android:bottomRightRadius="0px"
        android:topLeftRadius="44dp"
        android:topRightRadius="44dp"/>

    <solid android:color="@color/colorAccent"/>

    <size
        android:width="88dp"
        android:height="88dp"/>
</shape>
```
```background_fast_scroller_handle.xml
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_selected="true">
        <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">

            <corners android:radius="2dp"/>

            <solid android:color="@color/colorAccent"/>

            <size android:width="4dp" android:height="32dp"/>
        </shape>
    </item>

    <item>
        <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">

            <corners android:radius="2dp"/>

            <solid android:color="#666666"/>

            <size android:width="4dp" android:height="32dp"/>
        </shape>
    </item>
</selector>
```

## 使い方

```MainActivity.kt
fast_scroller.setRecyclerView(recycler_view)
```

RecyclerViewのAdapterに`RecyclerFastScroller.FastScrollable`を実装してください。`setBubbleText`で返した文字列がBubbleのところに表示されます。

```ItemAdapter.kt
class ItemAdapter(private val context: Context, list: List<String>? = null) : RecyclerView.Adapter<ItemAdapter.ViewHolder>(), RecyclerFastScroller.FastScrollable {

    override fun setBubbleText(position: Int): String {
        return items[position]
    }

    // 中略
}
```

## 最後に
サンプルプロジェクトです。
https://github.com/nakker1218/FastScroller

#参考
- https://github.com/timusus/RecyclerView-FastScroll
- http://tiro105.hateblo.jp/entry/2014/06/23/162310
- http://stackoverflow.com/questions/27798212/how-to-add-a-fast-scroller-to-the-recyclerview
- https://blog.stylingandroid.com/recyclerview-fastscroll-part-1/
- https://blog.stylingandroid.com/recyclerview-fastscroll-part-2/