前言
最近app需要在首页上做一个类似iOS的回弹效果, 我们的首页是一个ExpandableListView
, 如果要做到类似iOS的回弹效果, 最先想到的思路就是使用额外添加的Header和Footer配合改写事件分发机制实现. 众所周知, 这种做法非常的不通用, 下次一个页面用ListView
, 至少需要把代码复制一遍, 如果是ScrollView
, 则要重写一部分逻辑. 如果是LinearLayout
, 还得用写好的ScrollView
把它包起来. 项目里面有一些地方的listview自身已经有了一个header和一个footer了, 这样还会带来更多的逻辑上的麻烦.
我在想, 有没有一种简单的方法, 让我能不需要改写现有的控件的代码, 尤其是不改事件分发这种复杂的逻辑, 直接实现回弹效果. 于是有了今天这篇博文.
这个控件本质上是一个FrameLayout, 只需要套在最外层的view上就可以有回弹效果了, 支持所有的布局.
新思路
前段时间调查一个ViewPager无法滑动的问题时(原文), 偶然发现Support v4包里有一个叫ViewCompat.canScrollHorizontally
的方法, ViewPager遍历子view并调用这个方法来检查可滑动性, 来解决可滑动控件嵌套的问题的.
对应的, 还有一个ViewCompat.canScrollVertically
, 在回弹效果中, 不能简单的在外层套一个布局实现的问题在于, 外部不知道内部的滑动情况, 如果有这个方法, 一切都好办了.
但是需要注意的是, 这个方法实际上是调用View.canScrollVertically
, 该方法是API 16才加入的, Support包并没有让他兼容到16以下, 所以目前并没有4.0以下的兼容方案, 只能做保守性兼容.
我们的思路就是, 自定义一个布局BounceFrameLayout
, 就继承FrameLayout
, 只能有一个子view, 每次有滑动出现, 我们首先询问子view是否能在对应方向上滑动, 如果不行, 那么我们通过View.scrollBy
的方法进行view的偏移, 如果能滑动, 就直接按正常流程分发事件即可.
具体细节
这个思路说起来容易, 做起来还是要考虑很多的. 下面一个一个来探讨.
是否可以滑动
是否可以滑动可以通过ViewCompat.canScrollVertically
判断, 但是这个方法只是对单个view进行判断, 假如BounceFrameLayout
内部先有一个LinearLayout
, LinearLayout
内部才有一个ListView
, 那么这个方法报告的可滑动性永远为false.
再考虑一种情况, 子view里面只有一部分是ListView
, 这个时候, 能不能滑动还要看MotionEvent.ACTION_DOWN事件的坐标落在什么区域上.
所以我们在判断是否可以滑动之前, 首先需要获取手指点击的地方的一个可滑动view, 而且是一层一层往下找, 直到找到, 或者找完了都没有找到为止.
protected View findScrollableTopChildUnder(View view, int x, int y) {
if (view != null) {
if (ScrollHelper.canScrollVertically(view, -1) || ScrollHelper.canScrollVertically(view, 1)) {
return view;
} else if (view instanceof ViewGroup) {
final ViewGroup group = (ViewGroup) view;
final int scrollx = group.getScrollX();
final int scrolly = group.getScrollY();
final int childCount = group.getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
final View child = group.getChildAt(i);
if (scrollx + x >= child.getLeft() && scrollx + x < child.getRight() &&
scrolly + y >= child.getTop() && scrolly + y < child.getBottom()) {
return findScrollableTopChildUnder(child, x + scrollx - child.getLeft(),
y + scrolly - child.getTop());
}
}
}
}
return null;
}
ScrollHelper.canScrollVertically
是我将ViewCompat
中的代码抽出来写的类, 主要是改进了一些方法, 防止系统的bug影响我们的判断, 这个在最后说.
这个方法的功能就是不断的遍历子view, 直到找到第一个可在竖直方向上滑动的view为止, 如果没有返回null. 里面还考虑到了父view的scrollY导致的坐标计算的变化.
我们捕获到这个view之后, 在一次触摸事件的过程里面, 这个view就不需要变了, 之后需要询问是否可以滑动时, 就调用下面的方法.
protected boolean canScroll(View v, int dx, int dy) {
if (v == null) {
return false;
}
return ScrollHelper.canScrollVertically(v, -dy);
}
重写哪个方法
一般来讲, 我们需要重写的最多三个方法, 这里考虑到我们要做的是一个类似旁路监听的逻辑, 而且有些时候滑动需要我们自己处理, 有些时候需要子view处理, 所以我们要重写的是dispatchTouchEvent
.
事件如何连续
考虑下面的场景, 用户首先手指下滑, 我们的检测到listview无法滑动, 将view下移做overscroll效果, 然后用户不松手, 转上滑, 我们将view移回原位, 用户继续上滑, 此时listview处理事件.
众所周知这其实是违反Android的事件分发逻辑的, 因为一旦一个事件被一个view处理, 那么之后所有的事件都会交给它处理, 如果它处理到一半又不想处理了, 那么这个事件是无法转交给其他view的, 而且如果父view一旦决定拦截事件, 那么这个事件也无法再次下穿, 只有等待下一次事件过程.
这也是我们要重写dispatchTouchEvent
的原因, 我们需要一个全程都能收到事件的方法, 针对事件不连续的问题, 我们采取的策略是事件欺骗. 也就是说当我发现我不能继续自己处理事件时, 我将本次event的action改为MotionEvent.ACTION_DOWN
后分发下去, 让子view重新开启事件处理流程, 后面的move事件照常分发, 如果我发现需要我来处理事件了, 我就将本次event的action改为MotionEvent.ACTION_CANCEL
再分发一次.
定义状态
明白了上面的东西, 其实没必要看事件分发流程, 只是一些业务逻辑而已, 但这里简单讲一下BounceFrameLayout
的几个状态, 方便理解.
private static final int BS_IDLE = 1;
private static final int BS_DRAG = 2;
private static final int BS_SETTLE = 3;
private static final int BS_WAIT = 4;
BS_IDLE
代表空闲, 也就是此时没有触摸事件. 如果发生触摸事件, 根据子view的可滑动性, 如果子view能处理这次滑动, 进入BS_WAIT
, 否则进入BS_DRAG
.
BS_DRAG
代表此时处在overscroll状态, 所有的事件都由我们自己处理, 如果此时松手, 进入BS_SETTLE
, 如果用户又把我们拽回原位, 这时我们进入BS_IDLE
BS_SETTLE
代表在overscroll状态下用户松手, 我们处于回弹状态, 如果回弹没有完成就又收到触摸事件, 直接进入BS_DRAG
BS_WAIT
代表我们正在等待子view处理事件, 一旦子view无法处理, 那么将进入BS_DRAG
兼容性方法
ScrollHelper
提供两个方法
interface ScrollHelperImpl {
boolean canScrollVertically(View v, int direction);
void scrollVerticalBy(View v, int dy);
}
其中在api 19上, 针对AbsListView
分别调用它的新方法canScrollList
和scrollListBy
, 在api 16上, scrollVerticalBy
不执行任何操作, canScrollVertically
使用兼容性方法
private boolean canAbsListViewScrollVertically(AbsListView abslistview, int direction) {
final int childCount = ab