最终的点击事件都由 onTouchEvent(MotionEvent event) 方法处理。只要手指触摸在屏幕,就会一直调用。重写 onTouchEvent 方法需要返回 true。
1. 常用常量
MotionEvent.ACTION_DOWN MotionEvent.ACTION_UP MotionEvent.ACTION_MOVE MotionEvent.ACTION_CANCEL …
2. 焦点坐标
getX():触摸点距离控件左边的距离,即视图坐标getY():触摸点距离控件顶边的距离,即视图坐标getRawX():触摸点距离整个屏幕左边的距离,即绝对坐标getRawY():触摸点距离整个屏幕顶边的距离,即绝对坐标使用 scrollTo/scrollBy 方法进行滑动时,这个过程是瞬间完成的。 如果要实现滑动效果,需要使用 Scroller。Scroller 本身不能实现 View 的滑动,就是为了计算滑动过程的位置信息。
1. 实例化 Scroller
private Scroller mScroller; public MyLinearLayout(Context context, @Nullable AttributeSet attrs) { super(context, attrs); mScroller = new Scroller(context); }2. 重写 computeScroll() 方法 参考:Android Scroller与computeScroll方法的调用关系
系统绘制 View 的时候在 draw() 方法中调用。computeScrollOffset() 方法表示是否还在滑动,根据 startScroll 的 duration 参数,在此时间内 computeScrollOffset() 一直返回 false,否则返回 true。也就是说在此时间内 Scroller 会一直计算currX,currY,时间结束才会终止计算。invalidate() 重绘,为了连续调用 computeScroll(),一直到滑动时间结束。 @Override public void computeScroll() { super.computeScroll(); if (mScroller.computeScrollOffset()) { ((View) getParent()).scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); invalidate(); } }3. 最后调用 startScroll(int startX, int startY, int dx, int dy, int duration)开始滑动
参数说明 startX:以 View 初始位置作为原点,动画在 x 轴上开始的位置,在初始位置向左为正,向右为负。 startY:以 View 初始位置作为原点,动画在 y 轴上开始的位置,在初始位置向上为正,向下为负。 dx:以 View 初始位置作为原点,动画从 startX 开始到结束间的距离。 dy:以 View 初始位置作为原点,动画从 startY 开始到结束间的距离。 duration:动画执行的总时间。这里调用 invalidate() 是为了第一次触发调用 computeScroll()。 mScroller.startScroll(200, 0, -400, 0, 5000); invalidate();由上而下
点击事件用 MotionEvent 来表示,当一个点击事件产生后,事件最先传递给 Activity。当我们点击屏幕时,就产生了点击事件,这个事件被封装成了一个 MotionEvent,系统会将这个 MotionEvent 传递给 View 层级,在 View 层级中的传递过程就是点击事件分发。当点击事件产生后会由 Activity 来处理,传递给 PhoneWindow,再传递给 DecorView,最后传递给顶层的 ViewGroup。一般在事件传递中只考虑 ViewGroup 的 onInterceptTouchEvent 方法,因为一般情况下我们不会重写 dispatchTouchEvent 方法。对于根 ViewGroup,点击事件首先传递给它的 dispatchTouchEvent 方法,如果该 ViewGroup 的 onInterceptEvent 方法返回true,则表示它要拦截这个事件,这个事件就会交给它的 onTouchEvent 方法处理;如果 onInterceptTouchEvent 方法返回 false,则表示它不拦截这个事件,则这个事件会交给它的子元素的 dispatchTouchEvent 来处理,如此反复下去。如果传递给底层的 View,View 是没有子 View 的,就会调用 View 的dispatchTouchEvent 方法,一般情况下最终都会调用 View 的 onTouchEvent 方法。由下而上
当点击事件传给底层的 View 时,如果其 onTouchEvent 方法返回 true,则事件由底层的 View 消耗并处理;如果返回 false 则表示该 View 不做处理,则传递给父 View 的 onTouchEvent 处理;如果父 View 的 onTouchEvent 仍旧返回 false,则继续传递给该父 View 的父 View 处理,如此反复下去。扩展知识:二进制、原码反码补码1、原码反码补码2、位移运算符
提供三个方法
通过 makeMeasureSpec 来保存宽和高的信息,它是一个 size+mode 的合成值,通过 getSize 和 getMode 来分解。通过 getMode 得到宽或高的测量模式。通过 getSize 得到宽或高的测量大小。三种模式
UNSPECIFIED:未指定模式,View 想多大就多大,父容器不做限制,一般用于系统内部的测量。AT_MOST:最大模式,对应于 wrap_content 属性,控件大小一般随着控件的子空间或内容进行变化,但不超过父控件允许的最大尺寸。EXACTLY:精确模式,对应于 match_parent 属性或者具体的数值,控件大小已经确定的情况。measure 用来测量 View 的宽高,它的流程分为 View 的 measure 流程和 ViewGroup 的 measure 流程,只不过 ViewGroup 的 measure 流程除了要完成自己的测量,还要遍历的调用子元素的 measure 方法。
5.2.1 View 的 Measure 流程
源码中首先是 onMeasure 方法,通过 setMeasuredDimension 方法设置 View 的宽高。 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec)); } setMeasuredDimension 方法。 protected final void setMeasuredDimension(int measuredWidth, int measuredHeight) { boolean optical = isLayoutModeOptical(this); if (optical != isLayoutModeOptical(mParent)) { Insets insets = getOpticalInsets(); int opticalWidth = insets.left + insets.right; int opticalHeight = insets.top + insets.bottom; measuredWidth += optical ? opticalWidth : -opticalWidth; measuredHeight += optical ? opticalHeight : -opticalHeight; } setMeasuredDimensionRaw(measuredWidth, measuredHeight); } getDefaultSize 方法。 在 AT_MOST 和 EXACTLY 模式下,都返回 SpecSize 这个值,即 View 在这两种测量模式下的测量宽高直接取决于 SpecSize。也就是说,对于一个直接继承 View 的 View 来说,它的 wrap_content 和 match_parent 属性的效果是一样的。如果要实现自定义 View 的 wrap_content,就需要重写 onMeasure 方法,并对 wrap_content 属性进行处理。在 UNSPECIFIED 模式下返回的是第一个参数 size 值,size 值是从 getSuggestedMinimumWidth 和 getSuggestedMinimumHeight 方法得到的。 public static int getDefaultSize(int size, int measureSpec) { int result = size; int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); switch (specMode) { case MeasureSpec.UNSPECIFIED: result = size; break; case MeasureSpec.AT_MOST: case MeasureSpec.EXACTLY: result = specSize; break; } return result; } getSuggestedMinimumWidth 方法和 getSuggestedMinimumHeight 方法。 如果没有设置背景,取值为 mMinwidth,mMinWidth 是可以设置的,对应于 android:minWidth 这个属性设置的值或者 View 的 setMinimumWidth 的值,如果不指定,默认为 0。如果设置了背景,就取 mMinWidth 和 mBackground.getMinimumWidth() 之间的最大值,这个 mBackground 是 Drawable 类型的。 protected int getSuggestedMinimumWidth() { return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth()); } protected int getSuggestedMinimumHeight() { return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight()); } 在 Drawable 中的 getMinimumWidth 方法。 intrinsicWidth 得到的是这个 Drawable 的固有宽度,如果固有宽度大于 0 则返回固有宽度,否则返回 0。 public int getMinimumWidth() { final int intrinsicWidth = getIntrinsicWidth(); return intrinsicWidth > 0 ? intrinsicWidth : 0; }5.2.2 ViewGroup 的 Measure 流程
对于 ViewGroup 它不仅要测量自身,还要遍历子元素的 measure 方法。ViewGroup 源码中没有定义 onMeasure 方法,但却定义了 measureChildren 方法。 protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) { final int size = mChildrenCount; final View[] children = mChildren; for (int i = 0; i < size; ++i) { final View child = children[i]; if ((child.mViewFlags & VISIBILITY_MASK) != GONE) { measureChild(child, widthMeasureSpec, heightMeasureSpec); } } } 遍历子元素并调用 measureChild 方法。 调用 child.getLayoutParams() 方法来获得子元素的 LayoutParams 属性,获取子元素的 MeasureSpec 并调用子元素的 measure 方法进行测量。 protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) { final LayoutParams lp = child.getLayoutParams(); final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight, lp.width); final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom, lp.height); child.measure(childWidthMeasureSpec, childHeightMeasureSpec); } 根据父容器的 MeasureSpec 模式再结合子元素的 LayoutParams 属性来得出的子元素的 MeasureSpec 。当父容器 MeasureSpec 属性为 AT_MOST,子元素的 LayoutParams 属性 WRAP_CONTENT,这时子元素的 MeasureSpec 属性也为 AT_MOST,这和子元素设置 LayoutParams 属性为 MATCH_PARENT 效果是一样的。为了解决这个问题,需要在 LayoutParams 属性为 WRAP_CONTENT 时指定一下默认的宽高。(有点晕)由于 ViewGroup 有不同布局的需要,很难统一,所以 ViewGroup 并没有提供 onMeasure 方法,而是让其子类来各自实现测量的方法。建议如果能用系统控件的情况还是应尽量用系统控件。
继承系统控件
这种自定义 View 在系统控件的基础上进行拓展,一般是添加新的功能或者修改显示的效果,一般情况下在 onDraw 方法中进行处理。例如继承 TextView 画一条横线。继承 View
不仅要实现 onDraw 方法,在实现过程中还要考虑到 wrap_content 属性以及 padding 属性的设置。为了方便配置自己的自定义 View,还会对外提供自定义的属性。如果要改变触控的逻辑,还要重写 onTouchEvent 等触控事件的方法。例如自定义一个矩形控件。 对 padding 属性进行处理,例如画一个矩形并处理 padding 值。 @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int paddingLeft = getPaddingLeft(); int paddingRight = getPaddingRight(); int paddingTop = getPaddingTop(); int paddingBottom = getPaddingBottom(); int width = getWidth() - paddingLeft - paddingRight; int height = getHeight() - paddingTop - paddingBottom; canvas.drawRect(0 + paddingLeft, 0 + paddingTop, width + paddingLeft, height + paddingTop, mPaint); } 对 warp_content 属性进行处理,当为 AT_MOST 时,设置一个默认值。 @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) { setMeasuredDimension(600, 600); } else if (widthMeasureSpec == MeasureSpec.AT_MOST) { setMeasuredDimension(600, heightSpecSize); } else if (heightMeasureSpec == MeasureSpec.AT_MOST) { setMeasuredDimension(widthSpecSize, 600); } } 自定义属性。 <?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="MyView"> <attr name="view_color" format="color" /> </declare-styleable> </resources> 全部代码。 public class MyView extends View { private int mColor = Color.RED; private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); public MyView(Context context) { super(context); initDraw(); } public MyView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MyView); mColor = typedArray.getColor(R.styleable.MyView_view_color, Color.RED); typedArray.recycle(); initDraw(); } public MyView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initDraw(); } private void initDraw() { mPaint.setColor(mColor); mPaint.setStrokeWidth(1.5f); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) { setMeasuredDimension(600, 600); } else if (widthMeasureSpec == MeasureSpec.AT_MOST) { setMeasuredDimension(600, heightSpecSize); } else if (heightMeasureSpec == MeasureSpec.AT_MOST) { setMeasuredDimension(widthSpecSize, 600); } } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int paddingLeft = getPaddingLeft(); int paddingRight = getPaddingRight(); int paddingTop = getPaddingTop(); int paddingBottom = getPaddingBottom(); int width = getWidth() - paddingLeft - paddingRight; int height = getHeight() - paddingTop - paddingBottom; canvas.drawRect(0 + paddingLeft, 0 + paddingTop, width + paddingLeft, height + paddingTop, mPaint); }继承系统特定的 ViewGroup
例如自定义一个简单的 TitleBar 首先自定义一个 RelativeLayout 的布局。 <?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/titlebarRootLayout" android:layout_width="match_parent" android:layout_height="45dp" android:orientation="vertical"> <ImageView android:id="@+id/ivTitlebarLeft" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_alignParentStart="true" android:paddingLeft="15dp" android:paddingRight="15dp" android:src="@mipmap/ic_launcher" /> <TextView android:id="@+id/tvTitlebarTitle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:ellipsize="end" android:maxEms="8" android:maxLines="1" /> <ImageView android:id="@+id/ivTitlebarRight" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_alignParentEnd="true" android:paddingLeft="15dp" android:paddingRight="15dp" android:src="@mipmap/ic_launcher" /> </RelativeLayout> 根据布局继承 RelativeLayout 。 public class TitleBar extends RelativeLayout { private RelativeLayout titlebarRootLayout; private TextView tvTitlebarTitle; private ImageView ivTitlebarLeft, ivTitlebarRight; private int mColor; private int mTextColor; private String mTitle; public TitleBar(Context context) { super(context); initView(context); } public TitleBar(Context context, AttributeSet attrs) { super(context, attrs); TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.TitleBar); mColor = typedArray.getColor(R.styleable.TitleBar_title_bg, Color.BLUE); mTextColor = typedArray.getColor(R.styleable.TitleBar_title_text_color, Color.RED); mTitle = typedArray.getString(R.styleable.TitleBar_title_text); typedArray.recycle(); initView(context); } public TitleBar(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initView(context); } private void initView(Context context) { LayoutInflater.from(context).inflate(R.layout.view_customtitle, this, true); titlebarRootLayout = findViewById(R.id.titlebarRootLayout); tvTitlebarTitle = findViewById(R.id.tvTitlebarTitle); ivTitlebarLeft = findViewById(R.id.ivTitlebarLeft); ivTitlebarRight = findViewById(R.id.ivTitlebarRight); titlebarRootLayout.setBackgroundColor(mColor); tvTitlebarTitle.setTextColor(mTextColor); tvTitlebarTitle.setText(mTitle); } public void setTitle(String title) { if (!TextUtils.isEmpty(title)) { tvTitlebarTitle.setText(title); } } public void setLeftListener(OnClickListener onClickListener) { ivTitlebarLeft.setOnClickListener(onClickListener); } public void setRightListener(OnClickListener onClickListener) { ivTitlebarRight.setOnClickListener(onClickListener); } } 自定义属性。 <?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="TitleBar"> <attr name="title_text_color" format="color" /> <attr name="title_bg" format="color" /> <attr name="title_text" format="string" /> </declare-styleable> </resources>继承 ViewGroup
首先 onMeasure 方法中测量,正常情况下,我们应该根据 LayoutParams 中的宽高来处理,接着根据 widthMode 和 heightMode 来分别设置宽高,这里采用简化写法,如果没有子元素,直接将宽高设置为 0。这里没有考虑 padding 和子元素的 margin。 @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); measureChildren(widthMeasureSpec, heightMeasureSpec); // 如果没有子元素,就设置宽和高都为 0 if (getChildCount() == 0) { setMeasuredDimension(0, 0); } // 宽和高都是 AT_MOST,则宽度设置为所有子元素宽度的和,高度设置为第一个子元素的高度 else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) { View childOne = getChildAt(0); int childWidth = childOne.getMeasuredWidth(); int childHeight = childOne.getMeasuredHeight(); setMeasuredDimension(childWidth * getChildCount(), childHeight); } // 宽度是 AT_MOST,则宽度为所有子元素宽度的和 else if (widthMode == MeasureSpec.AT_MOST) { int childWidth = getChildAt(0).getMeasuredWidth(); setMeasuredDimension(childWidth * getChildCount(), heightSize); } // 高度是 AT_MOST,则高度为第一个子元素的高度 else if (heightMode == MeasureSpec.AT_MOST) { int childHeight = getChildAt(0).getMeasuredHeight(); setMeasuredDimension(widthSize, childHeight); } } 实现 onLayout 方法布局子元素。遍历所有子元素,如果子元素不是 GONE,则调用子元素的 layout 方法将其放置到合适的位置上。在这里第一个子元素占满位置,后面的子元素以相同的宽度依次想后排,对于 left 来说是一直累加的。这里没有考虑 padding 和子元素的 margin。 @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childCount = getChildCount(); int left = 0; View child; for (int i = 0; i < childCount; i++) { child = getChildAt(i); if (child.getVisibility() != View.GONE) { int width = child.getMeasuredWidth(); childWidth = width; child.layout(left, 0, left + width, child.getMeasuredHeight()); left += width; } } } 滑动切换页面,需要利用到 Scroller ,在 onTouchEvent 方法中处理。 @Override public boolean onTouchEvent(MotionEvent event) { int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: break; case MotionEvent.ACTION_MOVE: int deltaX = x - lastX; scrollBy(-deltaX, 0); break; case MotionEvent.ACTION_UP: int distance = getScrollX() - currentIndex * childWidth; // 判断滑动距离是否大于 1/2,是就切换页面 if (Math.abs(distance) > childWidth / 2) { if (distance > 0) { currentIndex++; } else { currentIndex--; } } smoothScrollTo(currentIndex * childWidth, 0); break; default: break; } lastX = x; lastY = y; return true; } private void smoothScrollTo(int destX, int destY) { scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000); invalidate(); } 快速滑动切换页面,需要利用 VelocityTracker,在 onTouchEvent 方法的 ACTION_UP 中处理。为了用户体验,通常情况下,不仅仅要判断滑动一半时就切换页面,如果滑动速度较快,也应该判定为用户想要切换页面。参考:让控件如此丝滑Scroller和VelocityTracker的API讲解与实战——Android高级UI @Override public boolean onTouchEvent(MotionEvent event) { // 为 VelocityTracker 传入触摸事件(包括ACTION_DOWN、ACTION_MOVE、ACTION_UP等), // 这样 VelocityTracker 才能在调用了 computeCurrentVelocity 方法后,正确的获得当前的速度。 tracker.addMovement(event); int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: break; case MotionEvent.ACTION_MOVE: int deltaX = x - lastX; scrollBy(-deltaX, 0); break; case MotionEvent.ACTION_UP: int distance = getScrollX() - currentIndex * childWidth; // 判断滑动距离是否大于 1/2,是就切换页面 if (Math.abs(distance) > childWidth / 2) { if (distance > 0) { currentIndex++; } else { currentIndex--; } } else { // 根据已经传入的触摸事件计算出当前的速度,可以通过getXVelocity 或 getYVelocity进行获取对应方向上的速度。 // 值得注意的是,计算出的速度值不超过Float.MAX_VALUE。参数解析: 速度的单位。值为1表示每毫秒像素数,1000表示每秒像素数。 tracker.computeCurrentVelocity(1000); float xV = tracker.getXVelocity(); if (Math.abs(xV) > 50) { if (xV > 0) { // 切换到上一个页面 currentIndex--; } else { // 切换到下一个页面 currentIndex++; } } } currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex; smoothScrollTo(currentIndex * childWidth, 0); // 重置 VelocityTracker 回其初始状态。 tracker.clear(); break; default: break; } lastX = x; lastY = y; return true; } 处理滑动冲突,如果里面是垂直滑动的 RecyclerView,这时候会导致滑动冲突。在滑动的时候检测到滑动方向是水平的话,就让父 View 拦截,确保父 View 用来进行 View 的滑动切换。 @Override public boolean onInterceptTouchEvent(MotionEvent ev) { boolean intercept = false; int x = (int) ev.getX(); int y = (int) ev.getY(); switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: intercept = false; break; case MotionEvent.ACTION_MOVE: int deltaX = x - lastInterceptX; int deltaY = y - lastInterceptY; // 判断是水平滑动还是垂直滑动 if (Math.abs(deltaX) - Math.abs(deltaY) > 0) { intercept = true; Log.d("TAG", "intercept=true"); } else { intercept = false; Log.d("TAG", "intercept=false"); } break; case MotionEvent.ACTION_UP: intercept = false; break; } // 如果不拦截,将不会执行 onTouchEvent 方法,会直接进入到子元素的点击事件,所以在这里也要设置 lastX 和 lastY。 lastX = x; lastY = y; lastInterceptX = x; lastInterceptY = y; return intercept; } 再次触摸屏幕阻止页面继续滑动。 case MotionEvent.ACTION_DOWN: if (!scroller.isFinished()) { scroller.abortAnimation(); } break; 完整代码。 public class HorizontalView extends ViewGroup { private int lastX; private int lastY; private int currentIndex = 0; private int childWidth = 0; private Scroller scroller; private VelocityTracker tracker; private int lastInterceptX = 0; private int lastInterceptY = 0; public HorizontalView(Context context) { super(context); init(); } public HorizontalView(Context context, AttributeSet attrs) { super(context, attrs); init(); } public HorizontalView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public void init() { scroller = new Scroller(getContext()); tracker = VelocityTracker.obtain(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); measureChildren(widthMeasureSpec, heightMeasureSpec); // 如果没有子元素,就设置宽和高都为 0 if (getChildCount() == 0) { setMeasuredDimension(0, 0); } // 宽和高都是 AT_MOST,则宽度设置为所有子元素宽度的和,高度设置为第一个子元素的高度 else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) { View childOne = getChildAt(0); int childWidth = childOne.getMeasuredWidth(); int childHeight = childOne.getMeasuredHeight(); setMeasuredDimension(childWidth * getChildCount(), childHeight); } // 宽度是 AT_MOST,则宽度为所有子元素宽度的和 else if (widthMode == MeasureSpec.AT_MOST) { int childWidth = getChildAt(0).getMeasuredWidth(); setMeasuredDimension(childWidth * getChildCount(), heightSize); } // 高度是 AT_MOST,则高度为第一个子元素的高度 else if (heightMode == MeasureSpec.AT_MOST) { int childHeight = getChildAt(0).getMeasuredHeight(); setMeasuredDimension(widthSize, childHeight); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childCount = getChildCount(); int left = 0; View child; for (int i = 0; i < childCount; i++) { child = getChildAt(i); if (child.getVisibility() != View.GONE) { int width = child.getMeasuredWidth(); childWidth = width; child.layout(left, 0, left + width, child.getMeasuredHeight()); left += width; } } } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { boolean intercept = false; int x = (int) ev.getX(); int y = (int) ev.getY(); switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: intercept = false; if (!scroller.isFinished()) { scroller.abortAnimation(); } break; case MotionEvent.ACTION_MOVE: int deltaX = x - lastInterceptX; int deltaY = y - lastInterceptY; // 判断是水平滑动还是垂直滑动 if (Math.abs(deltaX) - Math.abs(deltaY) > 0) { intercept = true; Log.d("TAG", "intercept=true"); } else { intercept = false; Log.d("TAG", "intercept=false"); } break; case MotionEvent.ACTION_UP: intercept = false; break; } // 如果不拦截,将不会执行 onTouchEvent 方法,会直接进入到子元素的点击事件,所以在这里也要设置 lastX 和 lastY。 lastX = x; lastY = y; lastInterceptX = x; lastInterceptY = y; return intercept; } @Override public boolean onTouchEvent(MotionEvent event) { // 为 VelocityTracker 传入触摸事件(包括ACTION_DOWN、ACTION_MOVE、ACTION_UP等), // 这样 VelocityTracker 才能在调用了 computeCurrentVelocity 方法后,正确的获得当前的速度。 tracker.addMovement(event); int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: if (!scroller.isFinished()) { scroller.abortAnimation(); } break; case MotionEvent.ACTION_MOVE: int deltaX = x - lastX; scrollBy(-deltaX, 0); break; case MotionEvent.ACTION_UP: int distance = getScrollX() - currentIndex * childWidth; // 判断滑动距离是否大于 1/2,是就切换页面 if (Math.abs(distance) > childWidth / 2) { if (distance > 0) { currentIndex++; } else { currentIndex--; } } else { // 根据已经传入的触摸事件计算出当前的速度,可以通过getXVelocity 或 getYVelocity进行获取对应方向上的速度。 // 值得注意的是,计算出的速度值不超过Float.MAX_VALUE。参数解析: 速度的单位。值为1表示每毫秒像素数,1000表示每秒像素数。 tracker.computeCurrentVelocity(1000); // 获取速度值,如果速度的绝对值大于 50,则认为是“快速滑动” float xV = tracker.getXVelocity(); if (Math.abs(xV) > 50) { if (xV > 0) { // 切换到上一个页面 currentIndex--; } else { // 切换到下一个页面 currentIndex++; } } } currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex; smoothScrollTo(currentIndex * childWidth, 0); // 重置 VelocityTracker 回其初始状态。 tracker.clear(); break; default: break; } lastX = x; lastY = y; return true; } private void smoothScrollTo(int destX, int destY) { scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000); invalidate(); } @Override public void computeScroll() { super.computeScroll(); if (scroller.computeScrollOffset()) { scrollTo(scroller.getCurrX(), scroller.getCurrY()); postInvalidate(); } } } 布局文件。 <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".MainActivity"> <com.mryuan.learndemo.HorizontalView android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/green"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView1" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/colorPrimary" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView2" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/green" /> </com.mryuan.learndemo.HorizontalView> </LinearLayout> 执行效果。