如何实现列表滑动标签置顶效果(以天天动听、网易云音乐、虾米音乐的歌手页为例)
2015-08-06 23:57
621 查看
很久前就准备写这篇技术博客了,无奈这两个月天天加班,现在总算抽出时间来好好写一写了~~
当然三个app从产品角度来说歌手页面很像,具体谁抄谁的产品设计我们不去管(虽然个人很BS这种抄袭),那是他们的事,咱们来讲的是技术,讲的是实现。来吧,先看下界面,当然最好大家下载对应的app体验一下~
体验后,天天动听和网易云音乐效果更佳,而虾米滑动时很大一个问题是,上滑至标签置顶时,无法继续滑动,必须松手再按下才能继续,下滑时也有同样的问题。
天天动听歌手页(V7.9.0正式版)(左为初始状态,右为标签置顶状态)
网易云音乐歌手页(V2.5.4)(左为初始状态,右为标签置顶状态)
虾米音乐(V4.6.3)(左为初始状态,右为标签置顶状态)
从外表上看三个app好像都实现了需要的效果,很相似,但后面我们会发现其实从技术的角度来分析,三个app其实是大相径庭,特别是虾米音乐。
在第一次看到这种效果的时候,相信你们和我的想法是一样的,首先这是一个ViewPager ,中间是一个tab标签,ViewPager的四个页面均包含一个ListView.
那么问题来了:ViewPager到底是多高,是整个屏幕,还是去掉了头部图片剩下的高度?(如下图)图中为了看清两者区别,故意把viewPager画的宽了一点。
其实按正常的思维来说,一般都会选择图B这种做法,理由很简单:1,ViewPager切换时,头部图片不会受到影响;2,标签置顶后,图片不再动,不会跟随列表滑上去。当然图B这种图片和列表分离的方式更容易做到这一点。
事实上虾米应该就是这样做的,图B这种做法好处是容易理解且容易实现。但它却有一个致命的问题,就是前面提到的,用户在向上滑动时当标签置顶时,无法继续滑动,必须抬起手再按下才可以,为什么会出现这种情况呢?这里就不得不提一下事件的传递流程了~(如果对事件传递流程不熟,建议先看一下这位兄弟写的这篇好文章http://blog.csdn.net/xiaanming/article/details/21696315)
图B中的view事件传递如下图所示
开始滑动时,ViewPager拦截了down事件,并消费了down事件(即ViewPager开始向上移动,此后的move和up事件除非手指抬起后再按下,否则不会再传递给ListView),当ViewPager滑动到顶端最终位置时,不再移动,此时抬起手ViewPager不拦截向下传递给ListView,ListView消费了touch事件,列表就正常滑动了。
但一般的产品怎么能容忍这种体验(标签置顶后抬起手才能再次滑动),于是就有了新的思路,即按图A所示的方法。从一开始滑动到最后只有listview在消费事件,viewpager不拦截,这就不会出现图B方式的差体验。但图A这种图片怎么才能在标签置顶后不让它随列表滑动,才是这个方法的亮点,其实说穿了很简单,这个头部图片是盖在整个viewpager之上的,它只是在listview滑动时,动态改变imageview坐标位置或改变imageView高度即可,当然listview要加一个header,这个header可以是一个空view,高度与头部图片一样高即可,这样就给用户了一个错觉,觉的头部图片就是listview的header.到这里,问题就解决了。
当然真正做这种效果时,你一定会遇到各种问题,比如当往上滑了一点距离后,再切换viewPager页面时,如何做到各个页面listview对齐;比如页面内容不足一屏时,如何还能滑动;再比如下拉放大效果和回弹效果的实现等。当然,如果你已经理解了图A的做法,那后面这些问题都可以一步步解决了。
最后附上一段代码,自己实现的一个OnScrollListener,主要目的是类似于自定义控件,方便使用。(注:最好自己实现一个,因为这个listener可能并不适合你的需求)
当然三个app从产品角度来说歌手页面很像,具体谁抄谁的产品设计我们不去管(虽然个人很BS这种抄袭),那是他们的事,咱们来讲的是技术,讲的是实现。来吧,先看下界面,当然最好大家下载对应的app体验一下~
体验后,天天动听和网易云音乐效果更佳,而虾米滑动时很大一个问题是,上滑至标签置顶时,无法继续滑动,必须松手再按下才能继续,下滑时也有同样的问题。
天天动听歌手页(V7.9.0正式版)(左为初始状态,右为标签置顶状态)
网易云音乐歌手页(V2.5.4)(左为初始状态,右为标签置顶状态)
虾米音乐(V4.6.3)(左为初始状态,右为标签置顶状态)
从外表上看三个app好像都实现了需要的效果,很相似,但后面我们会发现其实从技术的角度来分析,三个app其实是大相径庭,特别是虾米音乐。
在第一次看到这种效果的时候,相信你们和我的想法是一样的,首先这是一个ViewPager ,中间是一个tab标签,ViewPager的四个页面均包含一个ListView.
那么问题来了:ViewPager到底是多高,是整个屏幕,还是去掉了头部图片剩下的高度?(如下图)图中为了看清两者区别,故意把viewPager画的宽了一点。
其实按正常的思维来说,一般都会选择图B这种做法,理由很简单:1,ViewPager切换时,头部图片不会受到影响;2,标签置顶后,图片不再动,不会跟随列表滑上去。当然图B这种图片和列表分离的方式更容易做到这一点。
事实上虾米应该就是这样做的,图B这种做法好处是容易理解且容易实现。但它却有一个致命的问题,就是前面提到的,用户在向上滑动时当标签置顶时,无法继续滑动,必须抬起手再按下才可以,为什么会出现这种情况呢?这里就不得不提一下事件的传递流程了~(如果对事件传递流程不熟,建议先看一下这位兄弟写的这篇好文章http://blog.csdn.net/xiaanming/article/details/21696315)
图B中的view事件传递如下图所示
开始滑动时,ViewPager拦截了down事件,并消费了down事件(即ViewPager开始向上移动,此后的move和up事件除非手指抬起后再按下,否则不会再传递给ListView),当ViewPager滑动到顶端最终位置时,不再移动,此时抬起手ViewPager不拦截向下传递给ListView,ListView消费了touch事件,列表就正常滑动了。
但一般的产品怎么能容忍这种体验(标签置顶后抬起手才能再次滑动),于是就有了新的思路,即按图A所示的方法。从一开始滑动到最后只有listview在消费事件,viewpager不拦截,这就不会出现图B方式的差体验。但图A这种图片怎么才能在标签置顶后不让它随列表滑动,才是这个方法的亮点,其实说穿了很简单,这个头部图片是盖在整个viewpager之上的,它只是在listview滑动时,动态改变imageview坐标位置或改变imageView高度即可,当然listview要加一个header,这个header可以是一个空view,高度与头部图片一样高即可,这样就给用户了一个错觉,觉的头部图片就是listview的header.到这里,问题就解决了。
当然真正做这种效果时,你一定会遇到各种问题,比如当往上滑了一点距离后,再切换viewPager页面时,如何做到各个页面listview对齐;比如页面内容不足一屏时,如何还能滑动;再比如下拉放大效果和回弹效果的实现等。当然,如果你已经理解了图A的做法,那后面这些问题都可以一步步解决了。
最后附上一段代码,自己实现的一个OnScrollListener,主要目的是类似于自定义控件,方便使用。(注:最好自己实现一个,因为这个listener可能并不适合你的需求)
import android.support.v4.view.MotionEventCompat; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.animation.Animation; import android.view.animation.Transformation; import android.widget.*; /** * Created by mie.shen */ public class MyOnScrollListener implements AbsListView.OnScrollListener { private CallBack mC 4000 allBack; private static int mHeadImageViewHeight; //px private static int mHeadImageViewMaxHeight; //px private static int mTabHostHeight; //px private static final int TAB_MAX_ALPHA = 191; private static final int SHADOW_MAX_ALPHA = 255; private static final int ANIM_DELAY_TIME = 300; /** * * @param headerImageHeight int * @param headerImageMaxHeight int * @param tabHeight int * @param callBack CallBack */ public MyOnScrollListener(int headerImageHeight, int headerImageMaxHeight, int tabHeight, CallBack callBack) { mHeadImageViewHeight = headerImageHeight; mHeadImageViewMaxHeight = headerImageMaxHeight; mTabHostHeight = tabHeight; mCallBack = callBack; } @Override public void onScrollStateChanged(AbsListView view, int scrollState) { if (currentListHeaderViewNotNull() && view == mCallBack.getCurrentPageListView()) { view.setOnTouchListener(mOnTouchListener); if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) { updateHeadLayout(view); updateElementAlpha(); mCallBack.updateOtherPageListView(); } } } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { if (view == mCallBack.getCurrentPageListView()) { updateHeadLayout(view); updateElementAlpha(); } if (mCallBack.getCurrentPageListView() == view && isPageScrollEnable(firstVisibleItem, visibleItemCount, totalItemCount)) { mCallBack.requestNextPage(view, firstVisibleItem, visibleItemCount, totalItemCount); } } private boolean isPageScrollEnable(int firstVisibleItem, int visibleItemCount, int totalItemCount) { return (totalItemCount >= visibleItemCount) && ((totalItemCount - firstVisibleItem) <= visibleItemCount); } private void updateHeadLayout(AbsListView view) { ViewGroup.LayoutParams params = mCallBack.getHeaderLayout().getLayoutParams(); if (params.height <= mHeadImageViewHeight) { int scrollY = getScrollY(view); params.height = mHeadImageViewHeight + Math.max(-scrollY, mTabHostHeight - mHeadImageViewHeight); mCallBack.getHeaderLayout().setLayoutParams(params); } } private int getScrollY(AbsListView view) { View c = view.getChildAt(0); if (c == null) { return 0; } int firstVisiblePosition = view.getFirstVisiblePosition(); int top = c.getTop(); int headerHeight = 0; if (firstVisiblePosition >= 1) { headerHeight = mHeadImageViewHeight; } return -top + firstVisiblePosition * c.getHeight() + headerHeight; } private void updateElementAlpha() { mCallBack.getTabBgView().getBackground().setAlpha((int)(TAB_MAX_ALPHA * (1.0 - getAlpha()))); mCallBack.getHeaderShadow().getBackground().setAlpha((int)(SHADOW_MAX_ALPHA * getAlpha())); mCallBack.getIntroductionLayout().getBackground().setAlpha((int)(SHADOW_MAX_ALPHA * getAlpha())); } private float getAlpha() { float alpha = (float)(mCallBack.getHeaderLayout().getHeight() - mTabHostHeight) / (mHeadImageViewHeight - mTabHostHeight); return alpha > 1 ? 1 : alpha; } private View.OnTouchListener mOnTouchListener = new View.OnTouchListener() { private float mStartY = 0; private int mImageNewHeight = mHeadImageViewHeight; @Override public boolean onTouch(View v, MotionEvent event) { if (mCallBack.getHeaderLayout().getHeight() < mHeadImageViewHeight) { return false; } if (event.getPointerCount() > 1) { return true; } ListView view = (ListView)v; switch (event.getAction() & MotionEventCompat.ACTION_MASK) { case MotionEvent.ACTION_DOWN: mStartY = 0; break; case MotionEvent.ACTION_MOVE: if (mStartY == 0 && mCallBack.getHeaderLayout().getHeight() == mHeadImageViewHeight) { mStartY = event.getY(); mImageNewHeight = mHeadImageViewHeight; mCallBack.updateOtherPageListView(); return false; } if (mStartY != 0 && mCallBack.getHeaderLayout().getHeight() >= mHeadImageViewMaxHeight && event.getY() >= mStartY) { mStartY = event.getY(); mImageNewHeight = mHeadImageViewMaxHeight; return true; } if (mStartY != 0 && mCallBack.getHeaderLayout().getHeight() >= mHeadImageViewHeight) { enlargeHeaderLayout(view, event, mImageNewHeight, mStartY); if (mCallBack.getHeaderLayout().getHeight() == mHeadImageViewHeight) { return false; } return true; } break; case MotionEvent.ACTION_UP: if (mStartY != 0) { mStartY = 0; resetHeaderLayout(view.getChildAt(0), mCallBack.getHeaderLayout()); } break; default: break; } return false; } }; private void enlargeHeaderLayout(ListView view, MotionEvent event, int imageNewHeight, float startY) { AbsListView.LayoutParams listParams = (AbsListView.LayoutParams)view.getChildAt(0).getLayoutParams(); ViewGroup.LayoutParams imageParams = mCallBack.getHeaderLayout().getLayoutParams(); int targetHeight = imageNewHeight + (int) (event.getY() - startY); if (targetHeight >= mHeadImageViewMaxHeight) { listParams.height = mHeadImageViewMaxHeight; imageParams.height = mHeadImageViewMaxHeight; } else if (targetHeight >= mHeadImageViewHeight) { listParams.height = targetHeight; imageParams.height = targetHeight; } else { listParams.height = mHeadImageViewHeight; imageParams.height = mHeadImageViewHeight; } view.getChildAt(0).setLayoutParams(listParams); mCallBack.getHeaderLayout().setLayoutParams(imageParams); } private boolean currentListHeaderViewNotNull() { return mCallBack.getCurrentPageListHeaderView() != null; } private void resetHeaderLayout(View listHeader, View headerLayout) { ResetAnimimation animation = new ResetAnimimation( listHeader, mHeadImageViewHeight); animation.setDuration(ANIM_DELAY_TIME); listHeader.startAnimation(animation); ResetAnimimation headAnimation = new ResetAnimimation( headerLayout, mHeadImageViewHeight); headAnimation.setDuration(ANIM_DELAY_TIME); headerLayout.startAnimation(headAnimation); } /** * 回调接口 */ public interface CallBack { /** * 获取含头部图片的布局 * @return layout */ public RelativeLayout getHeaderLayout(); /** * 获取歌手简介的按钮 * @return Button */ public LinearLayout getIntroductionLayout(); /** * 获取头部图片阴影 * @return View */ public View getHeaderShadow(); /** * 获取标签背景View * @return View */ public View getTabBgView(); /** * 获取当前标签页列表 * @return ListView */ public ListView getCurrentPageListView(); /** * 获取当前标签页列表头部 * @return View */ public View getCurrentPageListHeaderView(); /** * 更新其它页的ListView */ public void updateOtherPageListView(); /** * 请求下一页 * @param view AbsListView * @param firstVisibleItem int * @param visibleItemCount int * @param totalItemCount int */ public void requestNextPage(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount); } /** * 下拉回弹的动画 */ public class ResetAnimimation extends Animation { private int mTargetHeight; private int mOriginalHeight; private int mExtraHeight; private View mView; protected ResetAnimimation(View view, int targetHeight) { this.mView = view; this.mTargetHeight = targetHeight; mOriginalHeight = view.getHeight(); mExtraHeight = this.mTargetHeight - mOriginalHeight; } @Override protected void applyTransformation(float interpolatedTime, Transformation t) { int newHeight; newHeight = (int) (mTargetHeight - mExtraHeight * (1 - interpolatedTime)); mView.getLayoutParams().height = newHeight; mView.requestLayout(); } } }