深入理解 Android 触摸事件:以实现 ViewPager 为例

前言

现在,我们通过实现一个简易版的 ViewPager,来演示如何自定义 ViewGroup 的触摸反馈。

实现过程

搭建框架

首先对所有子 View 进行测量、布局,让它们水平排列。

kotlin 复制代码
class MyViewPager(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 统一测量所有子View
        // 让所有子View的大小都和ViewGroup一样
        measureChildren(widthMeasureSpec, heightMeasureSpec)
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // 子View从左到右依次水平排列
        var left = 0
        val top = 0
        val right = width
        val bottom = height

        for (child in children) {
            child.layout(left, top, left + right, bottom)
            left += right
        }
    }
}

其中 measureChildren 方法会遍历所有子 View 并调用它们的 measure 方法,我们直接传递父 ViewGroupMeasureSpec 即可。

布局中使用:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<com.example.customviewpager.view.MyViewPager xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#2196F3" />

    <View
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#03A9F4" />

    <View
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#00BCD4" />

</com.example.customviewpager.view.MyViewPager>

运行效果:

页面拖动

然后重写 onTouchEvent 方法,让手指能够拖动页面。

kotlin 复制代码
// 按下时的X坐标
private var downX = 0f

// 记录按下时的滚动位置
private var downScrollX = 0f

override fun onTouchEvent(event: MotionEvent): Boolean {
    if (isEmpty()) return false

    when (event.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            downX = event.x
            downScrollX = scrollX.toFloat()
        }

        MotionEvent.ACTION_MOVE -> {
            val moveX = event.x
            // 计算目标滚动位置
            val targetScrollX = (downX - moveX + downScrollX).toInt()
            // 滑动所有子View
            scrollTo(targetScrollX, 0)
        }
    }

    return true
}

注意:scrollTo 方法的坐标系是反的,例如,要让 View 位于 (-100,0) 的位置,需要调用 scrollTo(100, 0)。

再来为滚动添加边界。

kotlin 复制代码
// onTouchEvent 方法中
MotionEvent.ACTION_MOVE -> {
    val moveX = event.x
    // 计算目标滚动位置
    val targetScrollX = (downX - moveX + downScrollX)

    // 滚动边界
    val minScrollX = 0f
    val maxScrollX = (childCount - 1) * width.toFloat()

    val finalScrollX = targetScrollX.coerceIn(minScrollX, maxScrollX)

    scrollTo(finalScrollX.toInt(), 0)
}

这样在滚动到两端时,就无法继续滚动了。

拦截事件

我们应该在需要时,也就是水平移动超过了阈值后,才响应水平滑动,重写 onInterceptTouchEvent 方法。

kotlin 复制代码
private val viewConfiguration = ViewConfiguration.get(context)

// 触摸滑动的阈值
private var touchSlop = viewConfiguration.scaledTouchSlop

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    if (isEmpty()) return false

    when (ev.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            // 为之后滑动做准备
            downX = ev.x
            downScrollX = scrollX.toFloat()
        }

        MotionEvent.ACTION_MOVE -> {
            val moveX = ev.x
            val distanceX = abs(moveX - downX)

            if (distanceX > touchSlop) {
                // 让父View不拦截滑动
                requestDisallowInterceptTouchEvent(true)
                return true
            }
        }
    }

    // 默认不拦截
    return false
}

在当前的示例中,子 View 默认不消费触摸事件,所以事件序列会直接分发给父 ViewGrouponTouchEvent 方法进行处理。此时,并不会进入到 onInterceptTouchEvent 中的 MOVE 分支。

只有当子 View 会消费事件,用户在子 View 上滑动时,才会进入到 onInterceptTouchEventMOVE 分支。

自动贴边

当用户手指抬起后,应该要平滑地滚动到最近的页面。我们使用 OverScroller 来滚动,因为它能够恰好在目标页面停下。

kotlin 复制代码
private val overScroller = OverScroller(context)

