经过了前两章的学习,我们基本已经懂得了一个 View 的工作流程以及事件是如何在他们之中传递的。在我们了解了这些知识之后,我们就能将其应用于我们的自定义 View 开发中啦!
另外,了解上两章的知识,能让我们更好的解决滑动冲突这个问题,毕竟现在的APP为了在尽可能少的空间中展现尽可能多的内容必定会大量的使用滑动,而方向不同、位于不同 View 之间的滑动事件就有可能存在冲突。在我们学习了事件分发机制之后,这些问题就能找到解决的方案啦。
Part.1 自定义 View
1.1.自定义 View 分类
自定义 View 一般分两种,一种是自定义 ViewGroup,一种就是自定义 View。而这两种不同的 View 又有各两种不同的自定义方法,一种是继承现有的 View/ViewGroup,一种是继承原始的 View/ViewGroup,接下来我们将分别提到需要注意的点。
继承 View 重写 Draw 方法
这种方法主要用于实现一些不规则的效果,这种效果不方便通过布局组合的方式来处理,例如画一个圆什么的。这主要是通过覆写 onDraw 的方法来实现。采用这种方法需要自己处理 WRAP_CONTENT 与 Padding 的问题。继承特定的 View
这种方法比较常见,一般用于扩展某种已有的 View 功能,比如 ImageView、TextView ,实际上Support包里面就有很多新的 View 组件就是以这种方法处理的。继承 ViewGroup 派生特殊的 Layout
这种方法主要是实现一些特殊的布局,系统提供的 ListView、RecycleView 等等都是采取了这种方法。采用这种方法稍微比较复杂,需要合适的处理 ViewGroup 的测量、布局两个过程,而且要处理子元素的测量和布局。继承特定的 ViewGroup
这种方法跟第三种方法实际上差不太多,一般当要自定义的 ViewGroup 的需求像某个已有的ViewGroup的拓展就可以采取这种方法,采取这种方法依然要处理测量和布局两个过程。需要注意的,上一种方法能完成的这种方法也能完成,只不过上一种方法更接近底层。
1.2.自定义 View 须知
让 View 支持 wrap_content
直接继承 View 与 ViewGroup 的控件,如果不在 onMeasure 中对 wrap_content 进行处理的话,那么外部在布局中使用 wrap_content 的话,得到的效果会跟父窗体一样大,具体原因已经在上一篇中解释过了。让 View 支持 padding
直接继承 View 空间,如果不在 draw 方法中处理 padding,那么 padding 属性就是无效的。另外,直接继承 ViewGroup 的控件 需要在 onMeasure 和 onLayout 里面考虑 padding 和子元素的 margin 属性的影响,否则将导致 padding 和子元素的 margin失效。尽量不要再 View 中使用 Handler
针对 View ,Android 提供了 post 系列方法,完全可以替代 Handler 的作用。View中如果有线程或者动画,需要及时停止,参考 View#onDetachedFromWindow
当 Activity 被 finish 或当前 View 被 remove的时候,该方法会被回调,因此我们可以在该方法内释放资源以避免造成内存泄漏。View 带有滑动嵌套的时候,要考虑滑动冲突
如果有滑动冲突的时候要做好事件处理,进而解决滑动冲突。
1.3. CircleView
本节我们将借助《android开发艺术鉴赏》中的 CircleView 来讲述如何自定义一个简单的 View。
整个过程十分简单,首先我们先建一个类并让它继承 View 类,注意每个 View 都至少要覆写三个构造方法
public CircleView(Context context) {
super(context);
init();
}
public CircleView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
第一个构造方法对应我们在代码中使用 new 来创建一个 View 调用的构造方法。第二个构造方法会在我们设置了一些 View 的属性时候被调用。第三个构造方法会在我们设置自定义 style 的时候被调用。
由于我们的 CircleView 要画一个圆,因而我们必须初始化 Paint 并在 onDraw 方法被调用时候,利用 canvas 画出来。
private void init(){
mPaint.setColor(mColor);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
final int paddingLeft = getPaddingLeft();
final int paddingRight = getPaddingRight();
final int paddingTop = getPaddingTop();
final int paddingBottom = getPaddingBottom();
int width = getWidth() - paddingLeft - paddingRight;
int height = getHeight() - paddingTop - paddingBottom;
int radius = Math.min(width,height) / 2;
canvas.drawCircle(paddingLeft+width/2,paddingTop+height/2,radius,mPaint);
}
上面的规则很简单,通过获取到的宽度和高度定好圆心,然后利用 canvas 的 drawCircle 方法就能完成绘制。
至此,我们的 CircleView 便绘制完成了,整个过程十分简单,但是这仅仅是十分初级的实现,我们仍需要考虑一些特殊的参数,例如 padding 和 warp_content 属性。原因我们曾经都了解过,接下来就通过代码写出来。
- 处理 wrap_content
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(200,200);
}else if (widthMeasureSpec == MeasureSpec.AT_MOST){
setMeasuredDimension(200,heightSpecSize);
}else{
setMeasuredDimension(widthSpecSize,200);
}
}
对 wrap_content 的处理十分简单,我们只要给定一个固定的宽高就可以,经过上面的代码,若长和宽任有一种被设置成 wrap_content 的时候,最终的结果就是他们会变成200dp。
- padding
实际上上面的 onDraw 方法已经处理好了 padding 的问题,可以看到我们首先获取了四个方向的 padding 值,然后再给宽高赋值的时候是减去了 padding 的值,这样子就能使得 padding 起作用啦。
至于 margin 为何不处理也能生效,这是因为父容器中已经对子容器的 margin 进行了处理,所以作为子元素我们无需在处理。
- 自定义属性
用过别人开发的 View 组件的人都会发现别人的 View 里有自定义属性,自定义属性丰富了我们的 View,也让它变得更加实用。
实用自定义属性的方法也十分简单,首先我们需要在 values 文件夹中声明一个资源 xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CircleView">
<attr name="circle_color" format="color"/>
</declare-styleable>
</resources>
其中 attr 中的 name 对应着自定义属性的名字 format 对应着属性的类型,而 declare-styleable 中的 name 属性则是声明了它内部的属性是对应于哪一个类的。
配置完 XML 文件后,我们仍需要在我们的代码中获取到这些信息,因而我们在构造方法中进行相应的处理
public CircleView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.CircleView);
mColor = a.getColor(R.styleable.CircleView_circle_color,Color.RED);
a.recycle();
init();
}
其中要注意的是 obtainStyledAttributes 中并不是利用 id 来指向 xml 文件的,而是使用 styleable 属性。同时,在获取对应的属性的时候,也是用 styleable 属性来获取对应的属性,自定义属性的名字会自动的转变成 类名_属性名的形式出现。
经过上述几步,一个完整的自定义 View 就完成啦。
Part.2 解决滑动冲突
了解了 View 事件分发机制之后,我们就可以来谈谈滑动冲突这个问题了。前面也提到,为了能够在有限的屏幕大小内尽可能的展示更多的内容,就会大幅度利用滑动功能。
正是由于可以滑动的部件太多了,当用户发生一个滑动事件的时候,多个组件均得到了这个滑动事件,但是谁处理却是不一定的事,当一个 View 抢夺了别的 View 的滑动事件时,就会产生冲突,造成错乱的情况。
滑动冲突多发于以下的场景:
外部滑动方向与内部滑动方向不一致
外部滑动方向与内部方向一致
上面两种情况嵌套
第一种情况实际上经常会发生,当我们在主页面使用 ViewPager 和 Fragment 配合使用,就达成了外部滑动横向的效果,每个 Fragment 都有自己的内容,而一般为了能让用户浏览更多的信息,往往 Fragment 内部还会嵌套一个 ListView ,而 ListView 是向右滑动的,这样就产生了滑动冲突。
但这种情况我们经常在用,但为什么缺没有发生冲突呢?这是因为 ViewPager 内部解决了这个问题,如果我们采用 ScrollView 而不是 ViewPager等,就必须手动解决滑动冲突了。
第二种情况稍微复杂一点,且比较少见,但还是会有可能出现。一个例子就是一个页面有很多内容,因而本身是可以上下滑动的,但是在内容的中下部会存在着一个 ListView ,这个 ListView 负责展示某些具体的东西。比较具体的如美团/大众点评/京东/淘宝等都存在这种需求。这种滑动冲突显然存在着逻辑问题,当手指开始滑动的时候,系统无法知道用户到底是想让那一层滑动,所以当手指滑动的时候就会出现问题。
第三种情况就是上面两种情况的混合,这种情况笔者在微博等地方发现过,其最外层是一个 ViewPager,允许用户左右滑动切换页面,而每一个页面中又包含着一个 ListView,在 ListView 的某个 item 中存在着一种特殊的组件,这种组件可以左右滑动查看信息。
这种情况看似十分复杂,但是实际上只要将它分成内外两层冲突,分解成场景一二两种情况分别处理就可以了。
2.1.第一种场景
接下来我们尝试解决第一种场景:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int x = (int)ev.getX();
int y = (int)ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (Math.abs(deltaX) > Math.abs(deltaY)){
intercepted = true;
}else{
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
这种方法叫做外部拦截法,上面的代码解决了外部是横向滑动,内部竖直滑动的问题,当 move 事件触发的时候,onInterceptTouchEvent 方法会进行判断,如果在 x 坐标的偏移量大于在 y 坐标的偏移量,就拦截该事件,否则交给子元素处理。
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int x = (int)ev.getX();
int y = (int)ev.getY();
ViewGroup parent = (ViewGroup) getParent();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
parent.requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (Math.abs(deltaX) > Math.abs(deltaY)){
parent.requestDisallowInterceptTouchEvent(false);
}else{
parent.requestDisallowInterceptTouchEvent(true);
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return super.dispatchTouchEvent(ev);
}
代码的逻辑看起来差不多,不过此处使用了 requestDisallowInterceptTouchEvent 方法来阻止父容器拦截事件,当决定事件要让父组件处理的时候才返回给父组件处理。当然,我们在父组件中还要进行相应的处理。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
if (action == MotionEvent.ACTION_DOWN){
return false;
}else {
return true;
}
}
我们知道事件分发的过程中,父容器必定会调用一次 onInterceptTouchEvent 方法,原因是就算子元素利用 requestDisallowInterceptTouchEvent 方法进行请求,由于 DOWN 事件的到来,会导致 requestDisallowInterceptTouchEvent 方法失效,从而调用 onInterceptTouchEvent 方法。因而我们便提前取消对 down 事件的拦截,全部交由子组件处理。
两种处理方法都能解决场景一的问题,但个人比较倾向第一种做法。第一种做法的思路首先就是符合事件处理的思路的,父容器先进行判断才交给子元素处理,而第二种方法显然是“儿子管父亲”。其次,从代码的繁琐度上讲,第一种方法也比第二种方法要简单,因为第二种方法涉及到两个组件的修改,万一子元素是其他人写的 ViewGroup 呢?显然是不合适的。
其余两种场景的解决方案此处就不给出了,思路是一样的,无法是在 onInterceptTouchEvent 方法进行相应的处理即可。