自定义 ViewGroup ,实现 子 view 居中或者滚动效果

18 阅读7分钟

想要实现功能:

  • 1.如果数量比较少不能占满全屏,条目就居中,大于2个的情况第一个和最后一个放到两端,其他居中。
  • 2.如果条目宽度大于屏幕宽度,就可以滚动展示。
  • 3.支持 margin和padding。
  • 4.支持 fling 效果。支持不滚动出边界。
  • 5.支持子 view 居中展示在父容器中。

实现上述功能需要考虑的功能点如下:

1.采用哪种方式实现拖拽和滚动效果

2.如果进行测量并包含 margin值。

3.触摸事件在拖动滚动时,需要进行事件拦截,以及触发拖动和 fling 效果。

4.需要了解最小滚动距离,以及松开手指后获取滚动速度和获取速度类的释放。

实现页面拖拽时,采用哪些方法;

setx/seTranslationX 和 scrollTo/scrollBy 的区别:

方法本质作用对象典型用途
setX()设置位置View 本身拖动、动画、定位
translationX偏移量View 渲染位置动画、滑动返回
scrollX / scrollTo()内容滚动View 的内容自定义滑动容器
场景使用方式
想要“滑动内容”,如 scroll view、轮播图、自定义容器滑动scrollTo() / scrollBy()
想让整个 View 动起来(动画、拖动、改变位置)setX() / translationX
多个 item 横向滚动,但 ViewGroup 不动scrollTo() 内容滑动
View 要响应用户手势移动(如拖动)translationX 较为平滑
你要写 fling、惯性滑动等功能scrollTo() + Scroller / OverScroller

如果想要实现的是内容(也可以是多个子 view )的拖拽或者滚动效果,那么使用 scrollTo或者 scrollBy ,这样后面可以结合OverScroller 实现惯性滚动效果。以及结果computeScroll方法实现滚动的计算,达到惯性滚动效果。

 override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
//            scroller.currX  滚动过程中当前偏移量,x 也是按照左边界为起始点 值为0
            //这种终止动画的方式在 overScroller 不太适合,会导致滑动暂停过于僵硬。
            //在Scroller 中使用下面代码的原因是为了避免过度偏移。
            /*        if (scroller.currX >= computeMaxScrollX() || scroller.currX <= 0) {
                        scroller.abortAnimation()
                        return
                    }*/

            scrollTo(scroller.currX, scroller.currY)

            // 滚动到了边界处
            if (scroller.isOverScrolled) {
//                如果需要区分方向就针对垂直和水平进行处理
                scroller.notifyHorizontalEdgeReached(
                    scroller.startX,
                    scroller.finalX,
                    20.dp()// overX 越界弹性距离
                )
                if (scroller.springBack(scrollX, 0, 0, computeMaxScrollX(), 0, 0)) {
                    invalidate()
                }
            }

            postInvalidate()
        }
    }

结合scrollTo 方法实现滚动的边界效果,避免出现滚动移除可见区域的情况。

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

实现测量子 View 并携带对应的 margin 值:

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        resetValue()
        for (i in 0 until childCount) {
            //如果在测量的时候设置了widthUser 那么将按照父容器的宽度- 已经使用的空间分配,同时最终的宽度不会超过父容器的宽度。
            //如果想要子 view 的宽度超过父容器的宽度,那么 width user就直接设置0.如果想要根据剩余空间进行计算子 view,就使用。
//            measureChildWithMargins(getChildAt(i), widthMeasureSpec, width, heightMeasureSpec, 0)
            measureChildWithMargins(getChildAt(i), widthMeasureSpec, 0, heightMeasureSpec, 0)
            //记录中的宽度
            totalWidth += getChildAt(i).measuredWidth
            Log.e("", "-------- > center view  $i  width  $totalWidth ")
            maxHeight = max(maxHeight, getChildAt(i).measuredHeight)
        }
//        获取宽高
        totalWidth += paddingLeft + paddingRight
        maxHeight += paddingTop + paddingBottom

        //设置最终的测量结果
        setMeasuredDimension(
            resolveSize(max(totalWidth, screenWidth), measuredWidth),//这个地方宽度设置的是最小是整个屏幕的宽度
            resolveSize(max(maxHeight, MeasureSpec.getSize(heightMeasureSpec)), measuredHeight)
        )
    }

这个地方主要的是通过measureChildWithMargins方法进行子 view 的测量。

| 特性                               | `measureChild()` | `measureChildWithMargins()` |
| -------------------------------- | ---------------- | --------------------------- |
| 是否处理子 View 的 `Margin`            | ❌ 否              | ✅ 是                         |
| 需要子 View 使用 `MarginLayoutParams` | ❌ 否              | ✅ 是                         |
| 调用时是否额外传入 used space             | ❌ 否              | ✅ 是(横向和纵向已占空间)              |
| 适用场景                             | 不关心 margin 的简单布局 | 支持 margin 的复杂布局             |

就是说想要处理子 view 的margin 值,就需要使用measureChildWithMargins方法。但是注意:在该方法内部有 widthUser,heightUser 数据,是表示已经使用的宽度;如果累加了子 view 的宽度,并widthUser = 累加宽度;那么计算的最大宽度就是父容器的最大宽度。这样如果子 view 过多,同时累加的宽度 等于父容器宽度的时候,后面没有测量的 子 View,宽度将为0;也就导致了无法正常的展示。

代码如下:

class CenterHorizontalScrollView @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null
) : ViewGroup(context, attributeSet, 0) {

    init {
//        setWillNotDraw(false); // 如果需要绘制滑动条等
//        setClipChildren(false); // 防止内容被裁剪
    }

    /**
     * 需要记录 view总的宽度
     * 需要记录 最小的滚动距离
     */

    private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
    private var totalWidth = 0
    private var maxHeight = 0//最大高度
    private val screenWidth = getScreenWidth()

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        resetValue()
        for (i in 0 until childCount) {
            //如果在测量的时候设置了widthUser 那么将按照父容器的宽度- 已经使用的空间分配,同时最终的宽度不会超过父容器的宽度。
            //如果想要子 view 的宽度超过父容器的宽度,那么 width user就直接设置0.如果想要根据剩余空间进行计算子 view,就使用。
//            measureChildWithMargins(getChildAt(i), widthMeasureSpec, width, heightMeasureSpec, 0)
            measureChildWithMargins(getChildAt(i), widthMeasureSpec, 0, heightMeasureSpec, 0)

            //记录中的宽度
            totalWidth += getChildAt(i).measuredWidth
            Log.e("", "-------- > center view  $i  width  $totalWidth ")
            maxHeight = max(maxHeight, getChildAt(i).measuredHeight)
        }
//        获取宽高
        totalWidth += paddingLeft + paddingRight
        maxHeight += paddingTop + paddingBottom

        //设置最终的测量结果
        setMeasuredDimension(
            resolveSize(max(totalWidth, screenWidth), measuredWidth),//这个地方宽度设置的是最小是整个屏幕的宽度
            resolveSize(max(maxHeight, MeasureSpec.getSize(heightMeasureSpec)), measuredHeight)
        )
    }

    //重置宽度和最大高度
    private fun resetValue() {
        totalWidth = 0//重置避免重复累加
        maxHeight = 0
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        Log.e("", "-------- > center view onLayout  width  $totalWidth  screen width $screenWidth")
        var left = paddingLeft
        val top = if (maxHeight < measuredHeight) (measuredHeight - maxHeight) / 2 else paddingTop
        //进行排版
        //如果总的累计宽度小于屏幕就居中展示
        if (totalWidth <= screenWidth) {
            for (i in 0 until childCount) {
                //如果想要居中平均分配那么就需要知道中间间距的大小值。
                val innerValue = (screenWidth - totalWidth) / (childCount - 1)
                //这个这种方式值排列展示
                getChildAt(i).let {
                    it.layout(
                        left,
                        top,
                        it.measuredWidth + left,
                        top + maxHeight//这个地方用最大的高度作为设置
                    )
                    //位置向左边偏移
                    left += it.measuredWidth + innerValue
                }
            }
        } else {
//                排版
            for (i in 0 until childCount) {
                getChildAt(i).let {
                    it.layout(left, top, left + it.measuredWidth, top + maxHeight)
                    left += it.measuredWidth
                }
            }
        }
    }

    private var downX = 0f

    //    指定滑动效果
    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        when (ev?.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                //在点击的时候如果在滚动就需要暂停滚动
                scroller.abortAnimation()
                downX = ev.x
            }

            MotionEvent.ACTION_MOVE -> {
                //如果 x轴的移动距离大于最小移动距离就进行移动
                val move = abs(ev.x - downX)
                if (move > touchSlop) {
                    return true
                }
            }
        }

        return super.onInterceptTouchEvent(ev)
    }

    private var lastX = 0
    private var scroller: OverScroller = OverScroller(context)
    private var velocityTracker: VelocityTracker? = null

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {

        if (velocityTracker == null) {
            velocityTracker = VelocityTracker.obtain()
        }
        velocityTracker?.addMovement(event)

        when (event.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                lastX = event.x.toInt()
            }

            MotionEvent.ACTION_MOVE -> {
                val x = event.x.toInt()
                val dx: Int = lastX - x
                scrollBy(dx, 0) // 滚动内容
                lastX = x
            }

            MotionEvent.ACTION_UP -> {
                velocityTracker?.computeCurrentVelocity(1000)
                //scrollX:向左边滚动的距离
                //计算得知:计算的xVelocity在原来的基础上缩小,滚动的速度将会变快。如果想要速度快一点,就减少该值
                scroller.fling(
                    scrollX, 0, (-velocityTracker?.xVelocity!!).toInt(), 0,
                    0, computeMaxScrollX(), 0, 0, 0, 0//overX属于距离。
                )
                invalidate()
                velocityTracker?.recycle()
                velocityTracker = null
            }

            MotionEvent.ACTION_CANCEL -> {
                velocityTracker?.recycle()
                velocityTracker = null
            }
        }

        return true
    }


    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
