Android应用开发中自定义ViewGroup的究极攻略
支持margin,gravity以及水平,垂直排列
最近在学习android的view部分,于是动手实现了一个类似viewpager的可上下或者左右拖动的viewgroup,中间遇到了一些问题(例如touchevent在onintercepttouchevent和ontouchevent之间的传递流程),现在将我的实现过程记录下来。
首先,要实现一个viewgroup,必须至少重写onlayout()方法(当然还有构造方法啦:))。onlayout()主要是用来安排子view在我们这个viewgroup中的摆放位置的。除了onlayout()方法之外往往还需要重写onmeasure()方法,用于测算我们所需要占用的空间。
首先,我们来重写onmeasure()方法:(先只考虑水平方向)
@override protected void onmeasure(int widthmeasurespec, int heightmeasurespec) { // 计算所有child view 要占用的空间 desirewidth = 0; desireheight = 0; int count = getchildcount(); for (int i = 0; i < count; ++i) { view v = getchildat(i); if (v.getvisibility() != view.gone) { measurechild(v, widthmeasurespec, heightmeasurespec); desirewidth += v.getmeasuredwidth(); desireheight = math .max(desireheight, v.getmeasuredheight()); } } // count with padding desirewidth += getpaddingleft() + getpaddingright(); desireheight += getpaddingtop() + getpaddingbottom(); // see if the size is big enough desirewidth = math.max(desirewidth, getsuggestedminimumwidth()); desireheight = math.max(desireheight, getsuggestedminimumheight()); setmeasureddimension(resolvesize(desirewidth, widthmeasurespec), resolvesize(desireheight, heightmeasurespec)); }
我们计算出所有visilibity不是gone的view的宽度的总和作为viewgroup的最大宽度,以及这些view中的最高的一个作为viewgroup的高度。这里需要注意的是要考虑咱们viewgroup自己的padding。(目前先忽略子view的margin)。
onlayout():
@override protected void onlayout(boolean changed, int l, int t, int r, int b) { final int parentleft = getpaddingleft(); final int parentright = r - l - getpaddingright(); final int parenttop = getpaddingtop(); final int parentbottom = b - t - getpaddingbottom(); if (buildconfig.debug) log.d("onlayout", "parentleft: " + parentleft + " parenttop: " + parenttop + " parentright: " + parentright + " parentbottom: " + parentbottom); int left = parentleft; int top = parenttop; int count = getchildcount(); for (int i = 0; i < count; ++i) { view v = getchildat(i); if (v.getvisibility() != view.gone) { final int childwidth = v.getmeasuredwidth(); final int childheight = v.getmeasuredheight(); v.layout(left, top, left + childwidth, top + childheight); left += childwidth; } } }
上面的layout方法写的比较简单,就是简单的计算出每个子view的left值,然后调用view的layout方法即可。
现在我们加上xml布局文件,来看一下效果:
<linearlayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" > <com.example.testslidelistview.slidegroup android:id="@+id/sl" android:layout_width="match_parent" android:layout_height="500dp" android:layout_margintop="50dp" android:background="#ffff00" > <imageview android:id="@+id/iv1" android:layout_width="150dp" android:layout_height="300dp" android:scaletype="fitxy" android:src="@drawable/lead_page_1" /> <imageview android:layout_width="150dp" android:layout_height="300dp" android:scaletype="fitxy" android:src="@drawable/lead_page_2" /> <imageview android:layout_width="150dp" android:layout_height="300dp" android:scaletype="fitxy" android:src="@drawable/lead_page_3" /> </com.example.testslidelistview.slidegroup> </linearlayout>
效果图如下:
从效果图中我们看到,3个小图连在一起(因为现在不支持margin),然后我们也没办法让他们垂直居中(因为现在还不支持gravity)。
现在我们首先为咱们的viewgroup增加一个支持margin和gravity的layoutparams。
@override protected android.view.viewgroup.layoutparams generatedefaultlayoutparams() { return new layoutparams(viewgroup.layoutparams.match_parent, viewgroup.layoutparams.match_parent); } @override public android.view.viewgroup.layoutparams generatelayoutparams( attributeset attrs) { return new layoutparams(getcontext(), attrs); } @override protected android.view.viewgroup.layoutparams generatelayoutparams( android.view.viewgroup.layoutparams p) { return new layoutparams(p); } public static class layoutparams extends marginlayoutparams { public int gravity = -1; public layoutparams(context c, attributeset attrs) { super(c, attrs); typedarray ta = c.obtainstyledattributes(attrs, r.styleable.slidegroup); gravity = ta.getint(r.styleable.slidegroup_layout_gravity, -1); ta.recycle(); } public layoutparams(int width, int height) { this(width, height, -1); } public layoutparams(int width, int height, int gravity) { super(width, height); this.gravity = gravity; } public layoutparams(android.view.viewgroup.layoutparams source) { super(source); } public layoutparams(marginlayoutparams source) { super(source); } }
xml的自定义属性如下:
<?xml version="1.0" encoding="utf-8"?> <resources> <attr name="layout_gravity"> <!-- push object to the top of its container, not changing its size. --> <flag name="top" value="0x30" /> <!-- push object to the bottom of its container, not changing its size. --> <flag name="bottom" value="0x50" /> <!-- push object to the left of its container, not changing its size. --> <flag name="left" value="0x03" /> <!-- push object to the right of its container, not changing its size. --> <flag name="right" value="0x05" /> <!-- place object in the vertical center of its container, not changing its size. --> <flag name="center_vertical" value="0x10" /> <!-- place object in the horizontal center of its container, not changing its size. --> <flag name="center_horizontal" value="0x01" /> </attr> <declare-styleable name="slidegroup"> <attr name="layout_gravity" /> </declare-styleable> </resources>
现在基本的准备工作差不多了,然后需要修改一下onmeasure()和onlayout()。
onmeasure():(上一个版本,我们在计算最大宽度和高度时忽略了margin)
@override protected void onmeasure(int widthmeasurespec, int heightmeasurespec) { // 计算所有child view 要占用的空间 desirewidth = 0; desireheight = 0; int count = getchildcount(); for (int i = 0; i < count; ++i) { view v = getchildat(i); if (v.getvisibility() != view.gone) { layoutparams lp = (layoutparams) v.getlayoutparams(); //将measurechild改为measurechildwithmargin measurechildwithmargins(v, widthmeasurespec, 0, heightmeasurespec, 0); //这里在计算宽度时加上margin desirewidth += v.getmeasuredwidth() + lp.leftmargin + lp.rightmargin; desireheight = math .max(desireheight, v.getmeasuredheight() + lp.topmargin + lp.bottommargin); } } // count with padding desirewidth += getpaddingleft() + getpaddingright(); desireheight += getpaddingtop() + getpaddingbottom(); // see if the size is big enough desirewidth = math.max(desirewidth, getsuggestedminimumwidth()); desireheight = math.max(desireheight, getsuggestedminimumheight()); setmeasureddimension(resolvesize(desirewidth, widthmeasurespec), resolvesize(desireheight, heightmeasurespec)); }
onlayout()(加上margin和gravity)
@override protected void onlayout(boolean changed, int l, int t, int r, int b) { final int parentleft = getpaddingleft(); final int parentright = r - l - getpaddingright(); final int parenttop = getpaddingtop(); final int parentbottom = b - t - getpaddingbottom(); if (buildconfig.debug) log.d("onlayout", "parentleft: " + parentleft + " parenttop: " + parenttop + " parentright: " + parentright + " parentbottom: " + parentbottom); int left = parentleft; int top = parenttop; int count = getchildcount(); for (int i = 0; i < count; ++i) { view v = getchildat(i); if (v.getvisibility() != view.gone) { layoutparams lp = (layoutparams) v.getlayoutparams(); final int childwidth = v.getmeasuredwidth(); final int childheight = v.getmeasuredheight(); final int gravity = lp.gravity; final int horizontalgravity = gravity & gravity.horizontal_gravity_mask; final int verticalgravity = gravity & gravity.vertical_gravity_mask; left += lp.leftmargin; top = parenttop + lp.topmargin; if (gravity != -1) { switch (verticalgravity) { case gravity.top: break; case gravity.center_vertical: top = parenttop + (parentbottom - parenttop - childheight) / 2 + lp.topmargin - lp.bottommargin; break; case gravity.bottom: top = parentbottom - childheight - lp.bottommargin; break; } } if (buildconfig.debug) { log.d("onlayout", "child[width: " + childwidth + ", height: " + childheight + "]"); log.d("onlayout", "child[left: " + left + ", top: " + top + ", right: " + (left + childwidth) + ", bottom: " + (top + childheight)); } v.layout(left, top, left + childwidth, top + childheight); left += childwidth + lp.rightmargin; } } }
现在修改一下xml布局文件,加上例如xmlns:ly="http://schemas.android.com/apk/res-auto",的xml命名空间,来引用我们设置的layout_gravity属性。(这里的“res-auto”其实还可以使用res/com/example/testslidelistview来代替,但是前一种方法相对简单,尤其是当你将某个ui组件作为library来使用的时候)
现在的效果图如下:有了margin,有了gravity。
其实在这个基础上,我们可以很容易的添加一个方向属性,使得它可以通过设置一个xml属性或者一个java api调用来实现垂直排列。
下面我们增加一个用于表示方向的枚举类型:
public static enum orientation { horizontal(0), vertical(1); private int value; private orientation(int i) { value = i; } public int value() { return value; } public static orientation valueof(int i) { switch (i) { case 0: return horizontal; case 1: return vertical; default: throw new runtimeexception("[0->horizontal, 1->vertical]"); } } }
然后我们需要改变onmeasure(),来正确的根据方向计算需要的最大宽度和高度。
@override protected void onmeasure(int widthmeasurespec, int heightmeasurespec) { // 计算所有child view 要占用的空间 desirewidth = 0; desireheight = 0; int count = getchildcount(); for (int i = 0; i < count; ++i) { view v = getchildat(i); if (v.getvisibility() != view.gone) { layoutparams lp = (layoutparams) v.getlayoutparams(); measurechildwithmargins(v, widthmeasurespec, 0, heightmeasurespec, 0); //只是在这里增加了垂直或者水平方向的判断 if (orientation == orientation.horizontal) { desirewidth += v.getmeasuredwidth() + lp.leftmargin + lp.rightmargin; desireheight = math.max(desireheight, v.getmeasuredheight() + lp.topmargin + lp.bottommargin); } else { desirewidth = math.max(desirewidth, v.getmeasuredwidth() + lp.leftmargin + lp.rightmargin); desireheight += v.getmeasuredheight() + lp.topmargin + lp.bottommargin; } } } // count with padding desirewidth += getpaddingleft() + getpaddingright(); desireheight += getpaddingtop() + getpaddingbottom(); // see if the size is big enough desirewidth = math.max(desirewidth, getsuggestedminimumwidth()); desireheight = math.max(desireheight, getsuggestedminimumheight()); setmeasureddimension(resolvesize(desirewidth, widthmeasurespec), resolvesize(desireheight, heightmeasurespec)); }
onlayout():
@override protected void onlayout(boolean changed, int l, int t, int r, int b) { final int parentleft = getpaddingleft(); final int parentright = r - l - getpaddingright(); final int parenttop = getpaddingtop(); final int parentbottom = b - t - getpaddingbottom(); if (buildconfig.debug) log.d("onlayout", "parentleft: " + parentleft + " parenttop: " + parenttop + " parentright: " + parentright + " parentbottom: " + parentbottom); int left = parentleft; int top = parenttop; int count = getchildcount(); for (int i = 0; i < count; ++i) { view v = getchildat(i); if (v.getvisibility() != view.gone) { layoutparams lp = (layoutparams) v.getlayoutparams(); final int childwidth = v.getmeasuredwidth(); final int childheight = v.getmeasuredheight(); final int gravity = lp.gravity; final int horizontalgravity = gravity & gravity.horizontal_gravity_mask; final int verticalgravity = gravity & gravity.vertical_gravity_mask; if (orientation == orientation.horizontal) { // layout horizontally, and only consider vertical gravity left += lp.leftmargin; top = parenttop + lp.topmargin; if (gravity != -1) { switch (verticalgravity) { case gravity.top: break; case gravity.center_vertical: top = parenttop + (parentbottom - parenttop - childheight) / 2 + lp.topmargin - lp.bottommargin; break; case gravity.bottom: top = parentbottom - childheight - lp.bottommargin; break; } } if (buildconfig.debug) { log.d("onlayout", "child[width: " + childwidth + ", height: " + childheight + "]"); log.d("onlayout", "child[left: " + left + ", top: " + top + ", right: " + (left + childwidth) + ", bottom: " + (top + childheight)); } v.layout(left, top, left + childwidth, top + childheight); left += childwidth + lp.rightmargin; } else { // layout vertical, and only consider horizontal gravity left = parentleft; top += lp.topmargin; switch (horizontalgravity) { case gravity.left: break; case gravity.center_horizontal: left = parentleft + (parentright - parentleft - childwidth) / 2 + lp.leftmargin - lp.rightmargin; break; case gravity.right: left = parentright - childwidth - lp.rightmargin; break; } v.layout(left, top, left + childwidth, top + childheight); top += childheight + lp.bottommargin; } } } }
现在我们可以增加一个xml属性:
<attr name="orientation"> <enum name="horizontal" value="0" /> <enum name="vertical" value="1" /> </attr>
现在就可以在布局文件中加入ly:orientation="vertical"来实现垂直排列了(ly是自定义的xml命名空间)
布局文件如下:
<linearlayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" > <com.example.testslidelistview.slidegroup xmlns:gs="http://schemas.android.com/apk/res-auto" android:id="@+id/sl" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_margintop="50dp" android:background="#ffff00" > <imageview android:id="@+id/iv1" android:layout_width="300dp" android:layout_height="200dp" android:layout_marginbottom="20dp" gs:layout_gravity="left" android:scaletype="fitxy" android:src="@drawable/lead_page_1" /> <imageview android:layout_width="300dp" android:layout_height="200dp" android:layout_marginbottom="20dp" gs:layout_gravity="center_horizontal" android:scaletype="fitxy" android:src="@drawable/lead_page_2" /> <imageview android:layout_width="300dp" android:layout_height="200dp" android:layout_marginbottom="20dp" gs:layout_gravity="right" android:scaletype="fitxy" android:src="@drawable/lead_page_3" /> </com.example.testslidelistview.slidegroup> </linearlayout>
现在效果图如下:
重写ontouchevent()以支持滑动:
要使view滑动,我们可以通过调用scrollto()和scrollby()来实现,这里需要注意的是:要使页面向左移动,需要增加mscrollx(就是向scrollby传递一个正数),同样的,要使页面向上移动,需要增加mscrolly。
@override public boolean ontouchevent(motionevent event) { final int action = event.getaction(); if (buildconfig.debug) log.d("ontouchevent", "action: " + action); switch (action) { case motionevent.action_down: x = event.getx(); y = event.gety(); break; case motionevent.action_move: float mx = event.getx(); float my = event.gety(); //此处的moveby是根据水平或是垂直排放的方向, //来选择是水平移动还是垂直移动 moveby((int) (x - mx), (int) (y - my)); x = mx; y = my; break; } return true; } //此处的moveby是根据水平或是垂直排放的方向, //来选择是水平移动还是垂直移动 public void moveby(int deltax, int deltay) { if (buildconfig.debug) log.d("moveby", "deltax: " + deltax + " deltay: " + deltay); if (orientation == orientation.horizontal) { if (math.abs(deltax) >= math.abs(deltay)) scrollby(deltax, 0); } else { if (math.abs(deltay) >= math.abs(deltax)) scrollby(0, deltay); } }
好,现在我们再运行这段代码,就会发现view已经可以跟随手指移动了,但现在的问题是当手指离开屏幕后,view就立即停止滑动了,这样的体验就相当不友好,那么我们希望手指离开后,view能够以一定的阻尼满满地减速滑动。
借助scroller,并且处理action_up事件
scroller是一个用于计算位置的工具类,它负责计算下一个位置的坐标(根据时长,最小以最大移动距离,以及阻尼算法(可以使用自定义的interpolator))。
scroller有两种模式:scroll和fling。
scroll用于已知目标位置的情况(例如:viewpager中向左滑动,就是要展示右边的一页,那么我们就可以准确计算出滑动的目标位置,此时就可以使用scroller.startscroll()方法)
fling用于不能准确得知目标位置的情况(例如:listview,每一次的滑动,我们事先都不知道滑动距离,而是根据手指抬起是的速度来判断是滑远一点还是近一点,这时就可以使用scroller.fling()方法)
现在我们改一下上面的ontouchevent()方法,增加对action_up事件的处理,以及初速度的计算。
@override public boolean ontouchevent(motionevent event) { final int action = event.getaction(); if (buildconfig.debug) log.d("ontouchevent", "action: " + action); //将事件加入到velocitytracker中,用于计算手指抬起时的初速度 if (velocitytracker == null) { velocitytracker = velocitytracker.obtain(); } velocitytracker.addmovement(event); switch (action) { case motionevent.action_down: x = event.getx(); y = event.gety(); if (!mscroller.isfinished()) mscroller.abortanimation(); break; case motionevent.action_move: float mx = event.getx(); float my = event.gety(); moveby((int) (x - mx), (int) (y - my)); x = mx; y = my; break; case motionevent.action_up: //maxflingvelocity是通过viewconfiguration来获取的初速度的上限 //这个值可能会因为屏幕的不同而不同 velocitytracker.computecurrentvelocity(1000, maxflingvelocity); float velocityx = velocitytracker.getxvelocity(); float velocityy = velocitytracker.getyvelocity(); //用来处理实际的移动 completemove(-velocityx, -velocityy); if (velocitytracker != null) { velocitytracker.recycle(); velocitytracker = null; } break; return true; }
我们在computemove()中调用scroller的fling()方法,顺便考虑一下滑动方向问题
private void completemove(float velocityx, float velocityy) { if (orientation == orientation.horizontal) { int mscrollx = getscrollx(); int maxx = desirewidth - getwidth();// - math.abs(mscrollx); if (math.abs(velocityx) >= minflingvelocity && maxx > 0) { mscroller.fling(mscrollx, 0, (int) velocityx, 0, 0, maxx, 0, 0); invalidate(); } } else { int mscrolly = getscrolly(); int maxy = desireheight - getheight();// - math.abs(mscrolly); if (math.abs(velocityy) >= minflingvelocity && maxy > 0) { mscroller.fling(0, mscrolly, 0, (int) velocityy, 0, 0, 0, maxy); invalidate(); } } }
好了,现在我们再运行一遍,问题又来了,手指抬起后,页面立刻又停了下来,并没有实现慢慢减速的滑动效果。
其实原因就是上面所说的,scroller只是帮助我们计算位置的,并不处理view的滑动。我们要想实现连续的滑动效果,那就要在view绘制完成后,再通过scroller获得新位置,然后再重绘,如此反复,直至停止。
重写computescroll(),实现view的连续绘制
@override public void computescroll() { if (mscroller.computescrolloffset()) { if (orientation == orientation.horizontal) { scrollto(mscroller.getcurrx(), 0); postinvalidate(); } else { scrollto(0, mscroller.getcurry()); postinvalidate(); } } }
computescroll()是在viewgroup的drawchild()中调用的,上面的代码中,我们通过调用computescrolloffset()来判断滑动是否已停止,如果没有,那么我们可以通过getcurrx()和getcurry()来获得新位置,然后通过调用scrollto()来实现滑动,这里需要注意的是postinvalidate()的调用,它会将重绘的这个event加入ui线程的消息队列,等scrollto()执行完成后,就会处理这个事件,然后再次调用viewgroup的draw()-->drawchild()-->computescroll()-->scrollto()如此就实现了连续绘制的效果。
现在我们再重新运行一下app,终于可以持续滑动了:),不过,当我们缓慢地拖动view,慢慢抬起手指,我们会发现通过这样的方式,可以使得所有的子view滑到屏幕之外,(所有的子view都消失了:()。
问题主要是出在completemove()中,我们只是判断了初始速度是否大于最小阈值,如果小于这个最小阈值的话就什么都不做,缺少了边界的判断,因此修改computemove()如下:
private void completemove(float velocityx, float velocityy) { if (orientation == orientation.horizontal) { int mscrollx = getscrollx(); int maxx = desirewidth - getwidth(); if (mscrollx > maxx) { // 超出了右边界,弹回 mscroller.startscroll(mscrollx, 0, maxx - mscrollx, 0); invalidate(); } else if (mscrollx < 0) { // 超出了左边界,弹回 mscroller.startscroll(mscrollx, 0, -mscrollx, 0); invalidate(); } else if (math.abs(velocityx) >= minflingvelocity && maxx > 0) { mscroller.fling(mscrollx, 0, (int) velocityx, 0, 0, maxx, 0, 0); invalidate(); } } else { int mscrolly = getscrolly(); int maxy = desireheight - getheight(); if (mscrolly > maxy) { // 超出了下边界,弹回 mscroller.startscroll(0, mscrolly, 0, maxy - mscrolly); invalidate(); } else if (mscrolly < 0) { // 超出了上边界,弹回 mscroller.startscroll(0, mscrolly, 0, -mscrolly); invalidate(); } else if (math.abs(velocityy) >= minflingvelocity && maxy > 0) { mscroller.fling(0, mscrolly, 0, (int) velocityy, 0, 0, 0, maxy); invalidate(); } } }
ok,现在当我们滑出边界,松手后,会自动弹回。
处理action_pointer_up事件,解决多指交替滑动跳动的问题
现在viewgroup可以灵活的滑动了,但是当我们使用多个指头交替滑动时,就会产生跳动的现象。原因是这样的:
我们实现ontouchevent()的时候,是通过event.getx(),以及event.gety()来获取触摸坐标的,实际上是获取的手指索引为0的位置坐标,当我们放上第二个手指后,这第二个手指的索引为1,此时我们同时滑动这两个手指,会发现没有问题,因为我们追踪的是手指索引为0的手指位置。但是当我们抬起第一个手指后,问题就出现了, 因为这个时候原本索引为1的第二个手指的索引变为了0,所以我们追踪的轨迹就出现了错误。
简单来说,跳动就是因为追踪的手指的改变,而这两个手指之间原本存在间隙,而这个间隙的距离就是我们跳动的距离。
其实问题产生的根本原因就是手指的索引会变化,因此我们需要记录被追踪手指的id,然后当有手指离开屏幕时,判断离开的手指是否是我们正在追踪的手指:
如果不是,忽略;
如果是,则选择一个新的手指作为被追踪手指,并且调整位置记录。
还有一点就是,要处理action_pointer_up事件,就需要给action与上一个掩码:event.getaction()&motionevent.action_mask 或者使用 event.getactionmasked()方法。
更改后的ontouchevent()的实现如下:
@override public boolean ontouchevent(motionevent event) { final int action = event.getactionmasked(); if (velocitytracker == null) { velocitytracker = velocitytracker.obtain(); } velocitytracker.addmovement(event); switch (action) { case motionevent.action_down: // 获取索引为0的手指id mpointerid = event.getpointerid(0); x = event.getx(); y = event.gety(); if (!mscroller.isfinished()) mscroller.abortanimation(); break; case motionevent.action_move: // 获取当前手指id所对应的索引,虽然在action_down的时候,我们默认选取索引为0 // 的手指,但当有第二个手指触摸,并且先前有效的手指up之后,我们会调整有效手指 // 屏幕上可能有多个手指,我们需要保证使用的是同一个手指的移动轨迹, // 因此此处不能使用event.getactionindex()来获得索引 final int pointerindex = event.findpointerindex(mpointerid); float mx = event.getx(pointerindex); float my = event.gety(pointerindex); moveby((int) (x - mx), (int) (y - my)); x = mx; y = my; break; case motionevent.action_up: velocitytracker.computecurrentvelocity(1000, maxflingvelocity); float velocityx = velocitytracker.getxvelocity(mpointerid); float velocityy = velocitytracker.getyvelocity(mpointerid); completemove(-velocityx, -velocityy); if (velocitytracker != null) { velocitytracker.recycle(); velocitytracker = null; } break; case motionevent.action_pointer_up: // 获取离开屏幕的手指的索引 int pointerindexleave = event.getactionindex(); int pointeridleave = event.getpointerid(pointerindexleave); if (mpointerid == pointeridleave) { // 离开屏幕的正是目前的有效手指,此处需要重新调整,并且需要重置velocitytracker int reindex = pointerindexleave == 0 ? 1 : 0; mpointerid = event.getpointerid(reindex); // 调整触摸位置,防止出现跳动 x = event.getx(reindex); y = event.gety(reindex); if (velocitytracker != null) velocitytracker.clear(); } break; } return true; }
好了,现在我们用多个手指交替滑动就很正常了。
我们解决了多个手指交替滑动带来的页面的跳动问题。但同时也还遗留了两个问题。
我们自定义的这个viewgroup本身还不支持onclick, onlongclick事件。
当我们给子view设置click事件后,我们的viewgroup居然不能滑动了。
相对来讲,第一个问题稍稍容易处理一点,这里我们先说一下第二个问题。
onintercepttouchevent()的作用以及何时会被调用
onintercepttouchevent()是用来给viewgroup自己一个拦截事件的机会,当viewgroup意识到某个touch事件应该由自己处理,那么就可以通过此方法来阻止事件被分发到子view中。
为什么onintercepttouchevent()方法只接收到来action_down事件??需要处理action_move,action_up等等事件吗??
按照google官方文档的说明:
如果onintercepttouchevent方法返回true,那么它将不会收到后续事件,事件将会直接传递给目标的ontouchevent方法(其实会先传给目标的ontouch方法);
如果onintercepttouchevent方法返回false,那么所有的后续事件都会先传给onintercepttouchevent,然后再传给目标的ontouchevent方法。
但是,为什么我们在onintercepttouchevent方法中返回false之后,却收不到后续的事件呢??通过实验以及*上面的一些问答得知,当我们在onintercepttouchevent()方法中返回false,且子view的ontouchevent返回true的情况下,onintercepttouchevent方法才会收到后续的事件。
虽然这个结果与官方文档的说法有点不同,但实验说明是正确的。仔细想想这样的逻辑也确实非常合理:因为onintercepttouchevent方法是用来拦截触摸事件,防止被子view捕获。那么现在子view在ontouchevent中返回false,明确声明自己不会处理这个触摸事件,那么这个时候还需要拦截吗?当然就不需要了,因此onintercepttouchevent不需要拦截这个事件,那也就没有必要将后续事件再传给它了。
还有就是onintercepttouchevent()被调用的前提是它的子view没有调用requestdisallowintercepttouchevent(true)方法(这个方法用于阻止viewgroup拦截事件)。
viewgroup的onintercepttouchevent方法,ontouchevent方法以及view的ontouchevent方法之间的事件传递流程
画了一个简单的图,如下:
其中:intercept指的是onintercepttouchevent()方法,touch指的是ontouchevent()方法。
好了,现在我们可以解决博客开头列出的第二个问题了,之所以为子view设置click之后,我们的viewgroup方法无法滑动,是因为,子view在接受到action_down事件后返回true,并且viewgroup的onintercepttouchevent()方法的默认实现是返回false(就是完全不拦截),所以后续的action_move,action_up事件都传递给了子view,因此我们的viewgroup自然就无法滑动了。
解决方法就是重写onintercepttouchevent方法:
/** * onintercepttouchevent()用来询问是否要拦截处理。 ontouchevent()是用来进行处理。 * * 例如:parentlayout----childlayout----childview 事件的分发流程: * parentlayout::onintercepttouchevent()---false?---> * childlayout::onintercepttouchevent()---false?---> * childview::ontouchevent()---false?---> * childlayout::ontouchevent()---false?---> parentlayout::ontouchevent() * * * * 如果onintercepttouchevent()返回false,且分发的子view的ontouchevent()中返回true, * 那么onintercepttouchevent()将收到所有的后续事件。 * * 如果onintercepttouchevent()返回true,原本的target将收到action_cancel,该事件 * 将会发送给我们自己的ontouchevent()。 */ @override public boolean onintercepttouchevent(motionevent ev) { final int action = ev.getactionmasked(); if (buildconfig.debug) log.d("onintercepttouchevent", "action: " + action); if (action == motionevent.action_down && ev.getedgeflags() != 0) { // 该事件可能不是我们的 return false; } boolean isintercept = false; switch (action) { case motionevent.action_down: // 如果动画还未结束,则将此事件交给ontouchevet()处理, // 否则,先分发给子view isintercept = !mscroller.isfinished(); // 如果此时不拦截action_down时间,应该记录下触摸地址及手指id,当我们决定拦截action_move的event时, // 将会需要这些初始信息(因为我们的ontouchevent将可能接收不到action_down事件) mpointerid = ev.getpointerid(0); // if (!isintercept) { downx = x = ev.getx(); downy = y = ev.gety(); // } break; case motionevent.action_move: int pointerindex = ev.findpointerindex(mpointerid); if (buildconfig.debug) log.d("onintercepttouchevent", "pointerindex: " + pointerindex + ", pointerid: " + mpointerid); float mx = ev.getx(pointerindex); float my = ev.gety(pointerindex); if (buildconfig.debug) log.d("onintercepttouchevent", "action_move [touchslop: " + mtouchslop + ", deltax: " + (x - mx) + ", deltay: " + (y - my) + "]"); // 根据方向进行拦截,(其实这样,如果我们的方向是水平的,里面有一个scrollview,那么我们是支持嵌套的) if (orientation == orientation.horizontal) { if (math.abs(x - mx) >= mtouchslop) { // we get a move event for ourself isintercept = true; } } else { if (math.abs(y - my) >= mtouchslop) { isintercept = true; } } //如果不拦截的话,我们不会更新位置,这样可以通过累积小的移动距离来判断是否达到可以认为是move的阈值。 //这里当产生拦截的话,会更新位置(这样相当于损失了mtouchslop的移动距离,如果不更新,可能会有一点点跳的感觉) if (isintercept) { x = mx; y = my; } break; case motionevent.action_cancel: case motionevent.action_up: // 这是触摸的最后一个事件,无论如何都不会拦截 if (velocitytracker != null) { velocitytracker.recycle(); velocitytracker = null; } break; case motionevent.action_pointer_up: solvepointerup(ev); break; } return isintercept; } private void solvepointerup(motionevent event) { // 获取离开屏幕的手指的索引 int pointerindexleave = event.getactionindex(); int pointeridleave = event.getpointerid(pointerindexleave); if (mpointerid == pointeridleave) { // 离开屏幕的正是目前的有效手指,此处需要重新调整,并且需要重置velocitytracker int reindex = pointerindexleave == 0 ? 1 : 0; mpointerid = event.getpointerid(reindex); // 调整触摸位置,防止出现跳动 x = event.getx(reindex); y = event.gety(reindex); if (velocitytracker != null) velocitytracker.clear(); } }
现在再运行app,问题应该解决了。
ontouchevent收到action_down,是否一定能收到action_move,action_up...??? 收到了action_move,能否说明它已经收到过action_down???
其实根据上面所说的onintercepttouchevent方法与ontouchevent方法之间事件传递的过程,我们知道这两个问题的答案都是否定的。
对于第一个,收到action_down事件后,action_move事件可能会被拦截,那么它将只能够再收到一个action_cancel事件。
对于第二个,是基于上面的这一个情况,action_down传递给了子view,而onintercepttouchevent拦截了action_move事件,所以我们的ontouchevent方法将会收到action_move,而不会收到action_down。(这也是为什么我在onintercepttouchevent方法的action_down中记录下位置信息的原因)
还有一个问题就是,如果我们单纯的在ontouchevent中: 对于action_down返回true,在接收到action_move事件后返回false,那么这个时候事件会重新寻找能处理它的view吗?不会,所有的后续事件依然会发给这个ontouchevent方法。
让viewgroup支持click事件
这里我们是在ontouchevent中对于action_up多做了一些处理:
判断从按下时的位置到现在的移动距离是否小于可被识别为move的阈值。
根据action_down和action_up之间的时间差,判断是click,还是long click(这里当没有设置long click的话,我们也可将其认为是click)
case motionevent.action_up: //先判断是否是点击事件 final int pi = event.findpointerindex(mpointerid); if((isclickable() || islongclickable()) && ((event.getx(pi) - downx) < mtouchslop || (event.gety(pi) - downy) < mtouchslop)) { //这里我们得到了一个点击事件 if(isfocusable() && isfocusableintouchmode() && !isfocused()) requestfocus(); if(event.geteventtime() - event.getdowntime() >= viewconfiguration.getlongpresstimeout() && islongclickable()) { //是一个长按事件 performlongclick(); } else { performclick(); } } else { velocitytracker.computecurrentvelocity(1000, maxflingvelocity); float velocityx = velocitytracker.getxvelocity(mpointerid); float velocityy = velocitytracker.getyvelocity(mpointerid); completemove(-velocityx, -velocityy); if (velocitytracker != null) { velocitytracker.recycle(); velocitytracker = null; } } break;