前言
现在,我们通过实现一个简易版的 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
方法,我们直接传递父 ViewGroup
的 MeasureSpec
即可。
布局中使用:
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 默认不消费触摸事件,所以事件序列会直接分发给父 ViewGroup
的 onTouchEvent
方法进行处理。此时,并不会进入到 onInterceptTouchEvent
中的 MOVE
分支。
只有当子 View 会消费事件,用户在子 View 上滑动时,才会进入到 onInterceptTouchEvent
的 MOVE
分支。
自动贴边
当用户手指抬起后,应该要平滑地滚动到最近的页面。我们使用 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()
}
}
运行效果: