自定义LayoutManager注意事项(三):自定义LayoutManager实战

通过前面两篇:《深入理解RecyclerView与LayoutManager(一):基本概念与原理》、《深入理解RecyclerView与LayoutManager(二):自定义LayoutManager注意事项》的学习,相信大家对RecyclerView以及LayoutManager都有了更深的认识。本篇将一步步的带着大家来实现一个案例效果,具体效果看这里

虽然有了前两篇的基础,但是一到实战阶段还是有点束手无策,这就跟上学的时候一样,上课听得津津有味,一做题就错误百出,其实也正常,因为只有通过实践才能将学过的零散的知识点串联起来,形成体系,这样才能融会贯通。

废话不多说,开始进入实战。

为了实现这个效果,分三步走:

  • 先搭建框架
  • 再实现静态效果
  • 最后处理滑动事件

自定义LayoutManager绕不开添加、测量、布局三步。

第一步实现框架如下

class CoverFlowLayoutManager : LayoutManager() {

    private var pendingScrollPosition: Int = RecyclerView.NO_POSITION

    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
    }

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        if (pendingScrollPosition != RecyclerView.NO_POSITION) {
            if (state.itemCount == 0) {
                removeAndRecycleAllViews(recycler)
                return
            }
        }

        detachAndScrapAttachedViews(recycler)

        fill(recycler, state)
    }

    private fun fill(recycler: RecyclerView.Recycler, state: RecyclerView.State) {

        for (i in 0 until itemCount) {
            layoutChunk(xxx)
        }

    }

    private fun layoutChunk(item: View, l: Int, t: Int, r: Int, b: Int) {
        //TODO
    }

    override fun scrollToPosition(position: Int) {
        pendingScrollPosition = position
    }

    override fun onLayoutCompleted(state: RecyclerView.State) {
        pendingScrollPosition = RecyclerView.NO_POSITION
    }

}

以上框架基本差不多摘自官方实现的LinearLayoutManagergenerateDefaultLayoutParams()是必须实现的,一般返回WRAP_CONTENT,由自己测量每个ItemView的大小,onLayoutChildren()也是必须要实现的,这里面是核心的流程,包括添加、布局、测量、回收。

拿到一个效果,首先要分析效果的组成,然后拆解成一个个小的静态效果,最后再串联起来,切记一口吃不了一个胖子。

基于以上效果,拆解过后的静态图如下:

图1

如果不考虑滑动,让我们使用普通的ViewGroup来实现上面效果,相信大家花点时间应该都能实现,无非就是在onMeasure()里面测量子View的大小,然后在onLayout()里面布局子View,关于自定义View相关知识可以移步这里看我写的另一篇文章。那换成LayoutManager其实也一样的,这些活放到onLayoutChildren()中实现即可。

简单的分析下上图的效果,初始状态下第一个item处于正中间,且大小是正常大小,左边没有item,右边的item缩小显示,越往右item越小,item之间有固定大小的间隙。

第二步实现静态效果

class CoverFlowLayoutManager : LayoutManager() {

