实现列表中横向滚动容器布局

68 阅读7分钟

想要实现在列表中横向滚动的 ViewGroup

image.png

在平时开发中会出现 列表中添加横向列表的功能,一般都是使用 recycle 嵌套横向 recycle。或者使用HorizontalScrollView + 容器实现;然后就想自定义一个容器,直接满足横向滚动。

支持:设置 item 的之间的间距,设置容器左右的space空格;支持设置屏幕宽度下展示 item 的个数。

思路:

1.需要进行子 view 的测量,以及获取到容器的最大宽度,宽度包含了 margin 值,以及 padding 值。(需要重写generateLayoutParams 等方法)

2.针对测量结果进行 Layout 排版,排版时候需要考虑 padding 值。

3.需要处理手动横滑和 fling 事件(VelocityTracker);需要获取到滚动速度(VelocityTracker)

4.需要在点击时区分是滚动还是点击事件。需要在onInterceptTouchEvent 方法中进行拦截。在 onTouchEvent 中进行滚动和滑动处理。

发现问题:

1.在重新设置子 view 的宽度时,使用 measureChildWithMargins()方法,无法重置宽度;因为该方法内不具备重新设置子 view 宽度高度的功能,只有跟进已有的Spec+margin 进行的测量。

2.在容器设置 padding 时,滚动时 padding 区域并没有跟随移动。原因是 scrollTo方式不支持 padding 区域跟随移动。因此需要手动的设置左右Space来满足条件。

进行子view 测量以及排版功能

 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        //由于 measure 会调用多次,如果不重置就会累加
        maxHeight = 0
        totalWidth = 0
        //针对子 view 进行测量
        for (i in 0 until childCount) {
            //进行子 view 的测量
            getChildAt(i).let {
                measureChildWithMargins(
                    it,
                    widthMeasureSpec, 0,
                    heightMeasureSpec, 0
                )
                //获取到最总的宽度
                totalWidth += it.measuredWidth
                //获取到最大高度
                maxHeight = max(maxHeight, it.measuredHeight)
            }

        }
        //宽度拼接 padding
        totalWidth += (paddingLeft + paddingRight)
        maxHeight += (paddingTop + paddingBottom)

        //计算获取父容器的宽度和高度
        setMeasuredDimension(
            resolveSize(max(totalWidth, screenWidth), measuredWidth),
            resolveSize(max(maxHeight, MeasureSpec.getSize(heightMeasureSpec)), measuredHeight)
        )
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
//        进行 view 的排版
        //获取到左侧的起始点
        var left = paddingLeft
        val top = if (maxHeight < measuredHeight) (measuredHeight - maxHeight) / 2 else paddingTop
        for (i in 0 until childCount) {
            getChildAt(i).let {
                it.layout(left, top, left + it.measuredWidth, top + it.measuredHeight)
                //进行左边位置确定
                left += it.measuredWidth
            }
        }
        maxHorizontalScrollDis = max(0, totalWidth - width)
    }

重写generateLayoutParams 避免在获取子 view 的 margin 值时转换异常:


     //重写 MarginParams
    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)
    }

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

    override fun generateLayoutParams(p: LayoutParams?): LayoutParams {
        return MarginLayoutParams(p)
    }

    override fun checkLayoutParams(p: LayoutParams?): Boolean {
        return p is MarginLayoutParams
    }

在事件onInterceptTouchEvent方法中进行事件拦截和分发:

    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        //在down 事件的时候移除滚动效果
        //如果触摸距离小于最小距离,就不进行拦截,将事件交给子 view。否则交给滚动处理。
        when (ev?.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                if (!scroller.isFinished) {
                    scroller.abortAnimation()
                }
                //获取到当前的位置
                curDownX = ev.x
            }

            MotionEvent.ACTION_MOVE -> {
                val moveX = abs(ev.x - curDownX)
                if (moveX > touchSlop) {
                    return true
                }
            }
        }

        return super.onInterceptTouchEvent(ev)
    }

