本文首发于微信公众号「后厂技术官」

前言

此前讲了很多,终于可以讲到这一节了,本文的例子是一个自定义的ViewGroup,左右滑动切换不同的页面,类似一个特别简化的ViewPager,这篇文章会涉及到这个系列的很多文章的内容比如View的measure、layout和draw流程,view的滑动等等,所以对View体系不大了解的同学看这篇文章前可以先从头阅读本系列的其他文章,再来看这篇文章效果会更好些。需要注意的是我们知道要实现一个自定义的ViewGroup是很复杂的,这个看看LineraLayout等源码我们就会知道,这里我们只需要把主要的功能实现就好了。

1.继承ViewGroup

要实现自定义的ViewGroup,首先要继承ViewGroup并调用父类构造方法,实现抽象方法等。

import android.content.Context;
import android.util.AttributeSet;
import android.view.ViewGroup;
public class HorizontalView extends ViewGroup{
public HorizontalView(Context context) {
super(context);
}
public HorizontalView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public HorizontalView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
}
}

这里我们定义了名字叫HorizontalView的类并继承 ViewGroup,onLayout这个抽象方法是必须要实现的,我们暂且什么都不做。

2.对wrap_content属性进行处理

Android View体系(九)自定义View这篇文章中我们同样对wrap_content属性进行了处理不明白的可以查看这篇文章或者直接查看Android View体系(七)从源码解析View的measure流程来了解具体的原因,这里就不赘述了。

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
public class HorizontalView extends ViewGroup {
//...省略此前的构造代码
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
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);
}
//如果宽度是wrap_content,则宽度为所有子元素的宽度的和
else if (widthMode == MeasureSpec.AT_MOST) {
int childWidth = getChildAt(0).getMeasuredWidth();
setMeasuredDimension(childWidth * getChildCount(), heightSize);
}
//如果高度是wrap_content,则高度为第一个子元素的高度
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) {
}
}

这里如果没有子元素时采用了简化的写法直接将宽和高直接设置为0,正常的话我们应该根据LayoutParams中的宽和高来做相应的处理,另外我们在测量时没有考虑它的padding和子元素的margin。

3.实现onLayout

接下来我们实现onLayout,来布局子元素,因为每一种布局方式子View的布局都是不同的,所以这个是ViewGroup唯一一个抽象方法,需要我们自己去实现:

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;

public class HorizontalView extends ViewGroup {
//... 省略构造方法代码和onMeasure的代码

@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;
}
}
}

遍历所有的子元素,如果子元素不是GONE,则调用子元素的layout方法将其放置到合适的位置上,相当于默认第一个子元素占满了屏幕,后面的子元素就是在第一个屏幕后面紧挨着和屏幕一样大小的后续元素,所以left是一直累加的,top保持0,bottom保持第一个元素的高度,right就是left+元素的宽度,同样这里没有处理自身的pading以及子元素的margin。

4.处理滑动冲突

这个自定义ViewGroup是水平滑动,如果里面是ListView,则ListView是垂直滑动,如果我们检测到的滑动方向是水平的话,就让父View拦截用来进行View的滑动切换 :

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;

public class HorizontalView extends ViewGroup {
private int lastInterceptX;
private int lastInterceptY;
private int lastX;
private int lastY;
//... 省略了构造函数的代码

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercept = false;
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 - lastInterceptX;
int deltaY = y - lastInterceptY;
//用户想水平滑动的,所以拦截
if (Math.abs(deltaX) - Math.abs(deltaY) > 0) {
intercept = true;
}
break;
case MotionEvent.ACTION_UP:
break;
}
lastX = x;
lastY = y;
lastInterceptX = x;
lastInterceptY = y;
return intercept;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
//... 省略了onMeasure和onLayout的代码
}

5.弹性滑动到其他页面

这里就会进入onTouchEvent事件,然后我们需要进行滑动切换页面,这里需要用到Scroller,具体请查看Android View体系(二)实现View滑动的六种方法这篇文章,而Scroller滑动的原理请查看 Android View体系(四)从源码解析Scroller这篇文章。

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;