    private var pendingScrollPosition: Int = RecyclerView.NO_POSITION
    private var childWidth = 0
    /** 一次完整的聚焦滑动所需要移动的距离 */
    private var onceCompleteScrollLength = -1f
    /** 第一个item的偏移量 */
    private var firstChildCompleteScrollLength = -1f
    /** 屏幕可见的第一个item的position */
    private var firstVisibleItemPosition = 0
    /** 屏幕可见的最后一个item的position */
    private var lastVisibleItemPosition = 0
    /** item之间的间距 */
    private var normalViewGap = 0

    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
    }

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        if (pendingScrollPosition != RecyclerView.NO_POSITION) {
            if (state.itemCount == 0) {
                removeAndRecycleAllViews(recycler)
                return
            }
        }

        onceCompleteScrollLength = -1f

        detachAndScrapAttachedViews(recycler)

        if (onceCompleteScrollLength == -1f) {
            //初始化一些常量
            val itemView = recycler.getViewForPosition(0)
            measureChildWithMargins(itemView, 0, 0)
            childWidth = getDecoratedMeasurementHorizontal(itemView)
        }

        //修正第一个item的偏移量
        firstChildCompleteScrollLength = width / 2f + childWidth / 2f

        fill(recycler, state)
    }

    private fun fill(recycler: RecyclerView.Recycler, state: RecyclerView.State) {

        var startX = getMinOffset()

        for (i in 0 until itemCount) {
            val item = recycler.getViewForPosition(i)
            //1.添加view
            addView(item)
            //2.测量view
            measureChildWithMargins(item, 0, 0)

            val l = startX.toInt()
            val t = paddingTop
            val r = l + getDecoratedMeasurementHorizontal(item)
            val b = t + getDecoratedMeasurementVertical(item)
            //3.布局view
            layoutChunk(item, l, t, r, b)

            startX += childWidth + normalViewGap

            //如果超出了边界就退出循环,不再添加view了
            if (startX > width - paddingRight) {
                lastVisibleItemPosition = i
                break
            }
        }
    }

    private fun layoutChunk(item: View, l: Int, t: Int, r: Int, b: Int) {
        // 缩放子view
        val minScale = 0.6f
        val currentScale: Float
        val childCenterX = (r + l) / 2
        val parentCenterX = width / 2
        val isChildLayoutLeft = childCenterX <= parentCenterX
        if (isChildLayoutLeft) {
            //越往左边越小
            val fractionScale = (parentCenterX - childCenterX) / (parentCenterX * 1.0f)
            currentScale = 1.0f - (1.0f - minScale) * fractionScale
        } else {
            //越往右边越小
            val fractionScale = (childCenterX - parentCenterX) / (parentCenterX * 1.0f)
            currentScale = 1.0f - (1.0f - minScale) * fractionScale
        }
        item.scaleX = currentScale
        item.scaleY = currentScale
        item.setAlpha(currentScale)
        layoutDecoratedWithMargins(item, l, t, r, b)
    }

    /**
     * 获取某个childView在竖直方向所占的空间,将margin考虑进去
     */
    private fun getDecoratedMeasurementVertical(view: View): Int {
        val params = view.layoutParams as RecyclerView.LayoutParams
        return (getDecoratedMeasuredHeight(view) + params.topMargin
                + params.bottomMargin)
    }

    /**
     * 获取某个childView在水平方向所占的空间,将margin考虑进去
     */
    private fun getDecoratedMeasurementHorizontal(view: View): Int {
        val params = view.layoutParams as RecyclerView.LayoutParams
        return getDecoratedMeasuredWidth(view) + params.leftMargin + params.rightMargin
    }

    /**
     * 最小偏移量,中间的item距离最左边的距离
     */
    private fun getMinOffset(): Float {
        if (childWidth == 0) {
            return 0f
        }
        return (width - childWidth) / 2f
    }

    override fun scrollToPosition(position: Int) {
        pendingScrollPosition = position
    }

    override fun onLayoutCompleted(state: RecyclerView.State) {
        pendingScrollPosition = RecyclerView.NO_POSITION
    }

}

上面代码的注释已经很详细了,测量和布局的思路跟自定义ViewGroup一样,layoutChunk()方法里面的逻辑需要自己对照上图思考下,尤其是缩放比例fractionScale设计的很巧妙。效果图如下:

图2

第三步处理滑动事件

RecyclerView中的onInterceptTouchEvent()onTouchEvent()已经帮我们处理触摸事件,比如事件的拦截处理、边界处理、惯性滚动处理等,我们只需要重写canScrollHorizontally()canScrollVertically()scrollHorizontallyBy()scrollVerticallyBy()来提供如何滚动的具体实现即可。

例子中是左右滑动,所以需要实现

scrollHorizontallyBy(int dx, Recycler recycler, State state)

其中,手指从右向左滑动dx > 0,手指从左向右滑动dx < 0

接下来还是分两步来实现:

    • 先实现跟随手指左右滑动
    • 再实现松开手指后的自动定位

修改后的代码如下所示:

class CoverFlowLayoutManager : LayoutManager() {

    private var pendingScrollPosition: Int = RecyclerView.NO_POSITION
    private var childWidth = 0
    /** 一次完整的聚焦滑动所需要移动的距离 */
    private var onceCompleteScrollLength = -1f
    /** 第一个item的偏移量 */
    private var firstChildCompleteScrollLength = -1f
    /** 屏幕可见的第一个item的position */
    private var firstVisibleItemPosition = 0
    /** 屏幕可见的最后一个item的position */
    private var lastVisibleItemPosition = 0
    /** item之间的间距 */
    private var normalViewGap = 0

