Edited at

RecyclerViewでFastScrollを実装する

More than 1 year has passed since last update.

この記事はAndroidその2 Advent Calendar 2016 の13日目の記事です。

2018/08/17リファクタリングとKotlin化しました。


はじめに

みなさん、RecyclerViewでもListViewと同じようにFastScrollしたいですよね?

ListViewでFastScrollをするためには、 setFastScrollEnabled()true にすすだけで良いですが、RecyclerViewにはそのようなメソッドは用意されていないため自分で実装する必要があります。

そこで、この記事ではRecyclerViewにFastScrollを実装するベストプラクティスをまとめました。


実装方法


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


参考