您的位置:首页 > 移动开发 > Android开发

Android回弹效果新思考与更加易用的实现

2015-10-03 15:38 537 查看


前言

最近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 = abslistview.getChildCount();
if (childCount == 0) {
return false;
}

final int firstPosition = abslistview.getFirstVisiblePosition();
final int listpaddingbottom = abslistview.getListPaddingBottom();
final int listpaddingtop = abslistview.getListPaddingTop();
if (direction > 0) {
final int lastBottom = abslistview.getChildAt(childCount - 1).getBottom();
final int lastPosition = firstPosition + childCount;
return lastPosition < abslistview.getCount() || lastBottom > abslistview.getHeight() - listpaddingbottom;
} else {
final int firstTop = abslistview.getChildAt(0).getTop();
return firstPosition > 0 || firstTop < listpaddingtop;
}
}


这个是因为
AbsListView
在clipToPadding为false并且有padding的情况下
canScrollVertically
无法正确报告滑动性, 所以使用这个兼容性方法.

使用方法

<com.package.name.BounceFrameLayout>
<这里是你的rootView, 可以是任何view, 最好是match_parent的>
</com.package.name.BounceFrameLayout>


明白我意思就行.

代码

下面是代码, 两个文件放一个包里, 注意需要最新的support v4支持

//BounceFrameLayout.java
package com.shaw.framework.widget.bounce;

import android.content.Context;
import android.graphics.PointF;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.widget.FrameLayout;
import android.widget.Scroller;

/**
* A FrameLayout with bounce back effect. support android 2.2
* <pre>
* usage: wrap your view with BounceFrameLayout
*    <BounceFrameLayout>
*        <Root layout of the view that need bounce effect/>
*    </BounceFrameLayout>
* </pre>
* BounceFrameLayout can only have one child. But this child can be any view.
* @author Shaw
*
*/
public class BounceFrameLayout extends FrameLayout {

/**
* bounce effect only available on ICS and above
*/
private static boolean BOUNCE_EFFECT = true;
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;
private static final int BOUNCE_BACK_DURATION = 250; //ms
private PointF mInitMotionPoint = new PointF();
private PointF mLastMotionPoint = new PointF();
private PointF mOffset = new PointF();
private final int TOUCH_SLOP;
private int mBounceState = BS_IDLE;
private Scroller mScroller;
/**
* whether the view inside received a real down evnt
*/
private boolean receivedRealDown;
/**
* the view we want to test scollability, may be null
*/
private View capturedView;
/**
* should we cancel the fake down event. its just a
* workarund to reduce the incidents rate of unnecessary
* click event
*/
private boolean shouldCancelFakeDown;

public BounceFrameLayout(Context context) {
this(context, null);
}

public BounceFrameLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public BounceFrameLayout(Context context, AttributeSet attrs,
int defStyleAttr) {
super(context, attrs, defStyleAttr);
final ViewConfiguration conf = ViewConfiguration.get(getContext());
TOUCH_SLOP = (conf.getScaledTouchSlop() + 1) / 2;
mScroller = new Scroller(getContext(),
new AccelerateDecelerateInterpolator());
}

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (!BOUNCE_EFFECT) {
return super.dispatchTouchEvent(ev);
}
int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_UP:
if (shouldCancelFakeDown && mBounceState == BS_IDLE) {
// we must cancel the fake down event to prevent
// unnecessary click
ev.setAction(MotionEvent.ACTION_CANCEL);
super.dispatchTouchEvent(ev);
}
if (getScrollY() != 0) {
setBounceState(BS_SETTLE);
mScroller.startScroll(getScrollX(), getScrollY(), 0,
-getScrollY(), BOUNCE_BACK_DURATION);
ViewCompat.postInvalidateOnAnimation(this);
return true;
}
break;
case MotionEvent.ACTION_CANCEL:
break;
case MotionEvent.ACTION_DOWN:
saveInitMotion(ev);
View onlychild = getChildAt(0);
capturedView = findScrollableTopChildUnder(onlychild,
(int) (ev.getX() + getScrollX() - onlychild.getLeft()),
(int) (ev.getY() + getScrollY() - onlychild.getTop()));
if (mBounceState == BS_SETTLE) {
getParent().requestDisallowInterceptTouchEvent(true);
mScroller.forceFinished(true);
setBounceState(BS_DRAG);
return true;
} else {
receivedRealDown = true;
}
break;
case MotionEvent.ACTION_MOVE:
processMotionMove(ev);
int idy = (int) (mOffset.y/2);
final int curState = mBounceState;
switch (curState) {
case BS_DRAG: {
int ybefscroll = getScrollY();
int yaftscroll = getScrollY() - idy;
if (ybefscroll != 0 && ybefscroll * yaftscroll <= 0) {
idy = getScrollY();
}
this.scrollBy(0, -idy);
if (getScrollY() == 0) {
setBounceState(BS_IDLE);
if (!receivedRealDown && canScroll(capturedView, 0, idy)) {
shouldCancelFakeDown = true;
saveInitMotion(ev);
// user may scroll the scrollable view inside , so
// we dispatch a fake down event to let the scrollable
// view pareparing for scrolling. but we don't know
// when should we cancel it exacly
ev.setAction(MotionEvent.ACTION_DOWN);
}
} else {
return true;
}
}
break;
case BS_WAIT: {
// if idy == 0, canScroll cannot decide which direction to scroll, so
// just ignore it when state is BS_WAIT, this is ok because user end
// up with a fing, then state is in BS_WAIT, the next event sequence
// may cause idy == 0
if (idy != 0) {
final boolean canScroll = canScroll(capturedView, 0, idy);
if (!canScroll) {
setBounceState(BS_DRAG);
this.scrollBy(0, -idy);
if (receivedRealDown) {
receivedRealDown = false;
ev.setAction(MotionEvent.ACTION_CANCEL);
}
}
}
}
break;
case BS_IDLE: {
final int offsetYFromInit = (int) (ev.getY() - mInitMotionPoint.y);
final int offetXFromInit = (int) (ev.getX() - mInitMotionPoint.x);
if (Math.abs(offsetYFromInit) > TOUCH_SLOP &&
Math.abs(offsetYFromInit) > Math.abs(offetXFromInit)) {
getParent().requestDisallowInterceptTouchEvent(true);
final boolean canScroll = canScroll(capturedView, 0, idy);
if (canScroll) {
setBounceState(BS_WAIT);
if (shouldCancelFakeDown) {
// scroll the view to let it cancel the down event
// may not work
ScrollHelper.scrollVerticalBy(capturedView, idy);
shouldCancelFakeDown = false;
}
} else {
setBounceState(BS_DRAG);
this.scrollBy(0, -idy);
if (receivedRealDown) {
receivedRealDown = false;
// we will be in BS_DRAG, so we dispatch cancel
// event to scrollable view
ev.setAction(MotionEvent.ACTION_CANCEL);
}
}
}
}
break;
default:
break;
}
}
return super.dispatchTouchEvent(ev);
}

private void saveInitMotion(MotionEvent ev) {
mInitMotionPoint.set(ev.getX(), ev.getY());
mLastMotionPoint.set(ev.getX(), ev.getY());
}

private void processMotionMove(MotionEvent ev) {
mOffset.set(ev.getX() - mLastMotionPoint.x, ev.getY() - mLastMotionPoint.y);
mLastMotionPoint.set(ev.getX(), ev.getY());
}

private void setBounceState(int state) {
mBounceState = state;
}

@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
if (mScroller.isFinished() || getScrollY() == 0) {
mScroller.abortAnimation();
setBounceState(BS_IDLE);
}
ViewCompat.postInvalidateOnAnimation(this);
}
}

/**
* Tests scrollability of view v given a delta of dy.
*
* The canScrollVertically method of AbsListView cannot report scrollability correctly
* when clipToPadding==false, so we call canScrollList when the v is AbsListView and
*  api level is greater or equal than KITKAT
*
* @param v View to test for vertical scrollability
* @param dx Delta scrolled in pixels along the X axis (not used yet)
* @param dy Delta scrolled in pixels along the Y axis
* @return true if v can be scrolled by delta of dy. if v==null, false will return
*/
protected boolean canScroll(View v, int dx, int dy) {
if (v == null) {
return false;
}
return ScrollHelper.canScrollVertically(v, -dy);
}

/**
* Find the topmost scrollable view in the view (include itself) within the parent
* view's coordinate system recursively.
*
* @param x X position to test in the parent's coordinate system
* @param y Y position to test in the parent's coordinate system
* @return The topmost scrollable view under (x, y) or null if none found.
*/
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.java
package com.shaw.framework.widget.bounce;

import android.annotation.TargetApi;
import android.os.Build;
import android.support.v4.view.ScrollingView;
import android.view.View;
import android.widget.AbsListView;
import android.widget.Gallery;
import android.widget.ScrollView;

/**
* Helper for detecting scrollability and scrolling view content
* introduced after API level 19 in a backwards compatible fashion.
* @author Shaw
*
*/
public class ScrollHelper {

interface ScrollHelperImpl {
boolean canScrollVertically(View v, int direction);
void scrollVerticalBy(View v, int dy);
}

private ScrollHelper() {}

/**
* Check if this view can be scrolled vertically in a certain direction.
*
* @param v The View against which to invoke the method.
* @param direction Negative to check scrolling up, positive to check scrolling down.
* @return true if this view can be scrolled in the specified direction, false otherwise.
*/
public static boolean canScrollVertically(View v, int direction) {
return IMPL.canScrollVertically(v, direction);
}

public static void scrollVerticalBy(View v, int dy) {
IMPL.scrollVerticalBy(v, dy);
}

static final ScrollHelperImpl IMPL;
static {
final int version = Build.VERSION.SDK_INT;
if (version >= Build.VERSION_CODES.KITKAT) {
IMPL = new KitKatScrollHelperImpl();
} else if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
IMPL = new ICSViewCompatImpl();
} else {
IMPL = new BaseScrollHelperImpl();
}
}

static class BaseScrollHelperImpl implements ScrollHelperImpl {

@Override
public boolean canScrollVertically(View v, int direction) {
if (v instanceof ScrollingView) {
return canScrollingViewScrollVertically((ScrollingView) v, direction);
} else if (v instanceof AbsListView || v instanceof ScrollView || v instanceof Gallery){
return true;
} else {
return false;
}
}

@Override
public void scrollVerticalBy(View v, int dy) {
if (v instanceof ScrollView) {
v.scrollBy(0, dy);
}
}

private boolean canScrollingViewScrollVertically(ScrollingView view, int direction) {
final int offset = view.computeVerticalScrollOffset();
final int range = view.computeVerticalScrollRange() -
view.computeVerticalScrollExtent();
if (range == 0) return false;
if (direction < 0) {
return offset > 0;
} else {
return offset < range - 1;
}
}
}

@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
static class ICSViewCompatImpl  extends BaseScrollHelperImpl {

@Override
public boolean canScrollVertically(View v, int direction) {
if (v instanceof AbsListView) {
return canAbsListViewScrollVertically((AbsListView) v, direction);
} else {
return v.canScrollVertically(direction);
}
}

private boolean canAbsListViewScrollVertically(AbsListView abslistview, int direction) {
final int childCount = abslistview.getChildCount();
if (childCount == 0) {
return false;
}

final int firstPosition = abslistview.getFirstVisiblePosition();
final int listpaddingbottom = abslistview.getListPaddingBottom();
final int listpaddingtop = abslistview.getListPaddingTop();
if (direction > 0) {
final int lastBottom = abslistview.getChildAt(childCount - 1).getBottom();
final int lastPosition = firstPosition + childCount;
return lastPosition < abslistview.getCount() || lastBottom > abslistview.getHeight() - listpaddingbottom;
} else {
final int firstTop = abslistview.getChildAt(0).getTop();
return firstPosition > 0 || firstTop < listpaddingtop;
}
}
}

@TargetApi(Build.VERSION_CODES.KITKAT)
static class KitKatScrollHelperImpl extends ICSViewCompatImpl {

@Override
public boolean canScrollVertically(View v, int direction) {
if (v instanceof AbsListView) {
return ((AbsListView) v).canScrollList(direction);
} else {
return super.canScrollVertically(v, direction);
}
}

@Override
public void scrollVerticalBy(View v, int dy) {
if (v instanceof AbsListView) {
((AbsListView) v).scrollListBy(dy);
} else {
super.scrollVerticalBy(v, dy);
}
}

}
}
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  android bounce 回弹