public class HorizontalView extends ViewGroup {
//... 省略构造函数,init方法,onInterceptTouchEvent
int lastInterceptX;
int lastInterceptY;
int lastX;
int lastY;
int currentIndex = 0; //当前子元素
int childWidth = 0;
private Scroller scroller;
@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:
//相对于当前View滑动的距离,正为向左,负为向右
int distance = getScrollX() - currentIndex * childWidth;
//滑动的距离要大于1/2个宽度,否则不会切换到其他页面
if (Math.abs(distance) > childWidth / 2) {
if (distance > 0) {
currentIndex++;
} else {
currentIndex--;
}
}
smoothScrollTo(currentIndex * childWidth, 0);
break;
}
lastX = x;
lastY = y;
return super.onTouchEvent(event);
}
//...省略onMeasure方法
@Override
public void computeScroll() {
super.computeScroll();
if (scroller.computeScrollOffset()) {
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
//弹性滑动到指定位置
public void smoothScrollTo(int destX, int destY) {
scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(),
destY - getScrollY(), 1000);
invalidate();
}
@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);
int width = child.getMeasuredWidth();
//赋值为子元素的宽度
childWidth = width;
child.layout(left, 0, left + width, child.getMeasuredHeight());
left += width;
}
}
}

6.快速滑动到其他页面

我们不只滑动超过一半才切换到上/下一个页面,如果滑动速度很快的话,我们也可以判定为用户想要滑动到其他页面,这样的体验也是好的。 这部分也是在onTouchEvent中的ACTION_UP部分:
这里又需要用到VelocityTracker,它用来测试滑动速度的。使用方法也很简单,首先在构造函数中进行初始化,也就是前面的init方法中增加一条语句:

...
private VelocityTracker tracker;
...
public void init() {
scroller = new Scroller(getContext());
tracker=VelocityTracker.obtain();
}
...

接着改写onTouchEvent部分:


@Override
public boolean onTouchEvent(MotionEvent event) {
...
case MotionEvent.ACTION_UP:
//相对于当前View滑动的距离,正为向左,负为向右
int distance = getScrollX() - currentIndex * childWidth;
//必须滑动的距离要大于1/2个宽度,否则不会切换到其他页面
if (Math.abs(distance) > childWidth / 2) {
if (distance > 0) {
currentIndex++;
} else {
currentIndex--;
}
}
else {
//调用该方法计算1000ms内滑动的平均速度
tracker.computeCurrentVelocity(1000);
float xV = tracker.getXVelocity(); //获取到水平方向上的速度
//如果速度的绝对值大于50的话,就认为是快速滑动,就执行切换页面
if (Math.abs(xV) > 50) {
//大于0切换上一个页面
if (xV > 0) {
currentIndex--;
//小于0切换到下一个页面
} else {
currentIndex++;
}
}
}
currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ?
getChildCount() - 1 : currentIndex;
smoothScrollTo(currentIndex * childWidth, 0);
//重置速度计算器
tracker.clear();
break;
}

7.再次触摸屏幕阻止页面继续滑动

当我们快速向左滑动切换到下一个页面的情况,在手指释放以后,页面会弹性滑动到下一个页面,可能需要一秒才完成滑动,这个时间内,我们再次触摸屏幕,希望能拦截这次滑动,然后再次去操作页面。
要实现在弹性滑动过程中再次触摸拦截,肯定要在onInterceptTouchEvent中的ACTION_DOWN中去判断,如果在ACTION_DOWN的时候,scroller还没有完成,说明上一次的滑动还正在进行中,则直接中断scroller:

...
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {

boolean intercept = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.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;
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
intercept = false;
break;
}
//因为DOWN返回false,所以onTouchEvent中无法获取DOWN事件,所以这里要负责设置lastX,lastY
lastX = x;
lastY = y;
lastInterceptX = x;
lastInterceptY = y;
return intercept;
}
...