override fun onTouchEvent(event: MotionEvent): Boolean {
    if (isEmpty()) return false

    when (event.actionMasked) {
        // ...

        MotionEvent.ACTION_UP -> {
            val currentScrollX = scrollX
            // 目标页
            val targetPage = (currentScrollX + width / 2) / width
            val finalTargetPage = targetPage.coerceIn(0, childCount - 1)
            
            // 目标滚动位置
            val targetScrollX = finalTargetPage * width
            // 滚动距离
            val scrollDistance = targetScrollX - currentScrollX

            overScroller.startScroll(currentScrollX, 0, scrollDistance, 0)
            postInvalidateOnAnimation()
        }
    }

    return true
}

override fun computeScroll() {
    super.computeScroll()
    if (overScroller.computeScrollOffset()) {
        scrollTo(overScroller.currX, overScroller.currY)
        postInvalidateOnAnimation()
    }
}

这里,我们利用了 postInvalidateOnAnimation()computeScroll() 来完成贴边动画。

postInvalidateOnAnimation() 会在下一帧标记当前 View 为失效,而 computeScroll() 方法会在 draw 方法中被调用。我们重写 computeScroll() 方法,在里面滚动了 View 并再次调用 postInvalidateOnAnimation(),所以能够递归地完成动画。

现在,我们就完成了 ViewPager 的大半效果了,我们能够滑动到不同的页面,然后松手后能够停靠在最近的页面。

快滑翻页

再来实现快速滑动翻页,即使滑动的距离很短(不足半页)。我们需要用到 VelocityTracker,它能够获取手指滑动的速度。

kotlin 复制代码
// 速度追踪器
private val velocityTracker = VelocityTracker.obtain()
private var minVelocity = viewConfiguration.scaledMinimumFlingVelocity
private var maxVelocity = viewConfiguration.scaledMaximumFlingVelocity

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    if (isEmpty()) return false

    if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
        // 清空速度追踪器
        velocityTracker.clear()
    }
    // 添加事件
    velocityTracker.addMovement(ev)

    // ...
}

override fun onTouchEvent(event: MotionEvent): Boolean {
    if (isEmpty()) return false

    if (event.actionMasked == MotionEvent.ACTION_DOWN) {
        // 清空速度追踪器
        velocityTracker.clear()
    }
    // 添加事件
    velocityTracker.addMovement(event)

    when (event.actionMasked) {
        // ...

        MotionEvent.ACTION_UP -> {
            // 计算当前速度,单位px/s
            velocityTracker.computeCurrentVelocity(1000, maxVelocity.toFloat())
            val xVelocity = velocityTracker.xVelocity

            // 获取当前滚动位置
            val currentScrollX = scrollX
            // 获取之前的页码
            val oldPage = (downScrollX / width).toInt()
            val targetPage = if (abs(xVelocity) > minVelocity){
                // 如果水平速度超过了系统定义的最小 fling 速度
                // 根据速度方向来决定目标页面
                if (xVelocity < 0) oldPage + 1 else oldPage - 1
            }else{
                // 否则是普通拖拽,根据当前位置来决定目标页面
                (currentScrollX + width / 2) / width
            }

            val finalTargetPage = targetPage.coerceIn(0, childCount - 1)
            val targetScrollX = finalTargetPage * width
            val scrollDistance = targetScrollX - currentScrollX

            overScroller.startScroll(currentScrollX, 0, scrollDistance, 0)
            postInvalidateOnAnimation()
        }
    }

    return true
}

// 回收资源
override fun onDetachedFromWindow() {
    velocityTracker.recycle()
    super.onDetachedFromWindow()
}

需要说明一点,这里的基准页是手指按下时 所在的页面(oldPage),你也可以采用手指抬起时 所在的页面(currentPage)作为基准页。

现在,我们快速滑动也能翻页了。

边界回弹效果

最后,我们来扩展一下回弹效果:滑至两端时,滑动会带有阻尼。

在手指移动中,如果超出滑动边界,让滚动距离小于实际滑动距离即可,这样就会带有阻尼感。

kotlin 复制代码
companion object {
    private const val OVERSCROLL_DAMPING_FACTOR = 0.5f
}