    /** 水平方向累计偏移量 */
    private var horizontalOffset = 0f

    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
    }

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        if (pendingScrollPosition != RecyclerView.NO_POSITION) {
            if (state.itemCount == 0) {
                removeAndRecycleAllViews(recycler)
                return
            }
        }

        onceCompleteScrollLength = -1f

        detachAndScrapAttachedViews(recycler)

        if (onceCompleteScrollLength == -1f) {
            //初始化一些常量
            val itemView = recycler.getViewForPosition(0)
            measureChildWithMargins(itemView, 0, 0)
            childWidth = getDecoratedMeasurementHorizontal(itemView)
        }

        // 修正第一个可见view firstVisibleItemPosition 已经滑动了多少个完整的 onceCompleteScrollLength 就代表滑动了多少个item
        firstChildCompleteScrollLength = width / 2f + childWidth / 2f

        //重新布局
        fill(recycler, state, 0)
    }

    private fun fill(recycler: RecyclerView.Recycler, state: RecyclerView.State, dx: Int): Int {
        val resultDelta = fillHorizontal(recycler, state, dx)
        return resultDelta
    }

    private fun fillHorizontal(recycler: RecyclerView.Recycler, state: RecyclerView.State, dx: Int): Int {

        //边界检测
        var tempDx = dx
        if (dx < 0) {
            //已到达左边界
            if (horizontalOffset < 0) {
                horizontalOffset = 0f
                tempDx = 0
            }
        }

        if (dx > 0) {
            if (horizontalOffset >= getMaxOffset()) {
                horizontalOffset = getMaxOffset()
                tempDx = 0
            }
        }

        //分离全部的view,加入到临时缓存
        detachAndScrapAttachedViews(recycler)

        firstVisibleItemPosition = 0
        var startX = getMinOffset()
        onceCompleteScrollLength = firstChildCompleteScrollLength
        val fraction = (Math.abs(horizontalOffset) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f)
        // 临时将 lastVisibleItemPosition 赋值为getItemCount() - 1,放心,下面遍历时会判断view是否已溢出屏幕,并及时修正该值并结束布局
        lastVisibleItemPosition = itemCount - 1

        //每次布局之前都要对startX进行一次调整,初始的位置调整了,后面所有的view都是基于此位置进行布局的
        val normalViewOffset = onceCompleteScrollLength * fraction
        startX -= normalViewOffset

        for (i in firstVisibleItemPosition until lastVisibleItemPosition) {

            val item = recycler.getViewForPosition(i)
            //1.添加view
            addView(item)
            //2.测量view
            measureChildWithMargins(item, 0, 0)

            val l = startX.toInt()
            val t = paddingTop
            val r = l + getDecoratedMeasurementHorizontal(item)
            val b = t + getDecoratedMeasurementVertical(item)
            //3.布局view
            layoutChunk(item, l, t, r, b)

            startX += childWidth + normalViewGap

            //如果超出了边界就退出循环,不再添加view了
            if (startX > width - paddingRight) {
                lastVisibleItemPosition = i
                break
            }
        }

        return tempDx
    }

    private fun layoutChunk(item: View, l: Int, t: Int, r: Int, b: Int) {
        // 缩放子view
        val minScale = 0.6f
        val currentScale: Float
        //中间item中心点位置横坐标
        val childCenterX = (r + l) / 2
        val parentCenterX = width / 2
        val isChildLayoutLeft = childCenterX <= parentCenterX
        if (isChildLayoutLeft) {
            //越往左边越小
            val fractionScale = (parentCenterX - childCenterX) / (parentCenterX * 1.0f)
            currentScale = 1.0f - (1.0f - minScale) * fractionScale
        } else {
            //越往右边越小
            val fractionScale = (childCenterX - parentCenterX) / (parentCenterX * 1.0f)
            currentScale = 1.0f - (1.0f - minScale) * fractionScale
        }
        item.scaleX = currentScale
        item.scaleY = currentScale
        item.setAlpha(currentScale)
        layoutDecoratedWithMargins(item, l, t, r, b)
    }

    /**
     * 获取某个childView在竖直方向所占的空间,将margin考虑进去
     */
    private fun getDecoratedMeasurementVertical(view: View): Int {
        val params = view.layoutParams as RecyclerView.LayoutParams
        return (getDecoratedMeasuredHeight(view) + params.topMargin
                + params.bottomMargin)
    }

    /**
     * 获取某个childView在水平方向所占的空间,将margin考虑进去
     */
    private fun getDecoratedMeasurementHorizontal(view: View): Int {
        val params = view.layoutParams as RecyclerView.LayoutParams
        return getDecoratedMeasuredWidth(view) + params.leftMargin + params.rightMargin
    }

    /**
     * 最小偏移量,中间的item距离最左边的距离
     */
    private fun getMinOffset(): Float {
        if (childWidth == 0) {
            return 0f
        }
        return (width - childWidth) / 2f
    }

    /**
     * 最大偏移量
     */
    private fun getMaxOffset(): Float {
        if (childWidth == 0 || itemCount == 0) return 0f
        return ((childWidth + normalViewGap) * (itemCount - 1)).toFloat()
    }

    override fun scrollToPosition(position: Int) {
        pendingScrollPosition = position
    }

    override fun onLayoutCompleted(state: RecyclerView.State) {
        pendingScrollPosition = RecyclerView.NO_POSITION
    }

    override fun canScrollHorizontally(): Boolean {
        return true
    }

    override fun scrollHorizontallyBy(
        dx: Int,
        recycler: RecyclerView.Recycler,
        state: RecyclerView.State
    ): Int {

        // 手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;
        // 位移0、没有子View 当然不移动
        if (dx == 0 || childCount == 0) {
            return 0
        }
        val realDx = dx / 1.0f
        if (abs(realDx) < 0.00000001f) {
            return 0
        }
        horizontalOffset += dx

        val tempDx = fill(recycler, state, dx)

        return tempDx
    }

}

