前言:当你感到不舒服的时候就是成长的时候。入职阿里时学长跟我说的这句话,一直记得。到死时,人们往往不会因为自己做过什么而后悔,而常常会因为没做什么而后悔。趁你还有激情,加油!
相关文章:
1、《PullScrollView详解(一)——自定义控件属性》
2、《PullScrollView详解(二)——Animation、Layout与下拉回弹》
3、《PullScrollView详解(三)——PullScrollView实现》
4、《PullScrollView详解(四)——完全使用listview实现下拉回弹(方法一)》
5、《PullScrollView详解(五)——完全使用listview实现下拉回弹(方法二)》
6、《PullScrollView详解(六)——延伸拓展(listview中getScrollY()一直等于0、ScrollView中的overScrollBy)》
言归正转,这篇就是终极篇了,一般来讲,终极篇总是最有难度的,已经折磨大家四篇文章,这篇文章也终于千呼万唤始出来了。在listview中实现下拉回弹是比较有难度的,因为在listview中有关scroll各方面的获取都需要自己来做,所以整体难度还是比较大。废话不多说,现在开整吧。
先来看看效果图:(与PullScrollView的效果是一样一样的)
public class PullScrollListView extends ListView { //用户定义的手指可移动的最大高度,在这里,手指移动距离是content移动距离的两倍,是header移动距离的四倍 private int mContentMaxMoveHeight = 0; public PullScrollListView(Context context) { super(context); } public PullScrollListView(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public PullScrollListView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context, attrs); } private void init(Context context, AttributeSet attrs) { if (null != attrs) { TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.PullScrollView); if (ta != null) { mContentMaxMoveHeight = (int) ta.getDimension(R.styleable.PullScrollView_maxMoveHeight, -1); ta.recycle(); } } } }这里可能难理解的部分,就是init()函数里的部分,这是通过declare-styleable来自定义控件属性的知识,在 《PullScrollView详解(一)——自定义控件属性》 中有详细讲述。
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="fill_parent" android:layout_height="fill_parent"> <ImageView android:id="@+id/background_img" android:layout_width="match_parent" android:layout_height="400dp" android:layout_marginTop="-100dp" android:scaleType="fitXY" android:src="@drawable/pic3"/> <com.harvic.PullScrollListViewDemo.PullScrollListView android:id="@+id/pull_list_view" android:layout_width="match_parent" android:layout_height="match_parent" android:divider="@null" android:dividerPadding="0dp" android:dividerHeight="0dp" app:maxMoveHeight="200dp" app:headerTopHeight = "100dp" app:headerVisibleHeight="150dp"/> </FrameLayout>这里可能难理解的部分也就app:XXXX这些自定义属性了,在上面的博客中都有的。
(1)、Item布局(item_layout.xml)
<TextView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@android:id/text1" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_vertical" android:minHeight="40dp" android:background="#ffffff"/>(2)、MainActivity填充数据
public class MainActivity extends Activity { private String[] mStrings = {"Abbaye de Belloc", "Abbaye du Mont des Cats", "Abertam", "Abondance", "Ackawi", "Acorn", "Adelost", "Affidelice au Chablis", "Afuega'l Pitu", "Airag", "Airedale", "Aisy Cendre", "Allgauer Emmentaler", "Abbaye de Belloc", "Abbaye du Mont des Cats", "Abertam", "Abondance", "Ackawi", "Acorn", "Adelost", "Affidelice au Chablis", "Afuega'l Pitu", "Airag", "Airedale", "Aisy Cendre", "Allgauer Emmentaler"}; private LinkedList<String> mListItems; private PullScrollListView mListView; private ArrayAdapter<String> mAdapter; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); mListView = (PullScrollListView) findViewById(R.id.pull_list_view); mListItems = new LinkedList<String>(); mListItems.addAll(Arrays.asList(mStrings)); mAdapter = new ArrayAdapter<String>(this,R.layout.item_layout, mListItems); mListView.setAdapter(mAdapter); } }到现在,我们数据填充已经完成了,但如何将底部的小狗图片显示出来呢?在上篇,我们讲过,不能用padding和marginTop,只能通过给ListView添加透明Header来实现。
(1)、headerview的布局(headerview.xml)
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="300dp" android:clickable="false"> <!--如果要让headview不可点击,在这里设置clickable="false"是没用的,只有通过ListView.addHeaderView(view,null,false);来设置--> </LinearLayout>(2)、在MainActivity中添加headerview
public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); mListView = (PullScrollListView) findViewById(R.id.pull_list_view); mListItems = new LinkedList<String>(); mListItems.addAll(Arrays.asList(mStrings)); mAdapter = new ArrayAdapter<String>(this,R.layout.item_layout, mListItems); LayoutInflater inflater = getLayoutInflater(); View view = inflater.inflate(R.layout.headerview,mListView,false); //设置headerview不可点击 mListView.addHeaderView(view,null,false); mListView.setAdapter(mAdapter); }
//底部图片View private View mTopView; public void setmTopView(View view) { mTopView = view; }
public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); mListView = (PullScrollListView) findViewById(R.id.pull_list_view); ……………… ImageView headerView = (ImageView)findViewById(R.id.background_img); mListView.setmTopView(headerView); }
//底部View private View mContentView = this; //初始点击位置 private Point mTouchPoint = new Point(); //头部图片的初始化位置 private Rect mHeadInitRect = new Rect(); //ScrollView的contentView的初始化位置 private Rect mContentInitRect = new Rect(); //标识当前view是否移动 boolean mIsMoving = false; //是否关闭ListView的滑动. private boolean mEnableTouch = false; public boolean onInterceptTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { //保存原始位置 mTouchPoint.set((int) event.getX(), (int) event.getY()); mHeadInitRect.set(mTopView.getLeft(), mTopView.getTop(), mTopView.getRight(), mTopView.getBottom()); mContentInitRect.set(mContentView.getLeft(), mContentView.getTop(), mContentView.getRight(), mContentView.getBottom()); mIsMoving = false; mEnableTouch = false; } return super.onInterceptTouchEvent(event); }这里与博客三最大的不同在于,没有拦截ACTION_MOVE事件!为什么呢?因为listview本身就是一个控件,内部不会再有其它控件,顶部是不会有其它控件在OnTouchEvent中消费ACTION_MOVE事件的。所以不必拦截。因为在拦截之后,有关的ACTION_MOVE事件就不会再向上传递的,比如我们的HeaderView如果是一个viewPager呢?那它永远也不会滑动了,除非我们做到精确判断来拦截,即确定是在顶部下拉时才进行拦截,其它时候不拦截ACTION_MOVE消息。这样,只有在顶部下拉时才会被我们拦截起来,其它的滑动事件,比如左右滑,是会继续传递给子控件的。
在定义的变量里,其实没什么难度,与博客三不相同的一点是:
private View mContentView = this;mContentView的意义表示,向下拉动的内容部分,这里对应的是整个的listview,所以这里直接用this给它赋值。而不是像博客三一样从外部赋值。
上面也都说了,各个初始化的意义
mTouchPoint.set((int) event.getX(), (int) event.getY()); mHeadInitRect.set(mTopView.getLeft(), mTopView.getTop(), mTopView.getRight(), mTopView.getBottom()); mContentInitRect.set(mContentView.getLeft(), mContentView.getTop(), mContentView.getRight(), mContentView.getBottom());这三个变量mTouchPoint、mHeadInitRect、mContentInitRect分别表示手指点击位置、topView的初始化位置和listview的初始化位置。
mIsMoving = false; mEnableTouch = false;这两个变量现在讲出来,如果只看这篇文章可能有点难理解,有关他们的详细说明已经在 《PullScrollView详解(三)——PullScrollView实现》 详细说明,大家可以先看看这篇博客。这里我简单的说一下他们的意义,mIsMoving是用来标识当前View是不是正在向下移动,用来在ACTION_UP时,回弹View.如果不标识会怎样,那就会在ACTION_UP事件到来时,都会弹一下。比如当前你是在向上滚动,而不是在下拉,在你放开手指时,是不应该回弹的,这里给你回弹一下,你还能受得了,所以这个变量就是用来标识当手指放开时,要不要回弹的。
在讲解下拉之前,我们得先考虑一个问题,即如何判断什么时候该下拉了,在PullScrollView中我们可以通过getScrollY()==0来判断当前View是不是在顶部,如果在顶部而且又是在下拉,那么就可以下拉滚动了。
那在ListView中要怎么判断当前是不是到顶部了呢?大家可以尝试在listview中,getScrollY()始终等于0. 有关《延伸:为什么PullScrollView中getScrollY()有值而ListView中的getScrollY()却一直为零》的原因,可以参考在下一篇的延伸部分。
我这里就直接讲在listview中要怎么获取滚动高度的方法。先看看下面的这幅图:
在这幅图中,listview的item分别是两个高度不等的headview和各个item。
在图片下面被黄色框框起来的部分,代表屏幕。那黄色框以上的部分,就表示已经滚出去的部分,那么它的高度就是我们要计算的scrollHeight;
所以就这个图片当前状态而言
scrollHeight = 所有的header高度 + 已经滚动过去的所有Item的高度 + 当前可见item的滚动部分。
在正式开始之前先给大家讲ListView的两个函数:
//获取当前可见item的索引 public int getFirstVisiblePosition() //获取指定位置的item的视图 public View getChildAt(int index)
getFirstVisiblePosition()
这个函数用于获取当前可见item的索引,索引是从第一个headerview开始算的,第一个headerview索引是0,向下逐个加1.需要注意的是,只要当前item还能看见一丢丢,就算可见哦。比如,我上面的图中,利用getFirstVisiblePosition()得到的值会是3.即第四个item,虽然它向上滚动了一半,但它仍是可见的,所以getFirstVisiblePosition()获取到的是第四个item.
getChildAt(int index)
这个函数需要非常注意,它的index索引是从当前可见的位置开始的。当前第一个可见的Item的索引是0!!!!!不是从第一个headerview开始的!!!!就上面那个图而言,由于当前可见的item是第四个item.这时候调用getChildAt(0)获得是第四个item的View!!!!一定要非常注意。
但正是这个特性正好,我们可以使用getChildAt(0)来获得当前第一个可见item的视图,来得到它滚动的距离。
好了,介绍完计算方法和这两个函数,下面我们就开始看通过代码怎么计算滚动高度吧。
(1)、重写addHeaderView(View v)
因为我们会计算headview的高度,那我们需要得到所有headview的View。我们可以在重写ListView的addHeaderView(View v)方法,在用户利用addHeaderView(View v)添加headview时,我们顺便将它加入到我们的mHeadViews数组中。
private ArrayList<View> mHeadViews = new ArrayList<View>(); @Override public void addHeaderView(View v) { super.addHeaderView(v); if (v != null) { mHeadViews.add(v); } } @Override public void addHeaderView(View v, Object data, boolean isSelectable) { super.addHeaderView(v, data, isSelectable); if (v != null) { mHeadViews.add(v); } }在获得滚动高度时,因为每个headerview的高度一般都不一样,所以我们要分两种情况:第一种,没有完全滚出所有headview。第二种:完全滚出所有Headview
在没有完全滚出所有Headview时,滚动高度就是已经滚动过去的所有headview的高度和加上当前可见的headview滚动过去的高度。代码如下:下面会细讲
public int getScrollHeight(ListView list, ArrayList<? extends View> headviews) { if (list == null || headviews == null){ return -1; } int headCount = list.getHeaderViewsCount(); int firstVisiblePos = list.getFirstVisiblePosition(); int scrollHeight = 0; if (firstVisiblePos < headCount) { //如果还在header内部,说明只需要逐个计算header的高度就好了。 if (headviews.size() == 0) { new Exception("内部含有headerView,请在函数入口处设置headview list"); return -1; } for (int i = 0; i <= firstVisiblePos; i++) { View view = headviews.get(i); if (view != null && i == firstVisiblePos) { //注意,getTop()是负值,因为已经滚到不可见区域去了 scrollHeight += (-view.getTop()); } else if (i != firstVisiblePos) { scrollHeight += view.getHeight(); } } } else { //完全滚出headview ………… } return scrollHeight; }这段代码分为两部分:
int headCount = list.getHeaderViewsCount(); int firstVisiblePos = list.getFirstVisiblePosition(); int scrollHeight = 0; if (firstVisiblePos < headCount) { //没有完全滚出Headview }else{ //完全滚出Headview }在这段代码中,首先根据list.getHeaderViewsCount()获取到当前Headview的数量,其实使用headviews.size()也是一样的。然后利用list.getFirstVisiblePosition()得到当前可见Item的索引。然后判断当前可见的item是不是已经把headview全都给滚过去了。如果firstVisiblePos < headCount,即还没有完全滚过去,就开始if里的代码计算。这里要注意的是headCount是所有Headview的个数,而firstVisiblePos的值是从0开始的,所以要用小于号。当firstVisiblePos 等于 headCount的值的时候,其实已经过了Headview在第一个item项里了。
for (int i = 0; i <= firstVisiblePos; i++) { View view = headviews.get(i); if (view != null && i == firstVisiblePos) { //注意,getTop()是负值,因为已经滚到不可见区域去了 scrollHeight += (-view.getTop()); } else if (i != firstVisiblePos) { scrollHeight += view.getHeight(); } }在上面的代码中,首先将所有已经滚过去的Headview的高度相加:
if (i != firstVisiblePos) { scrollHeight += view.getHeight(); }当到当前可见的Item时,直接利用当前view.getTop()方法获取当前的顶点位置。它的位置在屏幕原点的位置是(0,0),现在在不可见区域,所以它的top值的绝对值就是当前已经滚出去的高度。其实这里说屏幕原点是不准确的,它的位置坐标系以是所在父控件的左上角为坐标系原点的。因为父控件是充满整个屏幕的,当然它的(0,0)原点就在屏幕左上角。
if (view != null && i == firstVisiblePos) { //注意,getTop()是负值,因为已经滚到不可见区域去了 scrollHeight += (-view.getTop()); }到这里就得到在没有滚出headview区域时的scrollHeight值了。
下面就来看看当滚出所有Headview时的代码计算方法:
public int getScrollHeight(ListView list, ArrayList<? extends View> headviews) { ………… if (firstVisiblePos < headCount) { //如果还在header内部,说明只需要逐个计算header的高度就好了。 } else { //先得到所有headview的高度和 if (headviews != null) { for (View view : headviews) { scrollHeight += view.getHeight(); } } //获取单个item的视图 View itemView = list.getChildAt(0); if (itemView != null) { //这里计算的是从headview到当前可见的item之间已经被完全滚过去的item的总高度 scrollHeight += (firstVisiblePos - headCount) * itemView.getHeight(); } //最后加上当前可见的item,已经滚动的部分 scrollHeight += (-itemView.getTop()); } return scrollHeight; }这里分为三部分:
if (headviews != null) { for (View view : headviews) { scrollHeight += view.getHeight(); } }然后得到所有滚过去的item的高度和:
View itemView = list.getChildAt(0); if (itemView != null) { //这里计算的是从headview到当前可见的item之间已经被完全滚过去的item的总高度 scrollHeight += (firstVisiblePos - headCount) * itemView.getHeight(); }最后,再加上当前可见的item滚动过去的高度:
scrollHeight += (-itemView.getTop());所以完整的获取滚动高度的代码如下:
public int getScrollHeight(ListView list, ArrayList<? extends View> headviews) { if (list == null || headviews == null){ return -1; } //!!!注意!!! //这里使用list.getHeaderViewsCount();获取到的headview数量 int headCount = list.getHeaderViewsCount(); int firstVisiblePos = list.getFirstVisiblePosition(); int scrollHeight = 0; if (firstVisiblePos < headCount) { //如果还在header内部,说明只需要逐个计算header的高度就好了。 if (headviews.size() == 0) { new Exception("内部含有headerView,请在函数入口处设置headview list"); return -1; } for (int i = 0; i <= firstVisiblePos; i++) { View view = headviews.get(i); if (view != null && i == firstVisiblePos) { //注意,getTop()是负值,因为已经滚到不可见区域去了 scrollHeight += (-view.getTop()); } else if (i != firstVisiblePos) { scrollHeight += view.getHeight(); } } } else { //这里假设除了headview以外的所有的正常ListItem的高度都是一样的。如果你的不一样,需要改写这一部分的计算方式了 //已经滚出所以headView,只需要将所以headview高度相加,然后再加上其它所有list的高度即可 if (headviews != null) { for (View view : headviews) { scrollHeight += view.getHeight(); } } //获取单个item的视图 View itemView = list.getChildAt(0); //值得非常注意的是firstVisiblePos是从0开始算的,所以headCount正好对应listview的第一个item的索引 if (itemView != null) { //这里计算的是从headview到当前可见的item之间已经被完全滚过去的item的总高度 scrollHeight += (firstVisiblePos - headCount) * itemView.getHeight(); } //最后加上当前可见的item,已经滚动的部分 scrollHeight += (-itemView.getTop()); } return scrollHeight; }
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_MOVE: { int moveHeight = (int) event.getY() - mTouchPoint.y; int scrolledHeight = getScrollHeight(this, mHeadViews);; if (moveHeight > 0 && scrolledHeight == 0) { if (moveHeight > mContentMaxMoveHeight){ moveHeight = mContentMaxMoveHeight; } float headerMoveHeight = moveHeight * 0.5f * SCROLL_RATIO; float contentMoveHeight = moveHeight * SCROLL_RATIO; mHeaderCurTop = (int) (mHeadInitRect.top + headerMoveHeight); mContentTop = (int) (mContentInitRect.top + contentMoveHeight); mTopView.layout(mHeadInitRect.left, mHeaderCurTop, mHeadInitRect.right, (int) (mHeadInitRect.bottom + headerMoveHeight)); mContentView.layout(mContentInitRect.left, mContentTop, mContentInitRect.right, (int) (mContentInitRect.bottom + contentMoveHeight)); mIsMoving = true; mEnableTouch = true; } else { mEnableTouch = false; } } break; ………… return mEnableTouch || super.onTouchEvent(event); }这里代码比较好理解,首先是判断当前是不是在顶部滑动
int moveHeight = (int) event.getY() - mTouchPoint.y; int scrolledHeight = getScrollHeight(this, mHeadViews);; if (moveHeight > 0 && scrolledHeight == 0) { }其中moveHeight是手指的移动距离,只有当手指有移动,而且当前Listview在顶部,即滚动距离为0时,才开始向下拉动。
if (moveHeight > mContentMaxMoveHeight){ moveHeight = mContentMaxMoveHeight; }然后根据手指的移动距离计算出topview和当前listview(也就是这里的contentview)的移动距离。因为我们要让topView(底部的小狗图片)移动的更慢些,所以在它的移动距离上额外乘以0.5.
float headerMoveHeight = moveHeight * 0.5f * SCROLL_RATIO; float contentMoveHeight = moveHeight * SCROLL_RATIO; mHeaderCurTop = (int) (mHeadInitRect.top + headerMoveHeight); mContentTop = (int) (mContentInitRect.top + contentMoveHeight);在计算出移动距离后,就是利用layout()函数将TopView和当前的listview(也就是这里的contentview)移动到指定的位置。
mTopView.layout(mHeadInitRect.left, mHeaderCurTop, mHeadInitRect.right, (int) (mHeadInitRect.bottom + headerMoveHeight)); mContentView.layout(mContentInitRect.left, mContentTop, mContentInitRect.right, (int) (mContentInitRect.bottom + contentMoveHeight));有关mEnableTouch的变量的作用,我不想再讲一遍了,这里的篇幅已经太长了,在 《 PullScrollView详解(三)——PullScrollView实现》 已经讲过了。大家可以去看看。其实建议大家先看这一篇,然后再回来看这篇,这样会理解的更透彻。
case MotionEvent.ACTION_UP: { //反弹 if (mIsMoving) { mTopView.layout(mHeadInitRect.left, mHeadInitRect.top, mHeadInitRect.right, mHeadInitRect.bottom); TranslateAnimation headAnim = new TranslateAnimation(0, 0, mHeaderCurTop - mHeadInitRect.top, 0); headAnim.setDuration(200); mTopView.startAnimation(headAnim); mContentView.layout(mContentInitRect.left, mContentInitRect.top, mContentInitRect.right, mContentInitRect.bottom); TranslateAnimation contentAnim = new TranslateAnimation(0, 0, mContentTop - mContentInitRect.top, 0); contentAnim.setDuration(200); mContentView.startAnimation(contentAnim); mIsMoving = false; } mEnableTouch = false; } break;
这部分,涉及到layout()的应用,及与Animation的结合的方法。看起来这只有几行代码,但真正理解出来还是比较有难度的,我在第二篇博客中详细讲解了,大家可以去看《PullScrollView详解(二)——Animation、Layout与下拉回弹》 大家看了之后,这里就不会有什么问题了。就不再细讲了。
好了,到这里所有代码都讲完了
**源码在文章底部给出**
我们博客中所有用到滚动的地方都用的layout()函数来实现的,但当布局层级比较复杂的时候,layout()会失效。这里向大家一个能够完美实现滚动的类:ViewHelper;它是nineoldandroids.jar包里的类。
这个类能实现有关动画的很多功能,而且出错率很小,我们项目中也一直在用。
这里我们用到下面的函数,意义是将指定View在指定Y轴上移动指定距离。
public static void setTranslationY(View view, float translationY)需要非常注意的是,这里的float translation的取值的意义。
public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_MOVE: { int moveHeight = (int) event.getY() - mTouchPoint.y; int scrolledHeight = getScrollHeight(this, mHeadViews); if (moveHeight > 0 && scrolledHeight == 0) { if (moveHeight > mContentMaxMoveHeight){ moveHeight = mContentMaxMoveHeight; } float headerMoveHeight = moveHeight * 0.5f * SCROLL_RATIO; float contentMoveHeight = moveHeight * SCROLL_RATIO; mHeaderCurTop = (int) (mHeadInitRect.top + headerMoveHeight); mContentTop = (int) (mContentInitRect.top + contentMoveHeight); //viewHelper是导入的jar包,在有些情况下layout()函数实现的并不好使,源自NineOldAndroids开源项目 ViewHelper.setTranslationY(mTopView, headerMoveHeight); ViewHelper.setTranslationY(mContentView, contentMoveHeight); mIsMoving = true; mEnableTouch = true; } else { mEnableTouch = false; } } break; case MotionEvent.ACTION_UP: { //反弹 if (mIsMoving) { ViewHelper.setTranslationY(mTopView, 0); TranslateAnimation headAnim = new TranslateAnimation(0, 0, mHeaderCurTop - mHeadInitRect.top, 0); headAnim.setDuration(200); mTopView.startAnimation(headAnim); ViewHelper.setTranslationY(mContentView, 0); TranslateAnimation contentAnim = new TranslateAnimation(0, 0, mContentTop - mContentInitRect.top, 0); contentAnim.setDuration(200); mContentView.startAnimation(contentAnim); mIsMoving = false; } mEnableTouch = false; } break; case MotionEvent.ACTION_CANCEL: { mEnableTouch = false; } break; } // 禁止控件本身的滑动. //这句厉害,如果mEnableMoving返回TRUE,那么就不会执行super.onTouchEvent(event) //只有返回FALSE的时候,才会执行super.onTouchEvent(event) //禁止控件本身的滑动,就会让它,本来应有的滑动就不会滑动了,比如向上滚动 //!!!!!这点对于listview控件尤为重要。因为在上滑时,如果不禁止控件本身的向上移动, // 就会乱套,因为你本不需要利用setTranslationY()上移的地方,他仍然会上移 return mEnableTouch || super.onTouchEvent(event); }