// onTouchEvent 方法中
MotionEvent.ACTION_MOVE -> {
    val moveX = event.x
    // 计算目标滚动位置
    val targetScrollX = (downX - moveX + downScrollX)

    // 滚动边界
    val minScrollX = 0f
    val maxScrollX = (childCount - 1) * width.toFloat()
    val finalScrollX = if (targetScrollX < minScrollX) {
        val dampedOverscroll = (targetScrollX - minScrollX) * OVERSCROLL_DAMPING_FACTOR
        minScrollX + dampedOverscroll
    } else if (targetScrollX > maxScrollX) {
        val dampedOverscroll = (targetScrollX - maxScrollX) * OVERSCROLL_DAMPING_FACTOR
        maxScrollX + dampedOverscroll
    } else {
        targetScrollX.coerceIn(minScrollX, maxScrollX)
    }

    scrollTo(finalScrollX.toInt(), 0)
}

最后,在手指抬起后,要先判断当前是否越界。如果是,目标页就是第 0 页或者最后一页,并不需要再判断速度或位置。

kotlin 复制代码
MotionEvent.ACTION_UP -> {
    // 计算当前速度,单位px/s
    velocityTracker.computeCurrentVelocity(1000, maxVelocity.toFloat())
    val xVelocity = velocityTracker.xVelocity

    // 获取当前滚动位置
    val currentScrollX = scrollX
    // 获取之前的页码
    val oldPage = (downScrollX / width).toInt()
    
    // 滚动边界
    val minScrollInt = 0
    val maxScrollInt = (childCount - 1) * width

    val targetPage = when{
        currentScrollX < minScrollInt -> {
            0
        }
        currentScrollX > maxScrollInt -> {
            childCount - 1
        }
        else -> {
            if (abs(xVelocity) > minVelocity) {
                // 如果水平速度超过了系统定义的最小 fling 速度
                // 根据速度方向来决定目标页面
                if (xVelocity < 0) oldPage + 1 else oldPage - 1
            } else {
                // 否则,根据当前位置来决定目标页面
                (currentScrollX + width / 2) / width
            }
        }
    }

    val finalTargetPage = targetPage.coerceIn(0, childCount - 1)
    // ...
}

至此,我们就完成了 MyViewPager。走完了构建一个复杂自定义 ViewGroup 的全过程。

完整代码

kotlin 复制代码
class MyViewPager(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs) {

    // 按下时的X坐标
    private var downX = 0f

    // 记录按下时的滚动位置
    private var downScrollX = 0f

    private val viewConfiguration = ViewConfiguration.get(context)

    // 触摸滑动的阈值
    private var touchSlop = viewConfiguration.scaledTouchSlop

    private val overScroller = OverScroller(context)

    // 速度追踪器
    private val velocityTracker = VelocityTracker.obtain()
    private var minVelocity = viewConfiguration.scaledMinimumFlingVelocity
    private var maxVelocity = viewConfiguration.scaledMaximumFlingVelocity