主要看fillHorizontal()scrollHorizontallyBy()

scrollHorizontallyBy()中,做了些简单的边界值判断,然后将滑动的距离累加起来,最后调用fill()不断地调整子View的位置。

fillHorizontal()中,首先处理边界问题,然后处理startX变量,注意对startX的理解非常重要,我刚开始看到这里的时候没有发挥想象,导致一直不理解。startX初始值为getMinOffset(),随着滑动的进行,需要在每次调整位置的时候都要基于已经滑动的距离做一次调整,所以以下代码是关键的需要理解的点:

//每次布局之前都要对startX进行一次调整,初始的位置调整了,后面所有的view都是基于此位置进行布局的
val normalViewOffset = onceCompleteScrollLength * fraction
startX -= normalViewOffset

fraction其实就是滑动的比例,比如需要滑动的距离是100,每次滑动的距离是10,那么计算出每次滑动的距离相对于需要滑动的距离的比例是0.1

startX -= normalViewOffset,因为初始的时候第一个View距离左边界距离是firstChildCompleteScrollLength,随着手指不断的滑动,减掉已经滑动的距离normalViewOffset就是第一个View距离左边界实际的距离,后面的for循环里面是基于此位置来布局所有可见的子View

还有一处核心的关键点就是在for循环里面布局之前调用了detachAndScrapAttachedViews(),如果不调用这个方法,就没法利用RecyclerView的缓存机制了,不断的addView,布局会错乱,滑动一会儿就OOM了。如果不是很理解这个方法,就简单的认为在每次进行布局之前,都需要调用detachAndScrapAttachedViews方法把屏幕中的items都分离出来。你还可以这么理解,如果让你自己写一个自定义ViewGroup,实现滚动效果,你也肯定会在每次布局之前都调用removeAllViews(),然后再addView(),所以detachAndScrapAttachedViews()其实就相当于removeAllViews()。但是detachAndScrapAttachedViews()里面做了很多缓存有关的工作,这样滑动的效率会更高。

为了方便理解,我修改minScale = 1f,这样更容易肉眼观察到滑动到边界的位置。

上面代码跑起来,当滑动到第一个View不可见的时候会出现布局错乱的问题,具体效果看这里。分析原因是由于firstVisibleItemPositionstartX没有动态变化导致。

根据图1分析可知,以horizontalOffset >= firstChildCompleteScrollLength作为分界线来动态计算firstVisibleItemPositionstartX。为了减少篇幅,只贴出修改的地方,修改代码如下:

private fun fillHorizontal(recycler: RecyclerView.Recycler, state: RecyclerView.State, dx: Int): Int {
    //边界检测
    var tempDx = dx
    if (dx < 0) {
        //已到达左边界
        if (horizontalOffset < 0) {
            horizontalOffset = 0f
            tempDx = 0
        }
    }

    if (dx > 0) {
        if (horizontalOffset >= getMaxOffset()) {
            horizontalOffset = getMaxOffset()
            tempDx = 0
        }
    }

    //分离全部的view,加入到临时缓存
    detachAndScrapAttachedViews(recycler)

    var startX: Float
    val fraction: Float
    if (horizontalOffset >= firstChildCompleteScrollLength) {
        //当第一个item不可见的时候
        startX = normalViewGap.toFloat()
        onceCompleteScrollLength = (childWidth + normalViewGap).toFloat()
        firstVisibleItemPosition = floor(Math.abs(horizontalOffset - firstChildCompleteScrollLength) / onceCompleteScrollLength).toInt() + 1
        fraction = (Math.abs(horizontalOffset - firstChildCompleteScrollLength) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f)
    } else {
        //当第一个item可见的时候
        startX = getMinOffset()
        firstVisibleItemPosition = 0
        onceCompleteScrollLength = firstChildCompleteScrollLength
        fraction = (Math.abs(horizontalOffset) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f)
    }
    // 临时将 lastVisibleItemPosition 赋值为getItemCount() - 1,放心,下面遍历时会判断view是否已溢出屏幕,并及时修正该值并结束布局
    lastVisibleItemPosition = itemCount - 1

    //每次布局之前都要对startX进行一次调整,初始的位置调整了,后面所有的view都是基于此位置进行布局的
    val normalViewOffset = onceCompleteScrollLength * fraction
    startX -= normalViewOffset

    for (i in firstVisibleItemPosition until lastVisibleItemPosition) {

        val item = recycler.getViewForPosition(i)
        //1.添加view
        addView(item)
        //2.测量view
        measureChildWithMargins(item, 0, 0)

        val l = startX.toInt()
        val t = paddingTop
        val r = l + getDecoratedMeasurementHorizontal(item)
        val b = t + getDecoratedMeasurementVertical(item)
        //3.布局view
        layoutChunk(item, l, t, r, b)

        startX += childWidth + normalViewGap

        //如果超出了边界就退出循环,不再添加view了
        if (startX > width - paddingRight) {
            lastVisibleItemPosition = i
            break
        }
    }

    return tempDx
}

主要修改了fillHorizontal()startXfirstVisibleItemPositiononceCompleteScrollLength以及fraction的计算方式。这三个变量是滚动的核心变量,理解了这三个变量,那这个示例也就差不多搞懂了。

    • startX:这个变量不用多说,每次滚动都要以这个变量作为锚点来布局所有可见的Item。
    • firstVisibleItemPosition:第一个可见的Item位置,决定for循环的起始位置,高效布局的关键。
    • onceCompleteScrollLength:原文的解释是“一次完整的聚焦滑动所需要移动的距离”。我觉得很有误导性,在下面的自动定位小结中这样理解是没问题的,但是在这里可以理解为将一个完整的Item从边缘位置滑动到不可见时所需要移动的距离。结合这张动图理解,当第1个Item从起始位置滑动到不可见时需要移动firstChildCompleteScrollLength = width / 2f + childWidth / 2f,从第2个Item开始,每个Item从边缘位置滑动到不可见所需要移动的距离都是childWidth + normalViewGap
    • fraction:实时滑动距离相对于上面的onceCompleteScrollLength的比例。

要实现松开手指后自动定位,需要重写onScrollStateChanged()并监听RecyclerView.SCROLL_STATE_IDLE状态。搭建框架如下:

//......此处省略其余代码

override fun onScrollStateChanged(state: Int) {
    super.onScrollStateChanged(state)

    when (state) {
        //当手指按下时,停止当前正在播放的动画
        RecyclerView.SCROLL_STATE_DRAGGING ->
            cancelAnimator()

        //当列表滚动停止后,判断一下自动选中是否打开
        RecyclerView.SCROLL_STATE_IDLE ->
            if (isAutoSelect) {
                //找到离目标落点最近的item索引
                smoothScrollToPosition(findShouldSelectPosition(), null)
            }

        else -> {}
    }
}

//......此处省略其余代码

具体定位的位置,需要根据已经滑动的距离horizontalOffset来计算,核心算法如下:

private fun findShouldSelectPosition(): Int {
    if (onceCompleteScrollLength == -1f || firstVisibleItemPosition == -1) {
        return -1
    }
    val position = (Math.abs(horizontalOffset) / (childWidth + normalViewGap))
    // 超过一半,应当选中下一项
    if ((Math.abs(horizontalOffset) % (childWidth + normalViewGap)) >= (childWidth + normalViewGap) / 2.0f) {
        if (position + 1 <= itemCount - 1) {
            return (position + 1).toInt()
        }
    }
    return position.toInt()
}

结合这张动图,我觉得不难理解。接下来就是处理自动滚动,需要用到属性动画。代码如下:

/**
    * 平滑滚动到某个位置
    *
    * @param position 目标Item索引
    */
fun smoothScrollToPosition(position: Int, listener: OnStackListener?) {
    if (position > -1 && position < itemCount) {
        startValueAnimator(position, listener)
    }
}

private fun startValueAnimator(position: Int, listener: OnStackListener?) {
    cancelAnimator()

    val distance = getScrollToPositionOffset(position)

    val minDuration = 100
    val maxDuration = 300
    val duration: Long

    val distanceFraction = ((abs(distance.toDouble()) / (childWidth + normalViewGap)).toFloat())

    Log.i(TAG, "distance-->$distance, distanceFraction-->$distanceFraction")

    duration = if (distance <= (childWidth + normalViewGap)) {
        (minDuration + (maxDuration - minDuration) * distanceFraction).toLong()
    } else {
        (maxDuration * distanceFraction).toLong()
    }
    selectAnimator = ValueAnimator.ofFloat(0.0f, distance)
    selectAnimator?.setDuration(duration)
    selectAnimator?.interpolator = LinearInterpolator()
    val startedOffset = horizontalOffset
    selectAnimator?.addUpdateListener(AnimatorUpdateListener { animation ->
        val value = animation.animatedValue as Float
        horizontalOffset = startedOffset + value
        requestLayout()
    })
    selectAnimator?.addListener(object : AnimatorListenerAdapter() {
        override fun onAnimationEnd(animation: Animator) {
            super.onAnimationEnd(animation)
            listener?.onFocusAnimEnd()
        }
    })
    selectAnimator?.start()
}

/**
    * 滑动到指定的位置需要移动的距离
    * @param position
    * @return
    */
private fun getScrollToPositionOffset(position: Int): Float {
    return position * (childWidth + normalViewGap) - Math.abs(horizontalOffset)
}

关键点:

    • 滑动到指定的位置需要移动的距离distance。1次完整的滑动距离是childWidth + normalViewGap,比如需要滑动到第2个位置时,完整的需要滑动的距离是2 * (childWidth + normalViewGap),减掉已经滑动的距离horizontalOffset,那剩下的就是需要滑动的距离了。
    • 根据上面计算出来的距离来动态计算滚动需要的时间duration。其实要是图简单,就固定一个时间就行,但这里的算法也比较精妙,距离短需要的时间就少,反之亦然。minDuration + (maxDuration - minDuration) * distanceFraction,类似的这种算法在自己处理滚动、滑动、动画等场景中经常会用到,其中distanceFraction就是实际滑动的距离相对于需要滑动的距离的比例,当实际滑动距离达到需要滑动的距离时,比例就是1,那么就取maxDuration,总之维持在minDurationmaxDuration之间。

最后使用ValueAnimator来创建动画,动画onAnimationUpdate()回调里面其实就是模拟手动滑动的过程,修改horizontalOffset值,然后调用requestLayout()来不断的重绘。回想手动滑动时也是在scrollHorizontallyBy()中改变horizontalOffset的值然后修改View的位置并重新添加View,但是代码中并没有调用requestLayout()来重绘,这是因为我们已经在scrollHorizontallyBy()中不断的调用了fill()进而来重新布局。

完整的代码在这里

以上就是全部的分析过程,一步步的从原理到实践,完成了一个看着简单但是实现起来并不简单的例子,相信看完这次的系列文章,大家都开始摩拳擦掌,想要一展身手了,加油吧。

本篇文章参考https://www.cnblogs.com/anyrtc/p/16373857.html,感谢作者这么优秀的文章,我这篇文章也只是班门弄斧罢了,站在了巨人的肩膀上补充了对原文的例子讲解。希望本篇文章能帮到大家,通过这次系列文章的编写,我对自定义LayoutManager也有了全新的和更加透彻的认识。

编写不易,转载请注明出处:https://www.longdw.com