项目中需要用到淘宝商品详情页面的下拉黏滞效果,刚开始的想法比较复杂,是通过投机取巧的方式来大致实现的,但是效果很不好,勉强可以使用,这怎么能行?后来自己尝试着去优化,感觉一个ListView就可以实现,于是就去用listView去实现了一下,主要用到了ListView的smoothScrollToPosition这个方法,做到最后,发现smoothScrollToPosition这个方法的一个bug。
假如当前ListView显示的是position为0,但是position为0的item只是显示了一部分,你调用smoothScrollToPosition方法,此时listView是不会滚动的,因为Android源代码认为
:你当前显示的position 0,你要滚动到position 0,这不是扯淡嘛!所以这个方法失效了,但是从StackOverFlow上面搜索,都是Android的一个bug!shit~将要实现的效果就这样泡汤了。
后来发现了一种新的思路:
1:自定义一个LinearLayout,自己去处理事件,然后根据事件调用Scroller的相关方法去滚动头部。
2:自定义一个HeaderView。
3:HeaderView下面是一个ListView。
想要实现的效果描述如下:
1:Header显示的时候,向上滑动,Header不断隐藏,Header完全隐藏后,listView才可以滑动。
2:Header显示的时候,向下滑动,Header不断显示,Header完全显示后,在向下滑动,无效果。
3:Header完全隐藏的时候,如果listView的firstVisiblePosition不是0,则滑动事件交给listView处理。
4:Header完全隐藏的时候,如果listView的firstVisiblePosition是0,则滑动事件交给LinearLayout,屏蔽listView的事件处理。
5:Header完全隐藏并且listView的firstVisiblePosition是0,不断下拉,header不断显示增大,如果手指抬起后,header显示的部分小于一定距离的话,header要反弹隐藏;Header显示超过一定距离,播放动画让header完全显示。
基本上面说的这几种情况就是我们自定义的LinearLayout需要处理的几种情况,主要涉及到事件的拦截onInterceptTouchEvent方法,和onTouch方法。
好了,在介绍实现代码之前,我们先介绍几个类:
1:
VelocityTracker--顾名思义即速度跟踪,在android中主要应用于touch event, VelocityTracker通过跟踪一连串事件实时计算出
当前的速度,这样的用法在android系统空间中随处可见,比如Gestures中的Fling, Scrolling等
//获取一个VelocityTracker对象, 用完后记得回收 //回收后代表你不需要使用了,系统将此对象在此分配到其他请求者 static public VelocityTracker obtain(); public void recycle(); //计算当前速度, 其中units是单位表示, 1代表px/毫秒, 1000代表px/秒, .. //maxVelocity此次计算速度你想要的最大值 public void computeCurrentVelocity(int units, float maxVelocity); //经过一次computeCurrentVelocity后你就可以用以下几个方法获取此次计算的值 //id是touch event触摸点的ID, 来为多点触控标识,有这个标识在计算时可以忽略 //其他触点干扰,当然干扰肯定是有的 public float getXVelocity(); public float getYVelocity(); public float getXVelocity(int id); public float getYVelocity(int id);2:
ViewConfiguration--该类中需要定义的是系统的一些常量,方面我们的使用,尽量和系统的保持一致,我们不用自己重复的定义这个常量,况且自己定义的不一定合适。代码如下:
/** * 包含了方法和标准的常量用来设置UI的超时、大小和距离 */ public class ViewConfiguration { // 设定水平滚动条的宽度和垂直滚动条的高度,单位是像素px private static final int SCROLL_BAR_SIZE = 10; //定义滚动条逐渐消失的时间,单位是毫秒 private static final int SCROLL_BAR_FADE_DURATION = 250; // 默认的滚动条多少秒之后消失,单位是毫秒 private static final int SCROLL_BAR_DEFAULT_DELAY = 300; // 定义边缘地方褪色的长度 private static final int FADING_EDGE_LENGTH = 12; //定义子控件按下状态的持续事件 private static final int PRESSED_STATE_DURATION = 125; //定义一个按下状态转变成长按状态的转变时间 private static final int LONG_PRESS_TIMEOUT = 500; //定义用户在按住适当按钮,弹出全局的对话框的持续时间 private static final int GLOBAL_ACTIONS_KEY_TIMEOUT = 500; //定义一个touch事件中是点击事件还是一个滑动事件所需的时间,如果用户在这个时间之内滑动,那么就认为是一个点击事件 private static final int TAP_TIMEOUT = 115; /** * Defines the duration in milliseconds we will wait to see if a touch event * is a jump tap. If the user does not complete the jump tap within this interval, it is * considered to be a tap. */ //定义一个touch事件时候是一个点击事件。如果用户在这个时间内没有完成这个点击,那么就认为是一个点击事件 private static final int JUMP_TAP_TIMEOUT = 500; //定义双击事件的间隔时间 private static final int DOUBLE_TAP_TIMEOUT = 300; //定义一个缩放控制反馈到用户界面的时间 private static final int ZOOM_CONTROLS_TIMEOUT = 3000; /** * Inset in pixels to look for touchable content when the user touches the edge of the screen */ private static final int EDGE_SLOP = 12; /** * Distance a touch can wander before we think the user is scrolling in pixels */ private static final int TOUCH_SLOP = 16; /** * Distance a touch can wander before we think the user is attempting a paged scroll * (in dips) */ private static final int PAGING_TOUCH_SLOP = TOUCH_SLOP * 2; /** * Distance between the first touch and second touch to still be considered a double tap */ private static final int DOUBLE_TAP_SLOP = 100; /** * Distance a touch needs to be outside of a window's bounds for it to * count as outside for purposes of dismissing the window. */ private static final int WINDOW_TOUCH_SLOP = 16; //用来初始化fling的最小速度,单位是每秒多少像素 private static final int MINIMUM_FLING_VELOCITY = 50; //用来初始化fling的最大速度,单位是每秒多少像素 private static final int MAXIMUM_FLING_VELOCITY = 4000; //视图绘图缓存的最大尺寸,以字节表示。在ARGB888格式下,这个尺寸应至少等于屏幕的大小 @Deprecated private static final int MAXIMUM_DRAWING_CACHE_SIZE = 320 * 480 * 4; // HVGA screen, ARGB8888 //flings和scrolls摩擦力度大小的系数 private static float SCROLL_FRICTION = 0.015f; /** * Max distance to over scroll for edge effects */ private static final int OVERSCROLL_DISTANCE = 0; /** * Max distance to over fling for edge effects */ private static final int OVERFLING_DISTANCE = 4; }3:
Scroller--Android里Scroller类是为了实现View平滑滚动的一个Helper类。通常在自定义的View时使用,在View中定义一个私有成员mScroller = new Scroller(context)。设置mScroller滚动的位置时,并不会导致View的滚动,通常是用mScroller记录/计算View滚动的位置,再重写View的computeScroll(),完成实际的滚动。 相关API介绍如下:
mScroller.getCurrX() //获取mScroller当前水平滚动的位置 mScroller.getCurrY() //获取mScroller当前竖直滚动的位置 mScroller.getFinalX() //获取mScroller最终停止的水平位置 mScroller.getFinalY() //获取mScroller最终停止的竖直位置 mScroller.setFinalX(int newX) //设置mScroller最终停留的水平位置,没有动画效果,直接跳到目标位置 mScroller.setFinalY(int newY) //设置mScroller最终停留的竖直位置,没有动画效果,直接跳到目标位置 //滚动,startX, startY为开始滚动的位置,dx,dy为滚动的偏移量, duration为完成滚动的时间 mScroller.startScroll(int startX, int startY, int dx, int dy) //使用默认完成时间250ms mScroller.startScroll(int startX, int startY, int dx, int dy, int duration) mScroller.computeScrollOffset() //返回值为boolean,true说明滚动尚未完成,false说明滚动已经完成。这是一个很重要的方法,通常放在View.computeScroll()中,用来判断是否滚动是否结束。
下面上一段简单的代码,代码中读者可能会发现,其实最后调用的方法全是scrollTo方法。
import android.content.Context; import android.util.AttributeSet; import android.util.Log; import android.view.View; import android.widget.LinearLayout; import android.widget.Scroller; public class CustomView extends LinearLayout { private static final String TAG = "Scroller"; private Scroller mScroller; public CustomView(Context context, AttributeSet attrs) { super(context, attrs); mScroller = new Scroller(context); } //调用此方法滚动到目标位置 public void smoothScrollTo(int fx, int fy) { int dx = fx - mScroller.getFinalX(); int dy = fy - mScroller.getFinalY(); smoothScrollBy(dx, dy); } //调用此方法设置滚动的相对偏移 public void smoothScrollBy(int dx, int dy) { //设置mScroller的滚动偏移量 mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), dx, dy); invalidate();//这里必须调用invalidate()才能保证computeScroll()会被调用,否则不一定会刷新界面,看不到滚动效果 } @Override public void computeScroll() { //先判断mScroller滚动是否完成 if (mScroller.computeScrollOffset()) { //这里调用View的scrollTo()完成实际的滚动 scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); //必须调用该方法,否则不一定能看到滚动效果 postInvalidate(); } super.computeScroll(); } }
注意看这个自定义的View是继承ViewGroup,而不是继承View,我前面一篇文章讲到了这一块,要想移动某一个View,你必须移动该View的父亲,如果一个View不是ViewGroup,你直接调用该View的scrollTo方法是一点效果也没有的,文章的链接地址如下:
http://blog.csdn.net/ly985557461/article/details/44957749
5:
主要介绍完这几个类,下面还有一个重头戏,发一个文章链接,如果读者还不了解事件的分发机制,建议先看看下面这一篇文章:
http://blog.csdn.net/ly985557461/article/details/40865199
上面的基本工作做完后,下面给出关键的代码:
//要扩大高度的listView控件 private ListView listView; //允许滚动的最大的高度 public int mTopViewHeight; //头部是否隐藏的标志位 private boolean isTopHidden = false; //滚动的实现者 Scroller private OverScroller mScroller; //系统的类,用来记录一些常量,避免自己重复的定义 private VelocityTracker mVelocityTracker; //头部隐藏的监听者 private TopViewHiddenListener listener; //滑动的最小值,大于此值时,才认为时滑动 private int mTouchSlop; //滑动停止后,惯性滑动的变量 private int mMaximumVelocity, mMinimumVelocity; //记录上次触控点的Y private float mLastY; //滑动大于mTouchSlop时,认为时dragging private boolean mDragging; //headerView 滚动的距离 private float moveDistance = 0; //滑动到顶部后,下拉距离大于minBoundDistance时,头部动画显示,否则反弹回去 private float minBoundDistance = 0; //滑动的方向 private Direction direction = Direction.NONE; enum Direction {UP, DOWN, NONE} public StickyNavLayoutForBuyCircleInfo(Context context, AttributeSet attrs) { super(context, attrs); setOrientation(LinearLayout.VERTICAL); mScroller = new OverScroller(context, new AccelerateDecelerateInterpolator()); mVelocityTracker = VelocityTracker.obtain(); mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); mMaximumVelocity = ViewConfiguration.get(context).getScaledMaximumFlingVelocity(); mMinimumVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity(); minBoundDistance = DisplayUtil.dip2px(context, 100); }上面就是一些变量的定义,不废话了~
@Override protected void onFinishInflate() { super.onFinishInflate(); //在控件初始化完毕之后在得到listView的控件,必须在此方法中调用 listView = (ListView) findViewById(R.id.goodsList); }
在onFinishInflate方法中初始化listview,尽量在该方法中,否则可能出现listView未初始化的错误。
//此方法动态的设置头部滑动的距离,因为有些设计到头部高度不固定,需要动态的计算,所以需要动态设置高度 public void setTopViewHeight(int height) { mTopViewHeight = height; ViewGroup.LayoutParams params = listView.getLayoutParams(); params.height = getMeasuredHeight(); listView.setLayoutParams(params); }
为什么需要动态的设定listView的高度呢?因为当我们向上滑动的时候,listView会跟着向上滚动,如果listView的高度不变的话,那么滚动之后,listView显示的大小还是原来的大小,就会在下方留白,所以当header的高度计算完毕之后,要给listView的高度加上该高度,这样就算header完全隐藏,listview完全显示,屏幕下方也不会留白。
//事件拦截,一次事件 从Action_Down 到Action_Up结束,此次事件结束后,下一次事件会重新调用onInterceptTouchEvent @Override public boolean onInterceptTouchEvent(MotionEvent ev) { //拦截的情况 //1:头部显示,用户向上滑动,头部不断缩小,需要拦截事件,自己处理 //2:头部不显示,但是listView滚动到了顶部,再向下滑动,头部将要显示,需要拦截事件,自己处理,下滑的过程中,头部不断显示 int action = ev.getAction(); float y = ev.getY(); switch (action) { case MotionEvent.ACTION_DOWN: mLastY = y;//记录手指点击的Y break; case MotionEvent.ACTION_MOVE: float dy = y - mLastY;//滑动时记录滑动的距离 if (Math.abs(dy) > mTouchSlop) {//滑动距离大于mTouchSlop才认为时滑动 if (dy < 0) {//向上滑动 if (getScrollY() < mTopViewHeight) {//topView没有隐藏,则拦截事件,自己处理,让headerView随着手势不断缩小 return true;//返回true,则拦截事件,不向下分发,自己调用onTouch事件处理 } } else {//向下滑动,在头部向下滑动的过程中需要拦截事件 int firstPosition = listView.getFirstVisiblePosition();//得到listView头部的位置 if (firstPosition == 0 && getScrollY() <= mTopViewHeight) {//listView滚动到顶部并且topView将要显示,则拦截事件 return true; } } } break; } return super.onInterceptTouchEvent(ev); }上面是事件拦截,在header显示的时候,我们都需要拦截事件来自己处理~详细请看注释,逻辑并不是很复杂
@Override public boolean onTouchEvent(MotionEvent event) { //跟踪触摸屏事件,用来展示手指抬起后,惯性滑动的效果 mVelocityTracker.addMovement(event); int action = event.getAction(); float y = event.getY(); switch (action) { case MotionEvent.ACTION_DOWN: //手指按下,如果Scroller动画没有停止,停止动画 if (!mScroller.isFinished()) { mScroller.abortAnimation(); } //手指每次按下,清空VelocityTracker的状态 mVelocityTracker.clear(); //为VelocityTracker添加MotionEvent mVelocityTracker.addMovement(event); mLastY = y; return true; case MotionEvent.ACTION_MOVE: //记录移动的距离 float dy = y - mLastY; //判断是否时滑动 if (!mDragging && Math.abs(dy) > mTouchSlop) { mDragging = true; } if (mDragging) {//y方向超过此范围才认为是拖动 if (dy > 0) { //记录方向是向下滑动 direction = Direction.DOWN; } else { //记录方向是向上滑动 direction = Direction.UP; } //跟随手势移动,用来缩放headerView scrollBy(0, (int) -dy); mLastY = y; } break; case MotionEvent.ACTION_CANCEL: //手势取消时,停止动画 mDragging = false; if (!mScroller.isFinished()) { mScroller.abortAnimation(); } break; case MotionEvent.ACTION_UP: mDragging = false; //手指抬起后,计算惯性滑动速率 mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); //得到Y方向的速率 int velocityY = (int) mVelocityTracker.getYVelocity(); //如果大于最小的移动速率,则手指抬起后惯性滚动一段距离 if (Math.abs(velocityY) > mMinimumVelocity) { fling(-velocityY); } mVelocityTracker.clear(); //做回弹动作或者滚动到顶部,顶部隐藏了,需要下拉显示,如果下拉的距离过于小,则回弹 if (isTopHidden && listView.getFirstVisiblePosition() == 0) { //得到headerView滚动的距离 moveDistance = Math.abs(mTopViewHeight - getScrollY()); //如果下拉的距离大于最小下拉距离 if (moveDistance > minBoundDistance) { //滚动到顶部,显示headerView mScroller.startScroll(0, getScrollY(), 0, -getScrollY(), 400); isTopHidden = false; if (listener != null) { listener.onTopViewVisible(); } } else { //向上回弹,动画隐藏headerView mScroller.startScroll(0, getScrollY(), 0, (mTopViewHeight - getScrollY()), 200); isTopHidden = true; } invalidate(); } break; } return super.onTouchEvent(event); }
onTouch事件主要用来控制header的滑动
//重写LinearLayout的scrollTo方法,避免滑动过界 @Override public void scrollTo(int x, int y) { if (y < 0) { y = 0; } if (y > mTopViewHeight) { y = mTopViewHeight; } if (y != getScrollY()) { super.scrollTo(x, y); } if (!isTopHidden && direction == Direction.UP && (getScrollY() == mTopViewHeight)) { isTopHidden = true; if (listener != null) { listener.onTopViewHidden(); } } }
重写LinearLayout的scrollTo方法,避免滑动超过边界。
//重写此方法,不然直接调用Scroller的scrollto或者scrollBy方法没有效果 @Override public void computeScroll() { if (mScroller.computeScrollOffset()) { scrollTo(0, mScroller.getCurrY()); invalidate(); } }
该方法最后别忘了调用invalidate方法来进行刷新。
最后给上例子的地址:http://download.csdn.net/detail/ly985557461/8696003