    companion object {
        private const val OVERSCROLL_DAMPING_FACTOR = 0.5f
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 统一测量所有子View
        // 让所有子View的大小都和ViewGroup一样
        measureChildren(widthMeasureSpec, heightMeasureSpec)
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // 子View从左到右依次水平排列
        var left = 0
        val top = 0
        val right = width
        val bottom = height

        for (child in children) {
            child.layout(left, top, left + right, bottom)
            left += right
        }
    }

    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        if (isEmpty()) return false

        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            // 清空速度追踪器
            velocityTracker.clear()
        }
        // 添加事件
        velocityTracker.addMovement(ev)

        when (ev.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                // 为之后滑动做准备
                downX = ev.x
                downScrollX = scrollX.toFloat()
            }

            MotionEvent.ACTION_MOVE -> {
                val moveX = ev.x
                val distanceX = abs(moveX - downX)

                if (distanceX > touchSlop) {
                    // 让父View不拦截滑动
                    requestDisallowInterceptTouchEvent(true)
                    return true
                }
            }
        }

        // 默认不拦截
        return false
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (isEmpty()) return false

        if (event.actionMasked == MotionEvent.ACTION_DOWN) {
            // 清空速度追踪器
            velocityTracker.clear()
        }
        // 添加事件
        velocityTracker.addMovement(event)

        when (event.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                downX = event.x
                downScrollX = scrollX.toFloat()
            }

            MotionEvent.ACTION_MOVE -> {
                val moveX = event.x
                // 计算目标滚动位置
                val targetScrollX = (downX - moveX + downScrollX)

                // 滚动边界
                val minScrollX = 0f
                val maxScrollX = (childCount - 1) * width.toFloat()
                val finalScrollX = if (targetScrollX < minScrollX) {
                    val dampedOverscroll = (targetScrollX - minScrollX) * OVERSCROLL_DAMPING_FACTOR
                    minScrollX + dampedOverscroll
                } else if (targetScrollX > maxScrollX) {
                    val dampedOverscroll = (targetScrollX - maxScrollX) * OVERSCROLL_DAMPING_FACTOR
                    maxScrollX + dampedOverscroll
                } else {
                    targetScrollX.coerceIn(minScrollX, maxScrollX)
                }

                scrollTo(finalScrollX.toInt(), 0)
            }

            MotionEvent.ACTION_UP -> {
                // 计算当前速度,单位px/s
                velocityTracker.computeCurrentVelocity(1000, maxVelocity.toFloat())
                val xVelocity = velocityTracker.xVelocity

                // 获取当前滚动位置
                val currentScrollX = scrollX
                // 获取之前的页码
                val oldPage = (downScrollX / width).toInt()

                // 滚动边界
                val minScrollInt = 0
                val maxScrollInt = (childCount - 1) * width

                val targetPage = when {
                    currentScrollX < minScrollInt -> {
                        0
                    }

                    currentScrollX > maxScrollInt -> {
                        childCount - 1
                    }

                    else -> {
                        if (abs(xVelocity) > minVelocity) {
                            // 如果水平速度超过了系统定义的最小 fling 速度
                            // 根据速度方向来决定目标页面
                            if (xVelocity < 0) oldPage + 1 else oldPage - 1
                        } else {
                            // 否则,根据当前位置来决定目标页面
                            (currentScrollX + width / 2) / width
                        }
                    }
                }

                val finalTargetPage = targetPage.coerceIn(0, childCount - 1)
                val targetScrollX = finalTargetPage * width
                val scrollDistance = targetScrollX - currentScrollX

                overScroller.startScroll(currentScrollX, 0, scrollDistance, 0)
                postInvalidateOnAnimation()
            }
        }

        return true
    }

    override fun computeScroll() {
        super.computeScroll()
        if (overScroller.computeScrollOffset()) {
            scrollTo(overScroller.currX, overScroller.currY)
            postInvalidateOnAnimation()
        }
    }

    override fun onDetachedFromWindow() {
        // 回收速度跟踪器
        velocityTracker.recycle()
        super.onDetachedFromWindow()
    }

}

运行效果:

相关推荐
2501_916008893 小时前
Web 前端开发常用工具推荐与团队实践分享
android·前端·ios·小程序·uni-app·iphone·webview
我科绝伦(Huanhuan Zhou)4 小时前
MySQL一键升级脚本(5.7-8.0)
android·mysql·adb
怪兽20145 小时前
Android View, SurfaceView, GLSurfaceView 的区别
android·面试
龚礼鹏6 小时前
android 图像显示框架二——流程分析
android
消失的旧时光-19436 小时前
kmp需要技能
android·设计模式·kotlin
帅得不敢出门7 小时前
Linux服务器编译android报no space left on device导致失败的定位解决
android·linux·服务器
雨白7 小时前
协程间的通信管道 —— Kotlin Channel 详解
android·kotlin
TimeFine9 小时前
kotlin协程 容易被忽视的CompletableDeferred
android
czhc114007566310 小时前
Linux1023 mysql 修改密码等
android·mysql·adb
GOATLong11 小时前
MySQL内置函数
android·数据库·c++·vscode·mysql