最终效果如下图:在一个 Activity 中横向展示多个页面,每个页面都有自己的 ListView 列表,当手指上下滑动的距离大于横向滑动的距离时,就滚动当前页面的 ListView 列表项,当手指上下滑动的距离小于横向滑动的距离时,就切换页面,类似 ViewPager

自定义 MyHorizontalScrollView - 类似 ViewPager_Math

下面分成两部分说明一下,①测量和布局;②事件分发

PART_A 测量和布局

  1. 先看下我们这个容器控件在Activity中的具体情况
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<cc.catface.helloworld.view.MyHorizontalScrollView
android:id="@+id/mhsv_01"
android:layout_width="match_parent"
android:layout_height="match_parent" />

</LinearLayout>

从上可得重要信息:自定义ViewGroup的宽/高设置都为match_parent即对应的LayoutParams为EXACTLY

  1. 测量onMeasure()
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

// 记录当前ViewGroup的测量宽/高值
int measureedWidth;
int measureedHeight;

int childCount = getChildCount();

measureChildren(widthMeasureSpec, heightMeasureSpec);

int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);

if (childCount == 0) {
setMeasuredDimension(0, 0);
} else {
View childView = getChildAt(0);

// 当子(元素)页面宽/高设置为wrap_content时,自行计算子(元素)页面的宽/高值
// 当前ViewGroup的宽即单个子页面宽值乘以子页面个数
// 在此由于当前ViewGroup的宽/高设置都为match_parent,所以最后交给系统的整体控件的宽/高值均为父容器即所在Activity页面的建议值
measureedWidth = childView.getMeasuredWidth() * childCount;
measureedHeight = childView.getMeasuredHeight();

setMeasuredDimension((widthSpecMode == MeasureSpec.AT_MOST) ? measureedWidth : widthSpecSize, (heightSpecMode == MeasureSpec.AT_MOST) ? measureedHeight : heightSpecSize);
}
}
  1. 布局onLayout()
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) {
int left = 0;
int childCount = getChildCount();
mChildrenSize = childCount;

for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (childView.getVisibility() != View.GONE) {

// 即父容器的建议宽/高值match_parent
int childWidth = childView.getMeasuredWidth();
int childHeight = childView.getMeasuredHeight();

mChildWidth = childWidth;
childView.layout(left, 0, left + childWidth, childHeight);
// 为后续子页面的左起点坐标做处理
left += childWidth;
}
}
}

PART_B 事件分发

  1. 初始化
public class MyHorizontalScrollView extends ViewGroup {

private int mChildrenSize;
private int mChildWidth;
private int mChildIndex;

// 记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;

// 记录上次滑动的坐标(onInterceptTouchEvent)
private int mLastXIntercept;
private int mLastYIntercept;

private Scroller mScroller;
private VelocityTracker mVelocityTracker;


public MyHorizontalScrollView(Context context) {
super(context);
init();
}

public MyHorizontalScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}

public MyHorizontalScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

private void init() {
if (mScroller == null) {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
}
......
}
  1. onInterceptTouchEvent
@Override public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();

switch (event.getAction()) {

// 触屏瞬间拦截由父容器处理事件
case MotionEvent.ACTION_DOWN:
intercepted = false;
if (!mScroller.isFinished()) {
mScroller.abortAnimation(); // 可省,优化滑动体验
intercepted = true;
}
break;

// 根据水平/竖直滑动距离的大小控制切页面还是滚动当前页面列表
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (Math.abs(deltaX) > Math.abs(deltaY + 40)) {
intercepted = true;
} else {
intercepted = false;
}
break;

case MotionEvent.ACTION_UP:
intercepted = false;
break;

default:
break;

}


mLastX = x;
mLastY = y;
mLastXIntercept = x;
mLastYIntercept = y;

return intercepted;
}
  1. onTouchEvent
@Override public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();

switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;

// 滑动页面
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);
break;

// 根据水平滑动的方向和速度大小确定向左或向右切换页面
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
}

// 控制滑动的页面在正确范围内
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));

// 手指离屏瞬间控制滑动方向和距离
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
default:
break;
}

mLastX = x;
mLastY = y;

return true;
}
  1. 滑动
private void smoothScrollBy(int dx, int dy) {
// int startX, int startY, int dx, int dy, int duration
mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
invalidate();
}
// 光滑滑动
@Overridepublic void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
  1. 内存回收
@Override protected void onDetachedFromWindow() {
mVelocityTracker.clear();
mVelocityTracker.recycle();
super.onDetachedFromWindow();
}

完整类如下

public class MyHorizontalScrollView extends ViewGroup {

private int mChildrenSize;
private int mChildWidth;
private int mChildIndex;

// 记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;

// 记录上次滑动的坐标(onInterceptTouchEvent)
private int mLastXIntercept;
private int mLastYIntercept;

private Scroller mScroller;
private VelocityTracker mVelocityTracker;


public MyHorizontalScrollView(Context context) {
super(context);
init();
}

public MyHorizontalScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}

public MyHorizontalScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

private void init() {
if (mScroller == null) {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
}



@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();

switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
intercepted = false;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
intercepted = true;
}
break;

case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (Math.abs(deltaX) > Math.abs(deltaY + 40)) {
intercepted = true;
} else {
intercepted = false;
}
break;

case MotionEvent.ACTION_UP:
intercepted = false;
break;

default:
break;

}


mLastX = x;
mLastY = y;
mLastXIntercept = x;
mLastYIntercept = y;

return intercepted;
}

@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();

switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
}
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
default:
break;
}

mLastX = x;
mLastY = y;

return true;
}

private void smoothScrollBy(int dx, int dy) {
// int startX, int startY, int dx, int dy, int duration
mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
invalidate();
}

@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

// 记录子View测量宽/高值
int measureedWidth;
int measureedHeight;

int childCount = getChildCount();

measureChildren(widthMeasureSpec, heightMeasureSpec);

int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);

if (childCount == 0) {
setMeasuredDimension(0, 0);
} else {
View childView = getChildAt(0);

// 当子(元素)页面宽/高设置为wrap_content时,自行计算子(元素)页面的宽/高值
// 整体布局宽即单个子页面宽值乘以子页面个数
// 在此由于当前控件的宽/高设置都为match_parent,所以最后交给系统整体控件的宽/高值均为父容器即所在Activity页面的建议值
measureedWidth = childView.getMeasuredWidth() * childCount;
measureedHeight = childView.getMeasuredHeight();

setMeasuredDimension((widthSpecMode == MeasureSpec.AT_MOST) ? measureedWidth : widthSpecSize, (heightSpecMode == MeasureSpec.AT_MOST) ? measureedHeight : heightSpecSize);
}
}



@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int left = 0;
int childCount = getChildCount();
mChildrenSize = childCount;

for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (childView.getVisibility() != View.GONE) {

// 即父容器的建议宽/高值match_parent
int childWidth = childView.getMeasuredWidth();
int childHeight = childView.getMeasuredHeight();

mChildWidth = childWidth;
childView.layout(left, 0, left + childWidth, childHeight);
// 为后续子页面的左起点坐标做处理
left += childWidth;
}
}
}


@Override
protected void onDetachedFromWindow() {
mVelocityTracker.clear();
mVelocityTracker.recycle();
super.onDetachedFromWindow();
}
}