深入理解 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()
    }

}

运行效果:

相关推荐
shenshizhong2 小时前
看懂鸿蒙系统源码 比较重要的知识点
android·harmonyos
一只修仙的猿4 小时前
再谈性能优化,一次项目优化经历分享
android·性能优化
雮尘6 小时前
Android性能优化之枚举替代
android
2501_915909067 小时前
苹果上架App软件全流程指南:iOS 应用发布步骤、App Store 上架流程、uni-app 打包上传与审核技巧详解
android·ios·小程序·https·uni-app·iphone·webview
2501_915921437 小时前
iOS 文件管理与能耗调试结合实战 如何查看缓存文件、优化电池消耗、分析App使用记录(uni-app开发与性能优化必备指南)
android·ios·缓存·小程序·uni-app·iphone·webview
2501_915918418 小时前
App 苹果 上架全流程解析 iOS 应用发布步骤、App Store 上架流程
android·ios·小程序·https·uni-app·iphone·webview
2501_916007478 小时前
苹果上架全流程详解,iOS 应用发布步骤、App Store 上架流程、uni-app 打包上传与审核要点完整指南
android·ios·小程序·https·uni-app·iphone·webview
PuddingSama10 小时前
Android 高级绘制技巧: BlendMode
android·前端·面试
2501_9159214310 小时前
iOS App 性能监控与优化实战 如何监控CPU、GPU、内存、帧率、耗电情况并提升用户体验(uni-app iOS开发调试必备指南)
android·ios·小程序·uni-app·iphone·webview·ux