当时是为了满足需求, 没想太多顺手写的. 虽然功能上能满足, 但是代码上还是有点low.

这几天, 我的徒弟傻豆 在写一个IM项目, 需要滚动到底部. 于是我重写了一个ScrollHelper滚动操作类.

文章目录

  • 特性
  • 需求分析
  • 1.滚动, 滚动偏移, 滚动动画
  • 2. 滚动到顶部, 底部, 居中
  • 3.锁定滚动
  • 使用方法
  • 1.初始化
  • 2.操作方法
  • 3.锁定滚动


特性

  • 1.支持滚动时的动画控制
  • 2.支持滚动到任意position
  • 3.支持滚动offset控制
  • 4.支持滚动到顶部or底部or居中
  • 5.支持锁定滚动, 短时间之内强制滚动到目标position
  • 6.支持智能锁定滚动(达到某个条件, 自动滚动到设置的目标position)

需求分析

1.滚动, 滚动偏移, 滚动动画

需要动画使用:

//带偏移, 带动画
androidx.recyclerview.widget.RecyclerView#smoothScrollBy
//滚动, 带动画
androidx.recyclerview.widget.RecyclerView#smoothScrollToPosition

不需要动画使用:

//带偏移, 不带动画
androidx.recyclerview.widget.RecyclerView#scrollBy
//滚动, 不带动画
androidx.recyclerview.widget.RecyclerView#scrollToPosition

注意:
如果触发了androidx.recyclerview.widget.RecyclerView.ItemAnimator动画,
那么androidx.recyclerview.widget.RecyclerView#scrollToPositionandroidx.recyclerview.widget.RecyclerView#smoothScrollToPosition 都会在一定程度上产生滚动动画.

提示
为什么需要使用scrollToPositionscrollBy呢?
这里给大家推荐一套滚动方案:
如果需要滚动的目标已经出现在屏幕内, 那么直接使用scrollByorsmoothScrollBy.
如果需要滚动的目标没有出现在屏幕内, 那么先使用scrollToPositionorsmoothScrollToPosition,再使用scrollByorsmoothScrollBy.

如果调用了androidx.recyclerview.widget.RecyclerView.Adapter#notifyItemInserted, 那么scrollToPositionorsmoothScrollToPosition方法可能会无效果.通常此时都需要使用post, 文章后面会给出我的方法.

2. 滚动到顶部, 底部, 居中

需要细粒度的控制滚动, 必须要保证目标已经出现的屏幕内, 才看完美控制.

控制方法就是scrollByorsmoothScrollBy.

/**当需要滚动的目标位置已经在屏幕上可见*/
internal fun scrollWithVisible(scrollParams: ScrollParams) {
    when (scrollType) {
        SCROLL_TYPE_NORMAL -> {//不处理
            //nothing
        }
        SCROLL_TYPE_TOP -> {//滚动到顶部
            viewByPosition(scrollParams.scrollPosition)?.also { child ->
                recyclerView?.apply {
                    val dx = layoutManager!!.getDecoratedLeft(child) -
                            paddingLeft - scrollParams.scrollOffset

                    val dy = layoutManager!!.getDecoratedTop(child) -
                            paddingTop - scrollParams.scrollOffset

                    if (scrollParams.scrollAnim) {
                        smoothScrollBy(dx, dy)
                    } else {
                        scrollBy(dx, dy)
                    }
                }
            }
        }
        SCROLL_TYPE_BOTTOM -> {//滚动到底部
            viewByPosition(scrollParams.scrollPosition)?.also { child ->
                recyclerView?.apply {
                    val dx =
                        layoutManager!!.getDecoratedRight(child) -
                                measuredWidth + paddingRight + scrollParams.scrollOffset
                    val dy =
                        layoutManager!!.getDecoratedBottom(child) -
                                measuredHeight + paddingBottom + scrollParams.scrollOffset

                    if (scrollParams.scrollAnim) {
                        smoothScrollBy(dx, dy)
                    } else {
                        scrollBy(dx, dy)
                    }
                }
            }
        }
        SCROLL_TYPE_CENTER -> {//滚动到居中
            viewByPosition(scrollParams.scrollPosition)?.also { child ->

                recyclerView?.apply {
                    val recyclerCenterX =
                        (measuredWidth - paddingLeft - paddingRight) / 2 + paddingLeft

                    val recyclerCenterY =
                        (measuredHeight - paddingTop - paddingBottom) / 2 + paddingTop

                    val dx = layoutManager!!.getDecoratedLeft(child) - recyclerCenterX +
                            layoutManager!!.getDecoratedMeasuredWidth(child) / 2 + scrollParams.scrollOffset

                    val dy = layoutManager!!.getDecoratedTop(child) - recyclerCenterY +
                            layoutManager!!.getDecoratedMeasuredHeight(child) / 2 + scrollParams.scrollOffset

                    if (scrollParams.scrollAnim) {
                        smoothScrollBy(dx, dy)
                    } else {
                        scrollBy(dx, dy)
                    }
                }
            }
        }
    }
}