8.应用HorizontalView

首先我们在主布局中引用HorizontalView,它作为父容器,里面有两个ListView:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
tools:context="com.example.liuwangshu.mooncustomviewgroup.MainActivity">
<com.example.liuwangshu.mooncustomviewgroup.HorizontalView
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/lv_one"
android:layout_width="match_parent"
android:layout_height="match_parent"></ListView>
<ListView
android:id="@+id/lv_two"
android:layout_width="match_parent"
android:layout_height="match_parent"></ListView>
</com.example.liuwangshu.mooncustomviewgroup.HorizontalView>
</RelativeLayout>

接着在代码中为ListView填加数据:

package com.example.liuwangshu.mooncustomviewgroup;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.ArrayAdapter;
import android.widget.ListView;

public class MainActivity extends AppCompatActivity {
private ListView lv_one;
private ListView lv_two;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
lv_one=(ListView)this.findViewById(R.id.lv_one);
lv_two=(ListView)this.findViewById(R.id.lv_two);
String[] strs1 = {"1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"};
ArrayAdapter<String> adapter1 = new ArrayAdapter<String>
(this,android.R.layout.simple_expandable_list_item_1,strs1);
lv_one.setAdapter(adapter1);

String[] strs2 = {"A","B","C","D","E","F","G","H","I","J","K","L","M","N","O"};
ArrayAdapter<String> adapter2 = new ArrayAdapter<String>(this,
android.R.layout.simple_expandable_list_item_1,strs2);
lv_two.setAdapter(adapter2);
}
}

运行程序查看效果(录制有些问题listview的分割线显示不出来):

最后贴上HorizontalView的源码:

package com.example.liuwangshu.mooncustomviewgroup;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;

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);
init();
}

public void init() {
scroller = new Scroller(getContext());
tracker = VelocityTracker.obtain();
}

//todo intercept的拦截逻辑
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercept = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.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;
//水平方向距离长 MOVE中返回true一次,后续的MOVE和UP都不会收到此请求
if (Math.abs(deltaX) - Math.abs(deltaY) > 0) {
intercept = true;
Log.i("wangshu","intercept = true");
} else {
intercept = false;
Log.i("wangshu","intercept = false");
}
break;
case MotionEvent.ACTION_UP:
intercept = false;
break;
}
//因为DOWN返回false,所以onTouchEvent中无法获取DOWN事件,这里要负责设置lastX,lastY
lastX = x;
lastY = y;
lastInterceptX = x;
lastInterceptY = y;
return intercept;
}

@Override
public boolean onTouchEvent(MotionEvent event) {
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:
//相对于当前View滑动的距离,正为向左,负为向右
int distance = getScrollX() - currentIndex * childWidth;

//必须滑动的距离要大于1/2个宽度,否则不会切换到其他页面
if (Math.abs(distance) > childWidth / 2) {
if (distance > 0) {
currentIndex++;
} else {
currentIndex--;
}
} else {
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);
tracker.clear();
break;
default:
break;
}
lastX = x;
lastY = y;
return true;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
//测量所有子元素
measureChildren(widthMeasureSpec, heightMeasureSpec);
//处理wrap_content的情况
if (getChildCount() == 0) {
setMeasuredDimension(0, 0);
} 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);
} else if (widthMode == MeasureSpec.AT_MOST) {
View childOne = getChildAt(0);
int childWidth = childOne.getMeasuredWidth();
setMeasuredDimension(childWidth * getChildCount(), heightSize);
} else if (heightMode == MeasureSpec.AT_MOST) {
int childHeight = getChildAt(0).getMeasuredHeight();
setMeasuredDimension(widthSize, childHeight);
}
}
@Override
public void computeScroll() {
super.computeScroll();
if (scroller.computeScrollOffset()) {
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
public void smoothScrollTo(int destX, int destY) {
scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(),
destY - getScrollY(), 1000);
invalidate();
}
@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;
}
}
}
}

github源码下载