仿淘宝商品详情页面下拉黏滞成效

仿淘宝商品详情页面下拉黏滞效果

项目中需要用到淘宝商品详情页面的下拉黏滞效果,刚开始的想法比较复杂,是通过投机取巧的方式来大致实现的,但是效果很不好,勉强可以使用,这怎么能行?后来自己尝试着去优化,感觉一个ListView就可以实现,于是就去用listView去实现了一下,主要用到了ListView的smoothScrollToPosition这个方法,做到最后,发现smoothScrollToPosition这个方法的一个bug。

假如当前ListView显示的是position为0,但是position为0的item只是显示了一部分,你调用smoothScrollToPosition方法,此时listView是不会滚动的,因为Android源代码认为 :你当前显示的position 0,你要滚动到position 0,这不是扯淡嘛!所以这个方法失效了,但是从*上面搜索,都是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();
	}
}


4:
注意看这个自定义的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