private fun viewByPosition(position: Int): View? {
    return recyclerView?.layoutManager?.findViewByPosition(position)
}

3.锁定滚动

锁定滚动我这里使用了ViewTreeObserver.OnGlobalLayoutListenerorViewTreeObserver.OnDrawListener当做触发时机, 这样就不用自己写handle post了, 而且触发更及时.

inner abstract class LockScrollListener : ViewTreeObserver.OnGlobalLayoutListener,
   ViewTreeObserver.OnDrawListener,
   IAttachListener, Runnable {

   /**激活滚动动画*/
   var scrollAnim: Boolean = true
   /**激活第一个滚动的动画*/
   var firstScrollAnim: Boolean = false

   /**不检查界面 情况, 强制滚动到最后的位置. 关闭后. 会智能判断*/
   var force: Boolean = false

   /**第一次时, 是否强制滚动*/
   var firstForce: Boolean = true

   /**滚动阈值, 倒数第几个可见时, 就允许滚动*/
   var scrollThreshold = 2

   /**锁定需要滚动的position, -1就是最后一个*/
   var lockPosition = RecyclerView.NO_POSITION

   /**是否激活功能*/
   var enableLock = true

   /**锁定时长, 毫秒*/
   var lockDuration: Long = -1

   //记录开始的统计时间
   var _lockStartTime = 0L

   override fun run() {
       if (!enableLock || recyclerView?.layoutManager?.itemCount ?: 0 <= 0) {
           return
       }

       isScrollAnim = if (firstForce) firstScrollAnim else scrollAnim
       scrollType = SCROLL_TYPE_BOTTOM

       val position =
           if (lockPosition == RecyclerView.NO_POSITION) lastItemPosition() else lockPosition

       if (force || firstForce) {
           scroll(position)
           onScrollTrigger()
           L.i("锁定滚动至->$position $force $firstForce")
       } else {
           val lastItemPosition = lastItemPosition()
           if (lastItemPosition != RecyclerView.NO_POSITION) {
               //智能判断是否可以锁定
               if (position == 0) {
                   //滚动到顶部
                   val findFirstVisibleItemPosition =
                       recyclerView?.layoutManager.findFirstVisibleItemPosition()

                   if (findFirstVisibleItemPosition <= scrollThreshold) {
                       scroll(position)
                       onScrollTrigger()
                       L.i("锁定滚动至->$position")
                   }
               } else {
                   val findLastVisibleItemPosition =
                       recyclerView?.layoutManager.findLastVisibleItemPosition()

                   if (lastItemPosition - findLastVisibleItemPosition <= scrollThreshold) {
                       //最后第一个或者最后第2个可见, 智能判断为可以滚动到尾部
                       scroll(position)
                       onScrollTrigger()
                       L.i("锁定滚动至->$position")
                   }
               }
           }
       }

       firstForce = false
   }

   var attachView: View? = null

   override fun attach(view: View) {
       detach()
       attachView = view
   }

   override fun detach() {
       attachView?.removeCallbacks(this)
   }

   /**[ViewTreeObserver.OnDrawListener]*/
   override fun onDraw() {
       initLockStartTime()
       onLockScroll()
   }

   /**[ViewTreeObserver.OnGlobalLayoutListener]*/
   override fun onGlobalLayout() {
       initLockStartTime()
       onLockScroll()
   }

   open fun initLockStartTime() {
       if (_lockStartTime <= 0) {
           _lockStartTime = nowTime()
       }
   }

   open fun isLockTimeout(): Boolean {
       return if (lockDuration > 0) {
           val nowTime = nowTime()
           nowTime - _lockStartTime > lockDuration
       } else {
           false
       }
   }

   open fun onLockScroll() {
       attachView?.removeCallbacks(this)
       if (enableLock) {
           if (isLockTimeout()) {
               //锁定超时, 放弃操作
           } else {
               attachView?.post(this)
           }
       }
   }

   open fun onScrollTrigger() {

   }
}