//            scroller.currX  滚动过程中当前偏移量,x 也是按照左边界为起始点 值为0
            //这种终止动画的方式在 overScroller 不太适合,会导致滑动暂停过于僵硬。
            //在Scroller 中使用下面代码的原因是为了避免过度偏移。
            /*        if (scroller.currX >= computeMaxScrollX() || scroller.currX <= 0) {
                        scroller.abortAnimation()
                        return
                    }*/

            scrollTo(scroller.currX, scroller.currY)

            // 滚动到了边界处
            if (scroller.isOverScrolled) {
//                如果需要区分方向就针对垂直和水平进行处理
                scroller.notifyHorizontalEdgeReached(
                    scroller.startX,
                    scroller.finalX,
                    20.dp()// overX 越界弹性距离
                )
                if (scroller.springBack(scrollX, 0, 0, computeMaxScrollX(), 0, 0)) {
                    invalidate()
                }
            }

            postInvalidate()
        }
    }

    //重写 scrollTo 来设滚动的最终边界点。
    override fun scrollTo(x: Int, y: Int) {
        //x 表示滚动相对于左边 的距离,
        var newX = x
        if (newX < 0) {
            newX = 0
        }
        if (newX > computeMaxScrollX()) {
            newX = computeMaxScrollX()
        }
        super.scrollTo(newX, y)
    }

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


    //用于计算子 view 的 margin 值
    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
    }

    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
    }
}

xml:


    <com.example.verifykt.move.CenterHorizontalScrollView
        android:layout_width="match_parent"
        android:layout_height="80dp"
        android:background="@color/color_fafaaa"
        android:clipChildren="false"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toBottomOf="@id/llone">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="10dp"
            android:text="你好啊"
            android:textColor="@color/color_333333"
            android:textSize="15sp" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="10dp"
            android:text="在哪里"
            android:textColor="@color/color_333333"
            android:textSize="15sp" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="10dp"
            android:text="在这里"
            android:textColor="@color/color_333333"
            android:textSize="15sp" />
              .
              .
              .
        

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:lines="1"
            android:padding="10dp"
            android:text="大家都快来啊"
            android:textColor="@color/color_333333"
            android:textSize="15sp" />


    </com.example.verifykt.move.CenterHorizontalScrollView>