在事件onTouche 中进行移动事件的处理。

    private var touchDownX = 0f
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        //在up 中进行数据计算,并进行fling操作,回收速度计算
        //在 cancel 中回收速度计算
        //在 down 中初始化 速度计算,并记录点击位置
        //在 move 中,计算移动距离,然后通过 scrollBy 进行移动
        //添加移动监听
        velocityTracker = velocityTracker ?: VelocityTracker.obtain()
        velocityTracker?.addMovement(event)
        when (event?.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                scroller.abortAnimation()
                touchDownX = event.x
                //避免被事件拦截
                parent?.requestDisallowInterceptTouchEvent(true)
            }

            MotionEvent.ACTION_MOVE -> {
                //获取到移动的距离,进行移动
//                scrollBy(+X) → 内容往右滚动 → 就是手指向左拖动的效果
//                scrollBy(-X) → 内容往左滚动 → 就是手指向右拖动的效果
                event.x.let {
                    scrollBy((touchDownX - it).toInt(), 0)//这个地方不能位置别反了,内容移动和手指移动方向相反。
                    touchDownX = it
                }
            }

            MotionEvent.ACTION_UP -> {
                //计算速度
                velocityTracker?.computeCurrentVelocity(1000)
                //执行 fling
                scroller.fling(
                    scrollX,
                    0,
                    (-(velocityTracker?.xVelocity!!)).toInt(),
                    0,
                    0,
                    computeMaxScrollX(),
                    0,
                    0,
                    0,
                    0
                )
                //触发重新绘制
                invalidate()
                recycleVelocity()
            }

            MotionEvent.ACTION_CANCEL -> {
                recycleVelocity()
            }
        }

        return true//必须返回 true 都是滚动将无法响应,事件将会传到上级的 onTouch中
    }
    

对应的 attr

<declare-styleable name="Horizontal_column">
    <attr name="leftSpace" format="dimension"/>
    <attr name="rightSpace" format="dimension"/>
    <attr name="midSpace" format="dimension"/>
    <attr name="showNum" format="float"/>
</declare-styleable>

总的代码:

class HorizontalColumnView @JvmOverloads constructor(context: Context,attributeSet: AttributeSet? = null) : 
 ViewGroup(context, attributeSet, 0) {
 
    private var maxHorizontalScrollDis = 0

    //定义滚动
    private val scroller = OverScroller(context)

    //定义速度计算器
    private var velocityTracker: VelocityTracker? = null

    //获取到最小移动距离匹配器
    private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop

    private var totalWidth = 0
    private var maxHeight = 0

    private val screenWidth = getScreenWidth()

    private val edgeEffectLeft = EdgeEffect(context)
    private val edgeEffectRight = EdgeEffect(context)

    //设置间距
    private var leftSpace = 0;
    private var rightSpace = 0;
    private var midSpace = 0

    //如果设置展示的个数呢?例如展示2个半或者3个半
    private var showNum = 0f

    init {
        context.withStyledAttributes(attributeSet, R.styleable.Horizontal_column) {
            leftSpace =
                getDimensionPixelSize(R.styleable.Horizontal_column_leftSpace, 0.dp())
            rightSpace =
                getDimensionPixelSize(R.styleable.Horizontal_column_rightSpace, 0.dp())
            midSpace = getDimensionPixelSize(R.styleable.Horizontal_column_midSpace, 0.dp())

            //一个屏幕展示的数量
            showNum = getFloat(R.styleable.Horizontal_column_showNum, 0f)
        }
    }


    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        //由于 measure 会调用多次,如果不重置就会累加
        maxHeight = 0
        totalWidth = 0

        if (showNum > 0) {
            calculateAFixedNumberOfData(widthMeasureSpec, heightMeasureSpec)
        } else {
            defaultShow(widthMeasureSpec, heightMeasureSpec)
        }


        totalWidth -= midSpace//移除掉多加的中间间距
        //宽度拼接 padding
        totalWidth += (paddingLeft + paddingRight + leftSpace + rightSpace)
        maxHeight += (paddingTop + paddingBottom)

        //计算获取父容器的宽度和高度
        setMeasuredDimension(
            resolveSize(max(totalWidth, screenWidth), measuredWidth),
            resolveSize(max(maxHeight, MeasureSpec.getSize(heightMeasureSpec)), measuredHeight)
        )
    }

    /**
     * 根据屏幕宽设置展示 item 个数,进行展示;支持间距设置,不支持 item 的 margin 值了
     */
    private fun calculateAFixedNumberOfData(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        //获取到当前需要展示的子 view 的宽度
        val itemWidth = if (showNum > 0) {
            (getScreenWidth() -
                    ((floor(showNum) - 1) * midSpace)
                    - paddingLeft - leftSpace) * 1.0f / showNum
        } else {
            0f
        }

        for (i in 0 until childCount) {
            getChildAt(i).let {
                //计算活动width spec
                val widthSpec = MeasureSpec.makeMeasureSpec(itemWidth.toInt(), MeasureSpec.EXACTLY)
                val heightSpec = getChildMeasureSpec(
                    heightMeasureSpec,
                    paddingTop + paddingBottom,
                    LayoutParams.WRAP_CONTENT
                )
                //重新测量
                it.measure(widthSpec, heightSpec)
                //计算 total,不考虑子view 的margin 值了
                totalWidth += itemWidth.toInt() + midSpace
                (it.layoutParams as MarginLayoutParams).let { params ->
                    maxHeight =
                        max(
                            maxHeight,
                            it.measuredHeight + params.topMargin + params.bottomMargin
                        )
                }
            }
        }
    }

    /**
     * 默认展示,支持设置间距以及 view margin
     */
    private fun defaultShow(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        for (i in 0 until childCount) {
            getChildAt(i).let {
                measureChildWithMargins(
                    it,
                    widthMeasureSpec, 0,
                    heightMeasureSpec, 0
                )

                (it.layoutParams as MarginLayoutParams).let { params ->
                    //获取到最总的宽度,需要拼接上 margin 值以及中间的间距
                    totalWidth += it.measuredWidth + midSpace + params.leftMargin + params.rightMargin
                    //获取到最大高度
                    maxHeight =
                        max(
                            maxHeight,
                            it.measuredHeight + params.topMargin + params.bottomMargin
                        )
                }
            }
        }
    }

    @SuppressLint("DrawAllocation")
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {

        //获取到左侧的起始点
        val top = if (maxHeight < measuredHeight) (measuredHeight - maxHeight) / 2 else paddingTop
        if (showNum > 0) {
            calWidthLayout(top)
        } else {
            defaultWidthLayout(top)
        }


        maxHorizontalScrollDis = max(0, totalWidth - width)

    }

    /**
     * 计算item 的宽度然后进行排版
     */
    private fun calWidthLayout(top: Int) {
        var left = paddingLeft + leftSpace
        for (i in 0 until childCount) {
            getChildAt(i).let {

                it.layout(
                    left,
                    top,
                    left + it.measuredWidth,
                    top + it.measuredHeight
                )
                //进行左边位置确定
                left += it.measuredWidth + midSpace  //加上间距,
            }
        }
    }

    /**
     * 根据 view 的宽度,默认展示,支持 margin padding
     */
    private fun defaultWidthLayout(top: Int) {
        var left = paddingLeft + leftSpace
        for (i in 0 until childCount) {

            getChildAt(i).let {
                //需要获取到margin 值

                val rect = (it.layoutParams as MarginLayoutParams).let { params ->
                    //拼接上子 view 左侧的 margin
                    left += params.leftMargin
                    Rect(
                        params.leftMargin.toFloat(),
                        params.topMargin.toFloat(),
                        params.rightMargin.toFloat(),
                        params.bottomMargin.toFloat()
                    )
                }

                it.layout(
                    left,
                    top + rect.top.toInt(),
                    left + it.measuredWidth,
                    top + it.measuredHeight + rect.top.toInt()
                )
                //进行左边位置确定
                left += it.measuredWidth + midSpace + rect.right.toInt()//加上间距,
            }
        }
    }


    //重写 MarginParams
    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)
    }

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

    override fun generateLayoutParams(p: LayoutParams?): LayoutParams {
        return MarginLayoutParams(p)
    }

    override fun checkLayoutParams(p: LayoutParams?): Boolean {
        return p is MarginLayoutParams
    }

    //重写滚动需要的方法
    override fun computeScroll() {
        super.computeScroll()
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.currX, scroller.currY)
            // 滚动到了边界处
            if (scroller.isOverScrolled) {
             //如果需要区分方向就针对垂直和水平进行处理
                scroller.notifyHorizontalEdgeReached(
                    scroller.startX,
                    scroller.finalX,
                    20.dp()// overX 越界弹性距离
                )
                if (scroller.springBack(scrollX, 0, 0, maxHorizontalScrollDis, 0, 0)) {
                    invalidate()
                }
            }
            postInvalidateOnAnimation()

        }

        //todo
        /*      if (scroller.computeScrollOffset()) {
                  scrollTo(scroller.currX, scroller.currY)
                  invalidate()
              }*/
    }

    private fun computeMaxScrollX(): Int {
        return totalWidth - width
    }

    override fun scrollTo(x: Int, y: Int) {
        //x 表示滚动相对于左边 的距离,
       // var newX = x
        // if (newX < 0) newX = 0
        // if (newX > maxHorizontalScrollDis) newX = maxHorizontalScrollDis
     //super.scrollTo(newX, y)

    //类似于上方功能
        val clampedX = x.coerceIn(0, computeMaxScrollX())
        super.scrollTo(clampedX, y)
    }


    private var curDownX = 0f

    //重写触摸事件,进行拦截
    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        //在down 事件的时候移除滚动效果
        //如果触摸距离小于最小距离,就不进行拦截,将事件交给子 view。否则交给滚动处理。
        when (ev?.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                if (!scroller.isFinished) {
                    scroller.abortAnimation()
                }
                //获取到当前的位置
                curDownX = ev.x
            }

            MotionEvent.ACTION_MOVE -> {
                val moveX = abs(ev.x - curDownX)
                if (moveX > touchSlop) {
                    return true
                }
            }
        }

        return super.onInterceptTouchEvent(ev)
    }

    private var touchDownX = 0f

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        //在up 中进行数据计算,并进行fling操作,回收速度计算
        //在 cancel 中回收速度计算
        //在 down 中初始化 速度计算,并记录点击位置
        //在 move 中,计算移动距离,然后通过 setx 进行移动
        //添加移动监听
        velocityTracker = velocityTracker ?: VelocityTracker.obtain()
        velocityTracker?.addMovement(event)
        when (event?.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                scroller.abortAnimation()
                touchDownX = event.x
                //避免被事件拦截
                parent?.requestDisallowInterceptTouchEvent(true)
            }

            MotionEvent.ACTION_MOVE -> {

                //获取到移动的距离,进行移动
              //scrollBy(+X) → 内容往右滚动 → 就是手指向左拖动的效果
    //                scrollBy(-X) → 内容往左滚动 → 就是手指向右拖动的效果
                event.x.let {
                    scrollBy((touchDownX - it).toInt(), 0)//这个地方不能位置别反了,内容移动和手指移动方向相反。
                    touchDownX = it
                }

                // TODO: 数据绘制边界方式
                /*                                val dx = (touchDownX - event.x).toInt()
                                                touchDownX = event.x
                                                if (scrollX <= 0 && dx < 0) {
                                                    edgeEffectLeft.onPull(dx.toFloat() / width)
                                                    invalidate()
                                                } else if (scrollX >= computeMaxScrollX() && dx > 0) {
                                                    edgeEffectRight.onPull(dx.toFloat() / width)
                                                    invalidate()
                                                } else {
                                                    scrollBy(dx, 0)
                                                }*/
            }

            MotionEvent.ACTION_UP -> {
                //计算速度
                velocityTracker?.computeCurrentVelocity(1000)
                //执行 fling

                scroller.fling(
                    scrollX,
                    0,
                    (-(velocityTracker?.xVelocity!!)).toInt(),
                    0,
                    0,
                    computeMaxScrollX(),
                    0,
                    0,
                    0,
                    0
                )
                //触发重新绘制
                invalidate()

                //todo 下边数据边界效果
                /*                                velocityTracker?.computeCurrentVelocity(1000)
                                                val velocityX = -velocityTracker?.xVelocity!!.toInt()
                                                if (scrollX <= 0 && velocityX < 0) {
                                                    edgeEffectLeft.onAbsorb(velocityX)
                                                    invalidate()
                                                } else if (scrollX >= computeMaxScrollX() && velocityX > 0) {
                                                    edgeEffectRight.onAbsorb(velocityX)
                                                    invalidate()
                                                } else {
                                                    scroller.fling(
                                                        scrollX, 0,
                                                        velocityX, 0,
                                                        0, computeMaxScrollX(),
                                                        0, 0
                                                    )
                                                    invalidate()
                                                }*/

                recycleVelocity()
            }

            MotionEvent.ACTION_CANCEL -> {
                recycleVelocity()
                edgeEffectLeft.onRelease()
                edgeEffectRight.onRelease()
            }

        }

        return true
    }

    private fun recycleVelocity() {
        velocityTracker?.recycle()
        velocityTracker = null
    }


    private fun getScreenWidth(): Int {
        val metrics = DisplayMetrics()
        val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
        val display = windowManager.defaultDisplay
        display.getRealMetrics(metrics) // 包括系统栏(导航栏、状态栏)
        return metrics.widthPixels
    }


    //todo 数据绘制边界效果
    /*    override fun draw(canvas: Canvas) {
            super.draw(canvas)

            var needsInvalidate = false

            if (!edgeEffectLeft.isFinished) {
                canvas.withRotation(270f) {
                    canvas.translate(-height.toFloat(), 0f)
                    edgeEffectLeft.setSize(height, width)
                    needsInvalidate = edgeEffectLeft.draw(canvas)
                }
            }

            if (!edgeEffectRight.isFinished) {
                canvas.withRotation(90f) {
                    canvas.translate(0f, -width.toFloat())
                    edgeEffectRight.setSize(height, width)
                    needsInvalidate = edgeEffectRight.draw(canvas)
                }
            }


            if (needsInvalidate) postInvalidateOnAnimation()
        }*/
     }