/**锁定滚动到最后一个位置*/
inner class LockScrollLayoutListener : LockScrollListener() {

   override fun attach(view: View) {
       super.attach(view)
       view.viewTreeObserver.addOnGlobalLayoutListener(this)
   }

   override fun detach() {
       super.detach()
       attachView?.viewTreeObserver?.removeOnGlobalLayoutListener(this)
   }
}

/**滚动到0*/
inner class FirstPositionListener : LockScrollListener() {

   init {
       lockPosition = 0
       firstScrollAnim = true
       scrollAnim = true
       force = true
       firstForce = true
   }

   override fun attach(view: View) {
       super.attach(view)
       view.viewTreeObserver.addOnDrawListener(this)
   }

   override fun detach() {
       super.detach()
       attachView?.viewTreeObserver?.removeOnDrawListener(this)
   }

   override fun onScrollTrigger() {
       super.onScrollTrigger()
       if (isLockTimeout() || lockDuration == -1L) {
           detach()
       }
   }
}

private interface IAttachListener {
   fun attach(view: View)

   fun detach()
}

//滚动参数
internal data class ScrollParams(
   var scrollPosition: Int = RecyclerView.NO_POSITION,
   var scrollType: Int = SCROLL_TYPE_NORMAL,
   var scrollAnim: Boolean = true,
   var scrollOffset: Int = 0
)

使用方法

复制源码到工程即可, 就一个类文件.

1.初始化

val scrollHelper = ScrollHelper()
scrollHelper.attach(recyclerView)

2.操作方法

每次触发滚动时, 可配置的参数:

/**触发滚动是否伴随了adapter的addItem*/
var isFromAddItem = false
/**滚动是否需要动画*/
var isScrollAnim = false
/**滚动类别*/
var scrollType = SCROLL_TYPE_NORMAL
/**额外的偏移距离*/
var scrollOffset: Int = 0


/**滚动类别: 默认不特殊处理. 滚动到item显示了就完事*/
const val SCROLL_TYPE_NORMAL = 0
/**滚动类别: 将item滚动到第一个位置*/
const val SCROLL_TYPE_TOP = 1
/**滚动类别: 将item滚动到最后一个位置*/
const val SCROLL_TYPE_BOTTOM = 2
/**滚动类别: 将item滚动到居中位置*/
const val SCROLL_TYPE_CENTER = 3
//滚动到指定位置
ScrollHelper#scroll(position)

//滚动到底部
ScrollHelper#scrollToLast()

//滚动到顶部
ScrollHelper#scrollToFirst()

3.锁定滚动

锁定滚动配置参数:

/**激活滚动动画*/
var scrollAnim: Boolean = true
/**激活第一个滚动的动画*/
var firstScrollAnim: Boolean = false

/**不检查界面 情况, 强制滚动到最后的位置. 关闭后. 会智能判断*/
var force: Boolean = false

/**第一次时, 是否强制滚动*/
var firstForce: Boolean = true

/**滚动阈值, 倒数第几个可见时, 就允许滚动*/
var scrollThreshold = 2

/**锁定需要滚动的position, -1就是最后一个*/
var lockPosition = RecyclerView.NO_POSITION

/**是否激活功能*/
var enableLock = true

/**锁定时长, 毫秒*/
var lockDuration: Long = -1
//锁定滚动
ScrollHelper#lockPosition()