一 前言


IOS的UI和用户体验是它的优势, 与IOS相比, Android的UI和用户体验可能要差一些。 虽然Android版本已经到了4.4, 对系统的各个方法进行了大量实质性的优化, 但他的显示效果和交互体验依然不及IOS。 例如IOS上的很多控件都是带弹性的, 也就是拖拽一个控件到了该控件的边界, 但是控件依然可以随着手指的移动而移动一段距离。 这样的话, 给用户的感觉就不那么生硬,能在一定程度上提升用户体验。现在有很多Android App都采用了这中弹性控件,例如最常见的QQ, 多个界面都采用了弹性ScrollView。 我不知道腾讯是如何实现的, 在本文中我会给出自己的实现方式。


我之前的文章 Android上实现仿IOS弹性ListView, 在Android上实现了弹性的ListView, 主要的实现原理是为该ListView增加一个初始高度为0的HeaderView, 如果滚动到第一个条目的位置,用户依然向下拖拽, 那么就增加HeaderView的高度,实现拉伸效果, 当用户松开手指, 就将HeaderView的高度组件递减到0, 实现回弹效果。感兴趣的同学可以看一下实现细节, 文章的链接已经在上面给出。


由于本人水平有限,也由于没有经过严格的测试, 实现的这个控件难免有Bug存在。如果读者发现了Bug, 可以和我联系。 我的QQ:523901846 。 也可在CSDN中私信我, 我会尽快做出回应。


我自己在使用的过程中, 如果发现bug, 也会及时修改。 为了避免破坏文章的结构, 所有的修改都不会直接修改已完成的正文。 而是追加到文章的后面。除了会详细阐释bug的原因和修改bug的原理外,每次修改都会给出新的完整的代码, 读者如果需要直接使用该控件, 可以直接复制最新的源码到自己的项目中。


二 弹性ScrollView的实现


本文讨论弹性ScrollView的实现。弹性ScrollView实现原理和ListView不同, 因为ScrollView只能有一个子View, 不能为他添加额外的HeaderView。 弹性ScrollView的实现原理是移动这个唯一的子View的布局。下面首先给出所有的实现代码, 再对实现中的几个关键点进行说明。 (实现原理比较简单, 代码逻辑也不是很复杂, 代码比较少)

