介绍
在新一期的需求中,产品要求我们做出和美团某个页面类似的功能,即一个页面包含在scrollView中,上面一个部分放置一些常用的广告banner、宫格tab等,下面放置一个RecyclerView用于展示具体的产品列表。
要想实现上述功能,不可避免地要用到ScrollView嵌套RecyclerView。为什么要用RecyclerView?因为下面的产品列表项非常多,有60条,如果一次性加载到内存里肯定不现实,所以下方一定要用到可复用的RecyclerView。
而RecyclerView和ScrollView怎么嵌套使用呢?在以前,我总是习惯性地把RecyclerView设置为wrap_content,并且把RecyclerView的setNestedScrollingEnbaled设置为false,这样从来没有遇到过滑动冲突的问题,并且我看到团队里的很多大咖也是这么用。
然而,我们的产品有个需求是在滑动RecyclerView的过程中,RecyclerView顶部的悬停导航栏是要跟着滑动的,于是我就想到在RecyclerView的addOnScrollingListener里设置监听,并且利用linearLayouManager的findLastVisibleItemPosition、findFirstVisibleItemPosition、getChildCount这几个方法来判断当前滑动到RecyclerView的什么位置了,然后去对顶部悬停的导航栏进行联动。问题出现了。无论我怎么滑动,firstVisiblePosition永远为0,lastVisiblePosition永远为item总数-1,getChildCount永远为item总数。WTF,这是什么情况?后来查看资料发现,把RecyclerView高度设置为wrap_content居然是把所有的item都一次性加载进来,并没有用到复用和回收!!!!
对于一直强调代码性能的我,这绝对是我无法忍受的。那么,在为RecyclerView设置一个高度,并把setNestedScrollingEnabled(是否允许嵌套滑动)方法设置为true之后,滑动冲突问题出现了。那么,怎么解决呢?
只需要对ScrollView进行简单的修改,就可以实现。实现原理是,在进到页面中默认把滑动事件交给ScrollView,同时屏蔽RecyclerView的滑动事件;在RecyclerView滑动到顶部的时候,把滑动事件交给RecyclerView。
那么,怎么判断RecyclerView是否滑动到了屏幕顶部了呢?实现方法也是非常简单!通过recyclerview的getTop方法得到recyclerview距离顶部的距离,然后通过scrollView的getScrollY方法得到ScrollView滑动的距离。只需要比较这两个值就可以了。这里,我设置了两个接口回调,在Activity里设置ReyclerView的setNestedScrollingEnabled方法。
- package com.example.zhshan.hoveringScrollView;
- import android.content.Context;
- import android.graphics.Canvas;
- import android.util.AttributeSet;
- import android.view.View;
- import android.widget.LinearLayout;
- import android.widget.ScrollView;
- /**
- * @author Zhenhua on 2017/5/24 11:15.
- * @email zhshan@ctrip.com
- */
- public class MyscrollView extends ScrollView{
- public MyscrollView(Context context) {
- super(context);
- }
- public MyscrollView(Context context, AttributeSet attrs) {
- super(context, attrs);
- }
- public MyscrollView(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- }
- View view;
- @Override
- protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
- super.onLayout(changed, left, top, right, bottom);
- if(changed){
- LinearLayout v = (LinearLayout) getChildAt(0);
- if(v != null){
- for(int i=0;i<v.getChildCount();i++){
- if(v.getChildAt(i).getTag() != null && ((String)v.getChildAt(i).getTag()).equals("aaa")){
- view = v.getChildAt(i);
- break;
- }
- }
- }
- }
- }
- @Override
- protected void dispatchDraw(Canvas canvas) {
- super.dispatchDraw(canvas);
- if(getScrollY() >= view.getTop()){
- fixHead();
- //此处代码为实现悬停导航栏,如果只是单纯想解决滑动冲突,可删掉
- <span style="white-space:pre;"> </span> canvas.save();
- canvas.translate(0,getScrollY());
- canvas.clipRect(0,0,view.getWidth(),view.getHeight());
- view.draw(canvas);
- canvas.restore();
- }else {
- resetHead();
- }
- }
- private OnFixHeadListener listener;
- private void fixHead() {
- if (listener != null) {
- listener.onFix();
- }
- }
- private void resetHead() {
- if (listener != null) {
- listener.onReset();
- }
- }
- public void setFixHeadListener(OnFixHeadListener listener) {
- this.listener = listener;
- }
- public interface OnFixHeadListener {
- void onFix();
- void onReset();
- }
- }
通过这样的方法能够非常完美的实现 解决RecyclerView和ScrollView滑动冲突,与RecyclerView悬停导航栏功能。
下面附上demo(点击下载),并贴上两张demo截图。
PS: demo中也完美地实现了ReyclerView指定item置顶功能。
~~~~~~~华丽丽的分割线:问题进一步升级!!5.0以下手机无法解决滑动冲突问题~~~~~~~~~~
1、问题的背景:在RecyclerView需要把滑动事件处理权力交给ScrollView时,调用RecyclerView的setnestedscrollenable(false)方法;在ScrollView需要把滑动事件处理权力交给RecyclerView时,调用RecyclerView的setnestedscrollenable(true)方法。本以为这样就可以完美解决滑动冲突问题,然而测试却在我提测之后第一天就提了bug,我心灰意冷地打开后发现,5.0以下的手机仍然存在滑动冲突问题。去查了下recyclerview的setnestedscrollenable方法的文档才发现,这个方法只有在5.0以上手机有用。擦。。
2、问题如何得到解决?
这个时候就只能按照最原始的方法,根据Android的事件传递原理去一步步解决滑动冲突了。其实,我在2014年的时候就解决过一个滑动冲突问题,并做了一些总结。解决滑动冲突问题其实很简单!!
解决滑动冲突的原理:
(1)刚开始把滑动事件给ScrollView,在ScrollView滑动到某一个位置时,再把滑动事件给RecyclerView。
(2)在RecyclerView滑动到某一个位置时,再把滑动事件交给ScrollView。
总结一下,解决滑动冲突需要知道(1)什么时候把滑动事件传给内部View;(2)什么时候内部View再把滑动事件传给外部View。
先来看一下,如何把事件传给内部View?只需要在ScrollView滑动到某个位置后,使用接口回调,并且让RecyclerView在接口回调里,getParent().requestdisallowintercept()。具体代码如下:
- protected void dispatchDraw(Canvas canvas) {
- super.dispatchDraw(canvas);
- if (getScrollY() >= fixView.getTop()) {
- fix();
- } else {
- dismiss();
- }
- }
在fix回调里,requestdisallowintercept(true)来让ScrollView不拦截。
那么,RecyclerView如何把事件传给外部?
需要给RecyclerView设置ontouchlistener,然后在RecyclerView滑动到第一个item,并且正在向下滑动时,requestdisallowintercept(false)来让ScrollView拦截。
~~~~~~~~~~~~~华丽丽的分割线:滑动冲突完全解析~~~~~~~~~~~~~~~~~~~~~~
- @Override
- public boolean onInterceptTouchEvent(MotionEvent ev) {
- switch(ev.getAction()) {
- case MotionEvent.ACTION_DOWN:
- //必须返回false,否则子控件永远无法拿到焦点
- return false;
- case MotionEvent.ACTION_MOVE:
- if(事件交给子控件的条件) {
- return false;
- } else {
- return super.onInterceptTouchEvent(ev);
- }
- case MotionEvent.ACTION_UP:
- //必须返回false,否则子控件永远无法拿到焦点
- return false;
- default:
- return super.onInterceptTouchEvent(ev);
- }
- }
- @Override
- public boolean dispatchTouchEvent(MotionEvent ev) {
- switch(ev.getAction()) {
- case MotionEvent.ACTION_DOWN:
- //父容器禁止拦截
- getParent().requestDisallowInterceptTouchEvent(true);
- break;
- case MotionEvent.ACTION_MOVE:
- if(事件交给父容器的条件) {
- getParent().requestDisallowInterceptTouchEvent(false);
- }
- break;
- case MotionEvent.ACTION_UP:
- break;
- default:
- break;
- }
- return super.dispatchTouchEvent(ev);
- }
- public class HeaderScrollHelper {
- private int sysVersion; //当前sdk版本,用于判断api版本
- private ScrollableContainer mCurrentScrollableContainer;
- public HeaderScrollHelper() {
- sysVersion = Build.VERSION.SDK_INT;
- }
- /** 包含有 ScrollView ListView RecyclerView 的组件 */
- public interface ScrollableContainer {
- /** @return ScrollView ListView RecyclerView 或者其他的布局的实例 */
- View getScrollableView();
- }
- public void setCurrentScrollableContainer(ScrollableContainer scrollableContainer) {
- this.mCurrentScrollableContainer = scrollableContainer;
- }
- private View getScrollableView() {
- if (mCurrentScrollableContainer == null) return null;
- return mCurrentScrollableContainer.getScrollableView();
- }
- /**
- * 判断是否滑动到顶部方法,ScrollAbleLayout根据此方法来做一些逻辑判断
- * 目前只实现了AdapterView,ScrollView,RecyclerView
- * 需要支持其他view可以自行补充实现
- */
- public boolean isTop() {
- View scrollableView = getScrollableView();
- if (scrollableView == null) {
- throw new NullPointerException("You should call ScrollableHelper.setCurrentScrollableContainer() to set ScrollableContainer.");
- }
- if (scrollableView instanceof AdapterView) {
- return isAdapterViewTop((AdapterView) scrollableView);
- }
- if (scrollableView instanceof ScrollView) {
- return isScrollViewTop((ScrollView) scrollableView);
- }
- if (scrollableView instanceof RecyclerView) {
- return isRecyclerViewTop((RecyclerView) scrollableView);
- }
- if (scrollableView instanceof WebView) {
- return isWebViewTop((WebView) scrollableView);
- }
- throw new IllegalStateException("scrollableView must be a instance of AdapterView|ScrollView|RecyclerView");
- }
- private boolean isRecyclerViewTop(RecyclerView recyclerView) {
- if (recyclerView != null) {
- RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
- if (layoutManager instanceof LinearLayoutManager) {
- int firstVisibleItemPosition = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();
- View childAt = recyclerView.getChildAt(0);
- if (childAt == null || (firstVisibleItemPosition == 0 && childAt.getTop() == 0)) {
- return true;
- }
- }
- }
- return false;
- }
- private boolean isAdapterViewTop(AdapterView adapterView) {
- if (adapterView != null) {
- int firstVisiblePosition = adapterView.getFirstVisiblePosition();
- View childAt = adapterView.getChildAt(0);
- if (childAt == null || (firstVisiblePosition == 0 && childAt.getTop() == 0)) {
- return true;
- }
- }
- return false;
- }
- private boolean isScrollViewTop(ScrollView scrollView) {
- if (scrollView != null) {
- int scrollViewY = scrollView.getScrollY();
- return scrollViewY <= 0;
- }
- return false;
- }
- private boolean isWebViewTop(WebView scrollView) {
- if (scrollView != null) {
- int scrollViewY = scrollView.getScrollY();
- return scrollViewY <= 0;
- }
- return false;
- }
- /**
- * 将特定的view按照初始条件滚动
- *
- * @param velocityY 初始滚动速度
- * @param distance 需要滚动的距离
- * @param duration 允许滚动的时间
- */
- @SuppressLint("NewApi")
- public void smoothScrollBy(int velocityY, int distance, int duration) {
- View scrollableView = getScrollableView();
- if (scrollableView instanceof AbsListView) {
- AbsListView absListView = (AbsListView) scrollableView;
- if (sysVersion >= 21) {
- absListView.fling(velocityY);
- } else {
- absListView.smoothScrollBy(distance, duration);
- }
- } else if (scrollableView instanceof ScrollView) {
- ((ScrollView) scrollableView).fling(velocityY);
- } else if (scrollableView instanceof RecyclerView) {
- ((RecyclerView) scrollableView).fling(0, velocityY);
- } else if (scrollableView instanceof WebView) {
- ((WebView) scrollableView).flingScroll(0, velocityY);
- }
- }
- }
- public class HeaderScrollView extends LinearLayout {
- private static final int DIRECTION_UP = 1;
- private static final int DIRECTION_DOWN = 2;
- private int topOffset = 0; //滚动的最大偏移量
- private Scroller mScroller;
- private int mTouchSlop; //表示滑动的时候,手的移动要大于这个距离才开始移动控件。
- private int mMinimumVelocity; //允许执行一个fling手势动作的最小速度值
- private int mMaximumVelocity; //允许执行一个fling手势动作的最大速度值
- private int sysVersion; //当前sdk版本,用于判断api版本
- private View mHeadView; //需要被滑出的头部
- private int mHeadHeight; //滑出头部的高度
- private int maxY = 0; //最大滑出的距离,等于 mHeadHeight
- private int minY = 0; //最小的距离, 头部在最顶部
- private int mCurY; //当前已经滚动的距离
- private VelocityTracker mVelocityTracker;
- private int mDirection;
- private int mLastScrollerY;
- private boolean mDisallowIntercept; //是否允许拦截事件
- private boolean isClickHead; //当前点击区域是否在头部
- private OnScrollListener onScrollListener; //滚动的监听
- private HeaderScrollHelper mScrollable;
- public interface OnScrollListener {
- void onScroll(int currentY, int maxY);
- }
- public void setOnScrollListener(OnScrollListener onScrollListener) {
- this.onScrollListener = onScrollListener;
- }
- public HeaderScrollView(Context context) {
- this(context, null);
- }
- public HeaderScrollView(Context context, AttributeSet attrs) {
- this(context, attrs, 0);
- }
- public HeaderScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CTTourHeaderScrollView);
- topOffset = a.getDimensionPixelSize(R.styleable.CTTourHeaderScrollView_top_offset, topOffset);
- a.recycle();
- mScroller = new Scroller(context);
- mScrollable = new HeaderScrollHelper();
- ViewConfiguration configuration = ViewConfiguration.get(context);
- mTouchSlop = configuration.getScaledTouchSlop(); //表示滑动的时候,手的移动要大于这个距离才开始移动控件。
- mMinimumVelocity = configuration.getScaledMinimumFlingVelocity(); //允许执行一个fling手势动作的最小速度值
- mMaximumVelocity = configuration.getScaledMaximumFlingVelocity(); //允许执行一个fling手势动作的最大速度值
- sysVersion = Build.VERSION.SDK_INT;
- }
- @Override
- protected void onFinishInflate() {
- super.onFinishInflate();
- if (mHeadView != null && !mHeadView.isClickable()) {
- mHeadView.setClickable(true);
- }
- }
- @Override
- protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
- mHeadView = getChildAt(0);
- measureChildWithMargins(mHeadView, widthMeasureSpec, 0, MeasureSpec.UNSPECIFIED, 0);
- mHeadHeight = mHeadView.getMeasuredHeight();
- maxY = mHeadHeight - topOffset;
- //让测量高度加上头部的高度
- super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec) + maxY, MeasureSpec.EXACTLY));
- }
- /** @param disallowIntercept 作用同 requestDisallowInterceptTouchEvent */
- public void requestHeaderViewPagerDisallowInterceptTouchEvent(boolean disallowIntercept) {
- super.requestDisallowInterceptTouchEvent(disallowIntercept);
- mDisallowIntercept = disallowIntercept;
- }
- private float mDownX; //第一次按下的x坐标
- private float mDownY; //第一次按下的y坐标
- private float mLastY; //最后一次移动的Y坐标
- private boolean verticalScrollFlag = false; //是否允许垂直滚动
- @Override
- public boolean dispatchTouchEvent(MotionEvent ev) {
- float currentX = ev.getX(); //当前手指相对于当前view的X坐标
- float currentY = ev.getY(); //当前手指相对于当前view的Y坐标
- float shiftX = Math.abs(currentX - mDownX); //当前触摸位置与第一次按下位置的X偏移量
- float shiftY = Math.abs(currentY - mDownY); //当前触摸位置与第一次按下位置的Y偏移量
- float deltaY; //滑动的偏移量,即连续两次进入Move的偏移量
- obtainVelocityTracker(ev); //初始化速度追踪器
- switch (ev.getAction()) {
- //Down事件主要初始化变量
- case MotionEvent.ACTION_DOWN:
- mDisallowIntercept = false;
- verticalScrollFlag = false;
- mDownX = currentX;
- mDownY = currentY;
- mLastY = currentY;
- checkIsClickHead((int) currentY, mHeadHeight, getScrollY());
- mScroller.abortAnimation();
- break;
- case MotionEvent.ACTION_MOVE:
- if (mDisallowIntercept) break;
- deltaY = mLastY - currentY; //连续两次进入move的偏移量
- mLastY = currentY;
- if (shiftX > mTouchSlop && shiftX > shiftY) {
- //水平滑动
- verticalScrollFlag = false;
- } else if (shiftY > mTouchSlop && shiftY > shiftX) {
- //垂直滑动
- verticalScrollFlag = true;
- }
- /**
- * 这里要注意,对于垂直滑动来说,给出以下三个条件
- * 头部没有固定,允许滑动的View处于第一条可见,当前按下的点在头部区域
- * 三个条件满足一个即表示需要滚动当前布局,否者不处理,将事件交给子View去处理
- */
- if (verticalScrollFlag && (!isStickied() || mScrollable.isTop() || isClickHead)) {
- //如果是向下滑,则deltaY小于0,对于scrollBy来说
- //正值为向上和向左滑,负值为向下和向右滑,这里要注意
- scrollBy(0, (int) (deltaY + 0.5));
- invalidate();
- }
- break;
- case MotionEvent.ACTION_UP:
- if (verticalScrollFlag) {
- mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); //1000表示单位,每1000毫秒允许滑过的最大距离是mMaximumVelocity
- float yVelocity = mVelocityTracker.getYVelocity(); //获取当前的滑动速度
- mDirection = yVelocity > 0 ? DIRECTION_DOWN : DIRECTION_UP; //下滑速度大于0,上滑速度小于0
- //根据当前的速度和初始化参数,将滑动的惯性初始化到当前View,至于是否滑动当前View,取决于computeScroll中计算的值
- //这里不判断最小速度,确保computeScroll一定至少执行一次
- mScroller.fling(0, getScrollY(), 0, -(int) yVelocity, 0, 0, -Integer.MAX_VALUE, Integer.MAX_VALUE);
- mLastScrollerY = getScrollY();
- invalidate(); //更新界面,该行代码会导致computeScroll中的代码执行
- //阻止快读滑动的时候点击事件的发生,滑动的时候,将Up事件改为Cancel就不会发生点击了
- if ((shiftX > mTouchSlop || shiftY > mTouchSlop)) {
- if (isClickHead || !isStickied()) {
- int action = ev.getAction();
- ev.setAction(MotionEvent.ACTION_CANCEL);
- boolean dd = super.dispatchTouchEvent(ev);
- ev.setAction(action);
- return dd;
- }
- }
- }
- recycleVelocityTracker();
- break;
- case MotionEvent.ACTION_CANCEL:
- recycleVelocityTracker();
- break;
- default:
- break;
- }
- //手动将事件传递给子View,让子View自己去处理事件
- super.dispatchTouchEvent(ev);
- //消费事件,返回True表示当前View需要消费事件,就是事件的TargetView
- return true;
- }
- private void checkIsClickHead(int downY, int headHeight, int scrollY) {
- isClickHead = ((downY + scrollY) <= headHeight);
- }
- private void obtainVelocityTracker(MotionEvent event) {
- if (mVelocityTracker == null) {
- mVelocityTracker = VelocityTracker.obtain();
- }
- mVelocityTracker.addMovement(event);
- }
- private void recycleVelocityTracker() {
- if (mVelocityTracker != null) {
- mVelocityTracker.recycle();
- mVelocityTracker = null;
- }
- }
- @Override
- public void computeScroll() {
- if (mScroller.computeScrollOffset()) {
- final int currY = mScroller.getCurrY();
- if (mDirection == DIRECTION_UP) {
- // 手势向上划
- if (isStickied()) {
- //这里主要是将快速滚动时的速度对接起来,让布局看起来滚动连贯
- int distance = mScroller.getFinalY() - currY; //除去布局滚动消耗的时间后,剩余的时间
- int duration = calcDuration(mScroller.getDuration(), mScroller.timePassed()); //除去布局滚动的距离后,剩余的距离
- mScrollable.smoothScrollBy(getScrollerVelocity(distance, duration), distance, duration);
- //外层布局已经滚动到指定位置,不需要继续滚动了
- mScroller.abortAnimation();
- return;
- } else {
- scrollTo(0, currY); //将外层布局滚动到指定位置
- invalidate(); //移动完后刷新界面
- }
- } else {
- // 手势向下划,内部View已经滚动到顶了,需要滚动外层的View
- if (mScrollable.isTop() || isClickHead) {
- int deltaY = (currY - mLastScrollerY);
- int toY = getScrollY() + deltaY;
- scrollTo(0, toY);
- if (mCurY <= minY) {
- mScroller.abortAnimation();
- return;
- }
- }
- //向下滑动时,初始状态可能不在顶部,所以要一直重绘,让computeScroll一直调用
- //确保代码能进入上面的if判断
- invalidate();
- }
- mLastScrollerY = currY;
- }
- }
- @SuppressLint("NewApi")
- private int getScrollerVelocity(int distance, int duration) {
- if (mScroller == null) {
- return 0;
- } else if (sysVersion >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
- return (int) mScroller.getCurrVelocity();
- } else {
- return distance / duration;
- }
- }
- /** 对滑动范围做限制 */
- @Override
- public void scrollBy(int x, int y) {
- int scrollY = getScrollY();
- int toY = scrollY + y;
- if (toY >= maxY) {
- toY = maxY;
- } else if (toY <= minY) {
- toY = minY;
- }
- y = toY - scrollY;
- super.scrollBy(x, y);
- }
- /** 对滑动范围做限制 */
- @Override
- public void scrollTo(int x, int y) {
- if (y >= maxY) {
- y = maxY;
- } else if (y <= minY) {
- y = minY;
- }
- mCurY = y;
- if (onScrollListener != null) {
- onScrollListener.onScroll(y, maxY);
- }
- super.scrollTo(x, y);
- }
- /** 头部是否已经固定 */
- public boolean isStickied() {
- return mCurY == maxY;
- }
- private int calcDuration(int duration, int timepass) {
- return duration - timepass;
- }
- public int getMaxY() {
- return maxY;
- }
- public boolean isHeadTop() {
- return mCurY == minY;
- }
- /** 是否允许下拉,与PTR结合使用 */
- public boolean canPtr() {
- return verticalScrollFlag && mCurY == minY && mScrollable.isTop();
- }
- public void setTopOffset(int topOffset) {
- this.topOffset = topOffset;
- }
- public void setCurrentScrollableContainer(HeaderScrollHelper.ScrollableContainer scrollableContainer) {
- mScrollable.setCurrentScrollableContainer(scrollableContainer);
- }
- }
~~~~~~~~~~~~~华丽丽的分割线:解答朋友们关心的几个问题~~~~~~~~~~~~~~~~~~~~~~
首先感谢各位朋友的支持,看到你们能给我的github一个star或者fork我写的demo,我的内心充满了感恩。
最近有很多朋友通过留言或邮件的方式联系我,希望我能进行更细致的讲解。
为了能给更大家的开发带来更大的便捷,我决定还是更新一下github,并且把最新的代码合到了demo里,欢迎下载。
看到有一些朋友问为什么不采取多类型recyclerview的方式,我这里试着解答一下。问这个问题的朋友,我相信你肯定只是没有遇到这样必须要scrollview嵌套另外一个可滑动layout的需求。在我们的产品详情页里需要把webview置顶,如果你来实现能有什么更好的方式吗?只能用ScrollView嵌套一个webview吧。另外,我在博文里确实也已经提到过,如果只是实现吸顶功能,确实使用recyclerview就可以实现了,我也已经实现过并且代码也已经上线几个月了。我的一篇博文《【Android 编程架构 程序设计】多Item类型的RecyclerView替代scrollView(附demo)》介绍了一种多类型RecyclerView的编程框架,该代码已经上线并且稳定运行几个月了,如有需要可以去查看。