import android.content.Context;import android.graphics.Rect;import android.util.AttributeSet;import android.view.MotionEvent;import android.view.View;import android.view.animation.TranslateAnimation;import android.widget.ScrollView;/** * 有弹性的ScrollView * 实现下拉弹回和上拉弹回 * @author zhangjg * @date Feb 13, 2014 6:11:33 PM */public class ReboundScrollView extends ScrollView {private static final String TAG = "ElasticScrollView";//移动因子, 是一个百分比, 比如手指移动了100px, 那么View就只移动50px//目的是达到一个延迟的效果private static final float MOVE_FACTOR = 0.5f;//松开手指后, 界面回到正常位置需要的动画时间private static final int ANIM_TIME = 300;//ScrollView的子View, 也是ScrollView的唯一一个子Viewprivate View contentView; //手指按下时的Y值, 用于在移动时计算移动距离//如果按下时不能上拉和下拉, 会在手指移动时更新为当前手指的Y值private float startY;//用于记录正常的布局位置private Rect originalRect = new Rect();//手指按下时记录是否可以继续下拉private boolean canPullDown = false;//手指按下时记录是否可以继续上拉private boolean canPullUp = false;//在手指滑动的过程中记录是否移动了布局private boolean isMoved = false;public ReboundScrollView(Context context) {super(context);}public ReboundScrollView(Context context, AttributeSet attrs) {super(context, attrs);}@Overrideprotected void onFinishInflate() {if (getChildCount() > 0) {contentView = getChildAt(0);}}@Overrideprotected void onLayout(boolean changed, int l, int t, int r, int b) {super.onLayout(changed, l, t, r, b);if(contentView == null) return;//ScrollView中的唯一子控件的位置信息, 这个位置信息在整个控件的生命周期中保持不变originalRect.set(contentView.getLeft(), contentView.getTop(), contentView.getRight(), contentView.getBottom());}/** * 在触摸事件中, 处理上拉和下拉的逻辑 */@Overridepublic boolean dispatchTouchEvent(MotionEvent ev) {if (contentView == null) {return super.dispatchTouchEvent(ev);}int action = ev.getAction();switch (action) {case MotionEvent.ACTION_DOWN://判断是否可以上拉和下拉canPullDown = isCanPullDown();canPullUp = isCanPullUp();//记录按下时的Y值startY = ev.getY();break;case MotionEvent.ACTION_UP:if(!isMoved) break;  //如果没有移动布局, 则跳过执行// 开启动画TranslateAnimation anim = new TranslateAnimation(0, 0, contentView.getTop(),originalRect.top);anim.setDuration(ANIM_TIME);contentView.startAnimation(anim);// 设置回到正常的布局位置contentView.layout(originalRect.left, originalRect.top, originalRect.right, originalRect.bottom);//将标志位设回falsecanPullDown = false;canPullUp = false;isMoved = false;break;case MotionEvent.ACTION_MOVE://在移动的过程中, 既没有滚动到可以上拉的程度, 也没有滚动到可以下拉的程度if(!canPullDown && !canPullUp) {startY = ev.getY();canPullDown = isCanPullDown();canPullUp = isCanPullUp();break;}//计算手指移动的距离float nowY = ev.getY();int deltaY = (int) (nowY - startY);//是否应该移动布局boolean shouldMove = (canPullDown && deltaY > 0)    //可以下拉, 并且手指向下移动|| (canPullUp && deltaY< 0)    //可以上拉, 并且手指向上移动|| (canPullUp && canPullDown); //既可以上拉也可以下拉(这种情况出现在ScrollView包裹的控件比ScrollView还小)if(shouldMove){//计算偏移量int offset = (int)(deltaY * MOVE_FACTOR);//随着手指的移动而移动布局contentView.layout(originalRect.left, originalRect.top + offset,originalRect.right, originalRect.bottom + offset);isMoved = true;  //记录移动了布局}break;default:break;}return super.dispatchTouchEvent(ev);}/** * 判断是否滚动到顶部 */private boolean isCanPullDown() {return getScrollY() == 0 || contentView.getHeight() < getHeight() + getScrollY();}/** * 判断是否滚动到底部 */private boolean isCanPullUp() {return  contentView.getHeight() <= getHeight() + getScrollY();}}



三 关键点解析


1 判断适合拉伸的时机


也就是说要判断什么时候开始拉伸, 当然是当ScrollView滑动到顶部或底部的时候。 是否移动到顶部或底部,需要根据三个值进行判断, 这三个值分别是ScrollView的高度, ScrollView中的子控件(在本例中是contentView变量)的高度, 和ScrollView在竖直方向上滚动的距离mScrollY。这三个数值的关系如下图所示


其中位于下方的蓝色控件是ScrollView, 位于上方的是contentView。

所以mScrollY等于0的时候, 就说明ScrollView滚动到了顶部。 如下图所示:

当contentView.height() = scrollView.height + mScrollY时, 就说明滚动到了底部, 如下图所示:




还有一种情况既可以认为滚动到了底部,也可以认为滚动到顶部, 那就是contentView的高度本身就小于ScrollView的高度, 不需要滑动, 这时满足条件contentView.height() < scrollView.height + mScrollY。 如下图所示:



判断滚动到顶部和滚动到底部,分别由isCanPullDown方法和isCanPullUp方法实现。理解了上面图示的内容, 就可以很容易的理解这两个方法的原理。 这两个方法的代码如下所示:

/** * 判断是否滚动到顶部 */private boolean isCanPullDown() {return getScrollY() == 0 || contentView.getHeight() < getHeight() + getScrollY();}/** * 判断是否滚动到底部 */private boolean isCanPullUp() {return  contentView.getHeight() <= getHeight() + getScrollY();}

在用户按下手指时(也就是ACTION_DOWN事件), 调用上面的两个方法判断是否滚动到了顶部或底部, 如果滚动到了顶部或底部, 就说明在移动手指时需要移动contentView的布局。 这时就用标志位canPullDown和canPullUp记住这两个个状态, 并且也记住ACTION_DOWN发生时, 手指触摸点的Y值, 就是startY成员变量。具体实现代码如下:

case MotionEvent.ACTION_DOWN://判断是否可以上拉和下拉canPullDown = isCanPullDown();canPullUp = isCanPullUp();//记录按下时的Y值startY = ev.getY();break;


2 布局的移动


如果上面一步执行ACTION_DOWN之后, 判定不处于上拉或下拉的时机上, 那么在ACTION_MOVE事件处理时, 也要随着ACTION_MOVE事件的多次触发持续更新手指所处的Y值(startY变量)并且及时判断在手指移动的过程中是否使contentView滚动到了顶部或底部, 如果使contentView滚动到了顶部或底部,那么在下一个ACTION_MOVE事件的触发点, 就要移动布局了。 如果上面一步执行ACTION_DOWN之后, 就已经确定要上拉或下拉布局,那么在ACTION_MOVE时, 也就要随着手指的移动而移动布局。
布局移动的实现原理是改变contentView中的mTop和mBottom成员变量的值(这两个变量定义在父类View中),并且对contentView重新布局。mTop代表当前控件的顶端到父控件的顶端的距离,mBottom代表当前控件的底端到父控件的顶端的距离。示意图如下:


同时改变mTop和mBottom的值, 可以使contentView上下移动, 达到随手指拉伸的效果。代码逻辑如下:
case MotionEvent.ACTION_MOVE://在移动的过程中, 既没有滚动到可以上拉的程度, 也没有滚动到可以下拉的程度if(!canPullDown && !canPullUp) {startY = ev.getY();canPullDown = isCanPullDown();canPullUp = isCanPullUp();break;}//计算手指移动的距离float nowY = ev.getY();int deltaY = (int) (nowY - startY);//是否应该移动布局boolean shouldMove = (canPullDown && deltaY > 0)    //可以下拉, 并且手指向下移动|| (canPullUp && deltaY< 0)    //可以上拉, 并且手指向上移动|| (canPullUp && canPullDown); //既可以上拉也可以下拉(这种情况出现在ScrollView包裹的控件比ScrollView还小)if(shouldMove){//计算偏移量int offset = (int)(deltaY * MOVE_FACTOR);//随着手指的移动而移动布局contentView.layout(originalRect.left, originalRect.top + offset,originalRect.right, originalRect.bottom + offset);isMoved = true;  //记录移动了布局}break;


上面代码有三点需要注意: 1MOVE_FACTOR是一个常量, 定义为0.5F。 这是一个因子, 让手指移动的举例乘以这个因子得到布局移动的距离, 例如手指移动了100px, 那么布局就移动100*0.5 = 50px。 这样做主要是达到一种延迟的效果, 增强用户的体验。 2 调用contentView的layout方法对他重新布局, 传入的originalRect.top + offset 和originalRect.bottom + offset就是新的mTop和mBottom,originalRect.top和originalRect.bottom是原始的mTop和mBottom。 3 isMove变量用于记住布局已经移动的状态, 以便于在ACTION_UP事件触发时, 将布局回弹到正常位置


3 布局的回弹


在用户松开手时, 布局要回弹到原始位置。这个回弹很简单, 就是让mTop和mBottom回到原始的值。并加上动画效果。mTop和mBottom的原始值被记录在一个名为originalRect的Rect对象中。 在对ScrollView进行布局操作的时候, 初始化这个originalRect对象, 代码如下:
@Overrideprotected void onLayout(boolean changed, int l, int t, int r, int b) {super.onLayout(changed, l, t, r, b);if(contentView == null) return;//ScrollView中的唯一子控件的位置信息, 这个位置信息在整个控件的生命周期中保持不变originalRect.set(contentView.getLeft(), contentView.getTop(), contentView.getRight(), contentView.getBottom());}

该方法首先调用父类的同名方法对ScrollView进行布局,在 ScrollView进行布局时, 会对他的子View进行递归布局操作, 也就是说,在调用完父类的onLayout方法后, contentView也已经完成了布局操作, 这时它的位置是可以确定的。 所以下面就将它的位置信息保存在originalRect对象中。
回弹效果的实现逻辑如下:
case MotionEvent.ACTION_UP:if(!isMoved) break;  //如果没有移动布局, 则跳过执行// 开启动画TranslateAnimation anim = new TranslateAnimation(0, 0, contentView.getTop(),originalRect.top);anim.setDuration(ANIM_TIME);contentView.startAnimation(anim);// 设置回到正常的布局位置contentView.layout(originalRect.left, originalRect.top, originalRect.right, originalRect.bottom);//将标志位设回falsecanPullDown = false;canPullUp = false;isMoved = false;break;


上面代码中ANIM_TIME是一个常量, 代表动画的执行时间, 被定义为300毫秒。 回弹到原始位置后, 将canPullDown,canPullUp和isMoved标志清空, 以便进行下一次的触摸事件周期。


四 其他实现方式


在网上搜寻解决方案时, 发现以下代码也能实现弹性效果。
public class ReboundScrollView extends ScrollView {private static final int MAX_Y_OVERSCROLL_DISTANCE = 500;     private Context mContext;     private int mMaxYOverscrollDistance;           public ReboundScrollView(Context context){         super(context);         mContext = context;         initBounceScrollView();     }           public ReboundScrollView(Context context, AttributeSet attrs){         super(context, attrs);         mContext = context;         initBounceScrollView();    }           public ReboundScrollView(Context context, AttributeSet attrs, int defStyle){         super(context, attrs, defStyle);         mContext = context;         initBounceScrollView();     }           private void initBounceScrollView(){         final DisplayMetrics metrics = mContext.getResources().getDisplayMetrics();             final float density = metrics.density;                   mMaxYOverscrollDistance = (int) (density * MAX_Y_OVERSCROLL_DISTANCE);     }     @Override    protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, int scrollRangeY, int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent){          //这块是关键性代码        return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, maxOverScrollX, mMaxYOverscrollDistance, isTouchEvent);       }}

实现原理就是通过overScrollBy方法设置ScrollView可以过度滚动。 这种实现虽然简单, 但是有以下几个缺陷:
1 下拉或上拉时, 无法实现延迟效果, 也就是手指移动100px, 那么布局也移动100px, 经过尝试, 这种方式体验并不好, 给人的感觉是控件活动太灵活了。
2 如果下拉或上拉的举例超过MAX_Y_OVERSCROLL_DISTANCE设定的值, 布局就不会再随着手指的移动而移动
3 无法设置自定义的动画, 不能控制动画持续的时间


五 效果演示


下面是演示的截图。
如果在触摸前已经滚动到顶部, 效果图如下:


如果按住屏幕下拉, 会出现以下效果:


滑动到底部再继续上拉时的效果基本相似, 这里就不再给出效果图。
毕竟效果图不能体验到动态效果。 如果想体验动态效果, 可以下载我上传到百度云盘的资源。 该资源是一个简单的Android工程, 里面同样有ReboundScrollView的源码。
资源链接:http://pan.baidu.com/s/1sj97qqD


六 版本更新及Bug修改


修改bug 1 (2014 02 19)


在使用的过程中, 遇到一个bug。 当手指按到这个控件上时, 触发ACTION_UP事件, 随着手指在屏幕上移动, 会不断的触发ACTION_MOVE事件。 但是当移动到该控件范围外时, 依然可以在当前控件的dispatchTouchEvent方法中触发ACTION_MOVE事件。这就会导致当手指移动控件之外时, 依然可以拖动ScrollView中的布局, 这样给人的感觉很不好, 如果屏幕比较大而ScrollView占的范围比较小的话, 会把ScrollView中的布局拖拽到很远的位置, 有时会将这个布局拖拽到不可见的位置。
要修改这个bug, 需要判断手指是否移动到ScrollView外部, 如果移动到ScrollView外部, 就不再dispatchTouchEvent中处理这个事件。 主要是在dispatchTouchEvent开始处加上如下代码:



这段代码的逻辑非常简单。 首先判断是否移动到ScrollView外部。使用的代码如下:
boolean isTouchOutOfScrollView =  ev.getY() >= this.getHeight() || ev.getY() <= 0;

原理图如下:

如果手指移到控件的外面, 不能简单的返回。还要判断布局是否已经在手指移动的过程中而移动了, 如果已经移动了, 要在返回之前将contentView重新回到原位置。 这就是下面两句代码所做的事情:
if(isMoved)//如果当前contentView已经被移动, 首先把布局移到原位置, 然后消费点这个事件boundBack();

boundBack函数就是原来在ACTION_UP事件中所执行的逻辑, 只是简单的将处理 ACTION_UP事件的代码抽出来了。
case MotionEvent.ACTION_UP:boundBack();break;

/** * 将内容布局移动到原位置 * 可以在UP事件中调用, 也可以在其他需要的地方调用, 如手指移动到当前ScrollView外时 */private void boundBack(){if(!isMoved) return;  //如果没有移动布局, 则跳过执行// 开启动画TranslateAnimation anim = new TranslateAnimation(0, 0, contentView.getTop(),originalRect.top);anim.setDuration(ANIM_TIME);contentView.startAnimation(anim);// 设置回到正常的布局位置contentView.layout(originalRect.left, originalRect.top, originalRect.right, originalRect.bottom);//将标志位设回falsecanPullDown = false;canPullUp = false;isMoved = false;}

加上上面的逻辑之后, 就可以修改这个Bug, 进一步增加用户体验。
下面给出修改后的所有的源码。
import android.content.Context;import android.graphics.Rect;import android.util.AttributeSet;import android.view.MotionEvent;import android.view.View;import android.view.animation.TranslateAnimation;import android.widget.ScrollView;/** * 有弹性的ScrollView * 实现下拉弹回和上拉弹回 * @author zhangjg * @date Feb 13, 2014 6:11:33 PM */public class ReboundScrollView extends ScrollView {private static final String TAG = "ReboundScrollView";//移动因子, 是一个百分比, 比如手指移动了100px, 那么View就只移动50px//目的是达到一个延迟的效果private static final float MOVE_FACTOR = 0.5f;//松开手指后, 界面回到正常位置需要的动画时间private static final int ANIM_TIME = 300;//ScrollView的子View, 也是ScrollView的唯一一个子Viewprivate View contentView; //手指按下时的Y值, 用于在移动时计算移动距离//如果按下时不能上拉和下拉, 会在手指移动时更新为当前手指的Y值private float startY;//用于记录正常的布局位置private Rect originalRect = new Rect();//手指按下时记录是否可以继续下拉private boolean canPullDown = false;//手指按下时记录是否可以继续上拉private boolean canPullUp = false;//在手指滑动的过程中记录是否移动了布局private boolean isMoved = false;public ReboundScrollView(Context context) {super(context);}public ReboundScrollView(Context context, AttributeSet attrs) {super(context, attrs);}@Overrideprotected void onFinishInflate() {if (getChildCount() > 0) {contentView = getChildAt(0);}}@Overrideprotected void onLayout(boolean changed, int l, int t, int r, int b) {super.onLayout(changed, l, t, r, b);if(contentView == null) return;//ScrollView中的唯一子控件的位置信息, 这个位置信息在整个控件的生命周期中保持不变originalRect.set(contentView.getLeft(), contentView.getTop(), contentView.getRight(), contentView.getBottom());}/** * 在触摸事件中, 处理上拉和下拉的逻辑 */@Overridepublic boolean dispatchTouchEvent(MotionEvent ev) {if (contentView == null) {return super.dispatchTouchEvent(ev);}//手指是否移动到了当前ScrollView控件之外boolean isTouchOutOfScrollView =  ev.getY() >= this.getHeight() || ev.getY() <= 0;if(isTouchOutOfScrollView){//如果移动到了当前ScrollView控件之外if(isMoved)//如果当前contentView已经被移动, 首先把布局移到原位置, 然后消费点这个事件boundBack();return true;}int action = ev.getAction();switch (action) {case MotionEvent.ACTION_DOWN://判断是否可以上拉和下拉canPullDown = isCanPullDown();canPullUp = isCanPullUp();//记录按下时的Y值startY = ev.getY();break;case MotionEvent.ACTION_UP:boundBack();break;case MotionEvent.ACTION_MOVE://在移动的过程中, 既没有滚动到可以上拉的程度, 也没有滚动到可以下拉的程度if(!canPullDown && !canPullUp) {startY = ev.getY();canPullDown = isCanPullDown();canPullUp = isCanPullUp();break;}//计算手指移动的距离float nowY = ev.getY();int deltaY = (int) (nowY - startY);//是否应该移动布局boolean shouldMove = (canPullDown && deltaY > 0)    //可以下拉, 并且手指向下移动|| (canPullUp && deltaY< 0)    //可以上拉, 并且手指向上移动|| (canPullUp && canPullDown); //既可以上拉也可以下拉(这种情况出现在ScrollView包裹的控件比ScrollView还小)if(shouldMove){//计算偏移量int offset = (int)(deltaY * MOVE_FACTOR);//随着手指的移动而移动布局contentView.layout(originalRect.left, originalRect.top + offset,originalRect.right, originalRect.bottom + offset);isMoved = true;  //记录移动了布局}break;default:break;}return super.dispatchTouchEvent(ev);}/** * 将内容布局移动到原位置 * 可以在UP事件中调用, 也可以在其他需要的地方调用, 如手指移动到当前ScrollView外时 */private void boundBack(){if(!isMoved) return;  //如果没有移动布局, 则跳过执行// 开启动画TranslateAnimation anim = new TranslateAnimation(0, 0, contentView.getTop(),originalRect.top);anim.setDuration(ANIM_TIME);contentView.startAnimation(anim);// 设置回到正常的布局位置contentView.layout(originalRect.left, originalRect.top, originalRect.right, originalRect.bottom);//将标志位设回falsecanPullDown = false;canPullUp = false;isMoved = false;}/** * 判断是否滚动到顶部 */private boolean isCanPullDown() {return getScrollY() == 0 || contentView.getHeight() < getHeight() + getScrollY();}/** * 判断是否滚动到底部 */private boolean isCanPullUp() {return  contentView.getHeight() <= getHeight() + getScrollY();}}

上面链接中的演示工程并没有更新。 读者下载后, 可以将上面的新版本的ReboundScrollView替换工程中的ReboundScrollView。

更多相关文章

  1. 暂时遗忘OSGi,让我们去品味一杯android磨出的移动互联网咖啡吧
  2. Android(安卓)zar高速扫码程序,(比zxing快很多倍),包更小,扫码界面Xml
  3. Android软键盘(四)软件盘弹出布局上移的问题(2)
  4. Android(安卓)UI编程之自定义控件初步(上)——ImageButton
  5. Android中的流式布局
  6. Android(安卓)使用动画效果后的控件位置处理 类似系统通知栏下拉
  7. android 页面布局时定义控件ID时@id/XX和@+id/xx 有什么区别?
  8. Android中高级联动控件 RecyclerView+ViewPager嵌套滑动
  9. Android实现左滑退出Activity(完美封装)

随机推荐

  1. Android中ViewPager+Fragment取消(禁止)
  2. Android Training Note
  3. Android实践 -- Android文件储存系统 应
  4. Android输入子系统之InputDispatcher分发
  5. Android IntentFilter data标签
  6. Android(安卓)图片OutOfMemory异常bitmap
  7. 解决android模拟器上不了网的问题
  8. 读博文学Android
  9. [转]Android AndroidX的迁移
  10. Android的Activity的launchMode与onActiv