Android仿今日头条详情页---多子view嵌套滚动方案

一、背景

项目地址:ELinkageScrollLayout

类似百度APP、今日头条等产品的新闻详情页的实现,ELinkageScrollLayout给出了一种多子view嵌套滑动的解决方案。下文中我们统一叫做"联动容器"

联动容器(ELinkageScrollLayout)有如下优点:

  1. 不限子view的数量;
  2. 不限子view的类型;

先直观感受下联动容器嵌套滚动的Demo效果:

二、分析

同大多数自定义控件类似,联动容器也需要处理子view的测量、布局以及手势处理。测量和布局对联动容器的场景来说非常简单,手势处理是实现联动容器的关键,也是最困难的地方。

从demo效果可以看出,联动容器需要处理好和子view嵌套滑动问题。关于嵌套滑动的处理方案有两种,1.是基于Google的NestedScrolling机制实现嵌套滑动;2.是由联动容器内部处理和子view嵌套滑动的逻辑。百度App早期版本的联动容器采用的方案2实现的,下图为方案2联动容器手势处理流程:


并且我们对方案2联动容器的实现代码做了开源,感兴趣的同学可以参考:https://github.com/baiduapp-tec/LinkageScrollLayout

接下来我们详细阐述下方案1,也就是基于Google的NestedScrolling机制实现的联动容器。

三、详细设计

3.1 Google嵌套滑动机制

Google在Android 5.0推出了一套NestedScrolling机制,这套机制滚动打破了对之前Android传统的事件处理的认知,是按照逆向事件传递机制来处理嵌套滚动,事件传递可参考下图:



根据上图对NestedScrolling机制做个简要说明:事件由NestedScrollingParent向NestedScrollingChild传递,NestedScrollingChild在消费该事件之前,会先向上查找NestedScrollingParent,如果找到,则将具体的事件先通知给它们。NestedScrollingParent收到事件后,可选择选择消费/不消费/消费部分事件,NestedScrollingParent再将处理结果返回给NestedScrollingChild,NestedScrollingChild收到NestedScrollingParent对该事件的处理结果后,再决定后续的事件处理行为。

网上有很多关于NestedScrolling的文章,如果没接触过NestedScrolling的同学可参考下张鸿洋的这篇文章:https://blog.csdn.net/lmj623565791/article/details/52204039

3.2 接口设计

为了保证联动容器中子view的任意性,联动容器需提供完善的接口抽象供子view去实现。下图为联动容器暴露的接口类图:

结合上面的类图,对各接口做下简单解释:

ILinkageScroll是置于联动容器中的子view必须要实现的接口,联动容器在初始化时如果发现某个子view没实现该接口,会抛出异常。ILinkageScroll中又会涉及两个接口:LinkageScrollHandler、ChildLinkageEvent。

  • LinkageScrollHandler接口中的方法联动容器会在需要时主动调用,以通知子view完成一些功能,比如:获取子view是否可滚动,获取子view滚动条相关数据等。
  • ChildLinkageEvent接口定义了子view的一些事件信息,比如子view的内容滚动到顶部或底部。当发生这些事件后,子view主动调用对应方法,这样联动容器收到子view一些事件后会做出相应的反应,保证正常的联动效果。

上面仅简单说明了下接口功能,想更加深入了解的同学请参考:https://github.com/baiduapp-tec/ELinkageScroll

接下来我们详细分析下联动容器对手势处理细节,根据手势类型,将嵌套滑动分为两种情况来分析:1. scroll手势;2. fling手势;

3.3 scroll手势

先给出scroll手势处理的核心代码:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {
	@Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {

        boolean moveUp = dy > 0;
        boolean moveDown = !moveUp;
        int scrollY = getScrollY();
        int topEdge = target.getTop();
        LinkageScrollHandler targetScrollHandler
                = ((ILinkageScroll)target).provideScrollHandler();

        if (scrollY == topEdge) {	// 联动容器scrollY与当前子view的top坐标重合			
            if ((moveDown && !targetScrollHandler.canScrollVertically(-1))
                    || (moveUp && !targetScrollHandler.canScrollVertically(1))) {
				// 在对应的滑动方向上,如果子view不能垂直滑动,则由联动容器消费滚动距离
                scrollBy(0, dy);
                consumed[1] = dy;
            } 
        } else if (scrollY > topEdge) {	// 联动容器scrollY大于当前子view的top坐标,也就是说,子view头部已经滑出联动容器
            if (moveUp) {
				// 如果手指上滑,则由联动容器消费滚动距离
                scrollBy(0, dy);
                consumed[1] = dy;
            }
            if (moveDown) {
				// 如果手指下滑,联动容器会先消费部分距离,此时联动容器的scrollY会不断减小,
				// 直到等于子view的top坐标后,剩余的滑动距离则由子view继续消费。
                int end = scrollY + dy;
                int deltaY;
                deltaY = end > topEdge ? dy : (topEdge - scrollY);
                scrollBy(0, deltaY);
                consumed[1] = deltaY;
            }
        } else if (scrollY < topEdge) {	// 联动容器scrollY小于当前子view的top坐标,也就是说,子view还没有完全露出
            if (moveDown) {
				// 如果手指下滑,则由联动容器消费滚动距离
                scrollBy(0, dy);
                consumed[1] = dy;
            }
            if (moveUp) {
				// 如果手指上滑,联动容器会先消费部分距离,此时联动容器的scrollY会不断增大,
				// 直到等于子view的top坐标后,剩余的滑动距离则由子view继续消费。
                int end = scrollY + dy;
                int deltaY;
                deltaY = end < topEdge ? dy : (topEdge - scrollY);
                scrollBy(0, deltaY);
                consumed[1] = deltaY;
            }
        }
    }

    @Override
    public void scrollBy(int x, int y) {
        // 边界检查
        int scrollY = getScrollY();
        int deltaY;
        if (y < 0) {
            deltaY = (scrollY + y) < 0 ? (-scrollY) : y;
        } else {
            deltaY = (scrollY + y) > mScrollRange ?
                    (mScrollRange - scrollY) : y;
        }
        if (deltaY != 0) {
            super.scrollBy(x, deltaY);
        }
    }
}

onNestedPreScroll()回调是google嵌套滑动机制NestedScrollingParent接口中的方法。当子view滚动时,会先通过此方法询问父view是否消费这段滚动距离,父view根据自身情况决定是否消费以及消费多少,并将消费的距离放入数组consumed中,子view再根据数组中的内容决定自己的滚动距离。

代码注释已经非常详细,这里整体再做个解释:通过对子view的上边沿阈值和联动容器的scrollY进行比较,处理了3种case下的滚动情况。第12行,当scrollY == topEdge时,只要子view没有滚动到顶或者底,都由子view正常消费滚动距离,否则由联动容器消费滚动距离,并将消费的距离通过consumed变量通知子view,子view会根据consumed变量中的内容决定自己的滑动距离。第19行,当scrollY > topEdge时,也就是说当触摸的子view头部已经滑出联动容器,此时如果手指向上滑动,滑动距离全部由联动容器消费,如果手指向下滑动,联动容器会先消费部分距离,当联动容器的scrollY达到topEdge后,剩余的滑动距离由子view继续消费。第34行,当scrollY < topEdge这个和上一个第19行判断类似,这里不做过多解释。scroll手势处理流程图如下:


3.4 fling手势

联动容器对fling手势的处理大致思路如下:如果联动容器的scrollY等于子view的top坐标,则由子view自身处理fling手势,否则由联动容器处理fling手势。而且在一次完整的fling周期中,联动容器和各子view将会交替去完成滑动行为,直到速度降为0,联动容器需要处理好交替滑动时的速度衔接,保证整个fling的流畅行。接下来看下详细实现:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {

    @Override
    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
        int scrollY = getScrollY();
        int targetTop = target.getTop();
        mFlingOrientation = velocityY > 0 ? FLING_ORIENTATION_UP : FLING_ORIENTATION_DOWN;
        if (scrollY == targetTop) {	// 当联动容器的scrollY等于子view的top坐标,则由子view自身处理fling手势
            // 跟踪velocity,当target滚动到顶或底,保证parent继续fling
            trackVelocity(velocityY);
            return false;
        } else {	// 由联动容器消费fling手势
            parentFling(velocityY);
            return true;
        }
    }
}

onNestedPreFling()回调是google嵌套滑动机制NestedScrollingParent接口中的方法。当子view发生fling行为时,会先通过此方法询问父view是否要消费这次fling手势,如果返回true,表示父view要消费这次fling手势,反之不消费。

第7行根据velocityY正负值记录本次的fling的方向;第8行,当联动容器scrollY值等于触摸子view的top值,fling手势由子view处理,同时联动容器对本次fling手势的速度进行追踪,目的是当子view内容滚到顶或者底时,能够获得剩余速度以让联动容器继续fling;第13行,由联动容器消费本次fling手势。下面看下联动容器和子view交替fling的细节:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {

	@Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            int y = mScroller.getCurrY();
            y = y < 0 ? 0 : y;
            y = y > mScrollRange ? mScrollRange : y;
			// 获取联动容器下个滚动边界值,如果达到边界值,速度会传给下个子view,让子view继续快速滑动
			int edge = getNextEdge();
			// 边界检查
			if (mFlingOrientation == FLING_ORIENTATION_UP) {
                y = y > edge ? edge : y;
            }
			// 边界检查
            if (mFlingOrientation == FLING_ORIENTATION_DOWN) {
                y = y < edge ? edge : y;
            }
			// 联动容器滚动子view
            scrollTo(x, y);
            int scrollY = getScrollY();
			// 联动容器最新的scrollY是否达到了边界值
            if (scrollY == edge) {
				// 获取剩余的速度
                int velocity = (int) mScroller.getCurrVelocity();
                if (mFlingOrientation == FLING_ORIENTATION_UP) {
                    velocity = velocity > 0? velocity : - velocity;
                }
                if (mFlingOrientation == FLING_ORIENTATION_DOWN) {
                    velocity = velocity < 0? velocity : - velocity;
                }	
				// 获取top为edge的子view
                View target = getTargetByEdge(edge);
				// 子view根据剩余的速度继续fling
                ((ILinkageScroll) target).provideScrollHandler()
                        .flingContent(target, velocity);
                trackVelocity(velocity);
            }
            invalidate();
        }
    }

    /**
     * 根据fling的方向获取下一个滚动边界,
     * 内部会判断下一个子View是否isScrollable,
     * 如果为false,会顺延取下一个target的edge。
     */
    private int getNextEdge() {
        int scrollY = getScrollY();
        if (mFlingOrientation == FLING_ORIENTATION_UP) {
            for (View target : mLinkageChildren) {
                LinkageScrollHandler handler
                        = ((ILinkageScroll)target).provideScrollHandler();
                int topEdge = target.getTop();
                if (topEdge > scrollY
                        && isTargetScrollable(target)
                        && handler.canScrollVertically(1)) {
                    return topEdge;
                }
            }
        } else if (mFlingOrientation == FLING_ORIENTATION_DOWN) {
            for (View target : mLinkageChildren) {
                LinkageScrollHandler handler
                        = ((ILinkageScroll)target).provideScrollHandler();
                int bottomEdge = target.getBottom();
                if (bottomEdge >= scrollY
                        && isTargetScrollable(target)
                        && handler.canScrollVertically(-1)) {
                    return target.getTop();
                }
            }
        }
        return mFlingOrientation == FLING_ORIENTATION_UP ? mScrollRange : 0;
    }

    /** 
	 * child view的滚动事件 
	 */
    private ChildLinkageEvent mChildLinkageEvent = new ChildLinkageEvent() {
        @Override
        public void onContentScrollToTop(View target) {
			// 子view内容滚动到顶部回调
            if (mVelocityScroller.computeScrollOffset()) {
				// 从速度追踪器中获取剩余速度
                float currVelocity = mVelocityScroller.getCurrVelocity();
                currVelocity = currVelocity < 0 ? currVelocity : - currVelocity;
                mVelocityScroller.abortAnimation();
				// 联动容器根据剩余速度继续fling
                parentFling(currVelocity);
            }
        }

        @Override
        public void onContentScrollToBottom(View target) {
			// 子view内容滚动到底部回调
            if (mVelocityScroller.computeScrollOffset()) {
				// 从速度追踪器中获取剩余速度
                float currVelocity = mVelocityScroller.getCurrVelocity();
                currVelocity = currVelocity > 0 ? currVelocity : - currVelocity;
                mVelocityScroller.abortAnimation();
				// 联动容器根据剩余速度继续fling
                parentFling(currVelocity);
            }
        }
    };
}

fling的速度传递分为:1. 从联动容器向子view传递;2. 从子view向联动容器传递。

先看速度从联动容器向子view传递。核心代码在computeScroll()回调方法中。第10行,获取联动容器下一个滚动边界值,如果达到下一个滚动边界值,联动容器需要将剩余速度传给下个子view,让其继续滚动。第48行,getNextEdge()方法内部整体逻辑:遍历所有子view,将联动容器当前的scrollY与子view的top/bottom进行比较来获取下一个滑动边界。第35行,当联动容器检测到滑动到下个边界时,则调用ILinkageScroll.flingContent()让子view根据剩余速度继续滚动。

再看下速度从子view向联动容器传递。核心代码在第79行。当子view内容滚动到顶或者底,会回调onContentScrollToTop()方法或者onContentScrollToBottom()方法,联动容器收到回调后,在第89行和第102行,继续执行后续滚动。fling手势处理流程图如下:


四、滚动条

4.1 Android系统的ScrollBar

对于内容可滚动的页面,ScrollBar则是一个不可或缺的UI组件。ScrollBar方便用户定位当前阅读的位置,以及提示用户下面还有多少内容。所以,ScrollBar也是联动容器必须要实现的功能。

好在Android系统对滚动条的抽象已经非常友好了,自定义控件只需要重写View中的几个方法,Android系统就能帮助你正确绘制出滚动条。我们先看下View中的相关方法:

/**
 * <p>Compute the vertical offset of the vertical scrollbar's thumb within the horizontal range. This value is used to compute the position
 * of the thumb within the scrollbar's track.</p>
 *
 * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollRange()} and
 * {@link #computeVerticalScrollExtent()}.</p>
 *
 * @return the vertical offset of the scrollbar's thumb
 */
protected int computeVerticalScrollOffset() {
    return mScrollY;
}

/**
 * <p>Compute the vertical extent of the vertical scrollbar's thumb within the vertical range. This value is used to compute the length
 * of the thumb within the scrollbar's track.</p>
 *
 * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollRange()} and
 * {@link #computeVerticalScrollOffset()}.</p>
 *
 * @return the vertical extent of the scrollbar's thumb
 */
protected int computeVerticalScrollExtent() {
    return getHeight();
}

/**
 * <p>Compute the vertical range that the vertical scrollbar represents.</p>
 *
 * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollExtent()} and
 * {@link #computeVerticalScrollOffset()}.</p>
 *
 * @return the total vertical range represented by the vertical scrollbar
 */
protected int computeVerticalScrollRange() {
    return getHeight();
}

对于垂直Scrollbar,我们只需要重写computeVerticalScrollOffset(),computeVerticalScrollExtent(),computeVerticalScrollRange()这三个方法即可。Android对这三个方法注释已经非常详细了,这里再简单解释下:

  • computeVerticalScrollOffset()表示当前页面内容滚动的偏移值,这个值是用来控制Scrollbar的位置。缺省值为当前页面Y方向上的滚动值。
  • computeVerticalScrollExtent()表示滚动条的范围,也就是滚动条在垂直方向上所能触及的最大界限,这个值也会被系统用来计算滚动条的长度。缺省值是View的实际高度。
  • computeVerticalScrollRange()表示整个页面内容可滚动的数值范围,缺省值为View的实际高度。

需要注意的是:offset,extent,range三个值在单位上必须保持一致。

4.2 联动容器实现ScrollBar

联动容器是由系统中可滚动的子view组成的,这些子view(ListView、RecyclerView、WebView)肯定都实现了ScrollBar功能,那么联动容器实现ScrollBar就非常简单了,联动容器只需拿到所有子view的offset,extent,range值,然后再根据联动容器的滑动逻辑把所有子view的这些值转换成联动容器对应的offset,extent,range即可。下面我看下详细代码:

public interface LinkageScrollHandler {
    // ...省略无关代码

    /**
     * get scrollbar extent value
     *
     * @return extent
     */
    int getVerticalScrollExtent();

    /**
     * get scrollbar offset value
     *
     * @return extent
     */
    int getVerticalScrollOffset();

    /**
     * get scrollbar range value
     *
     * @return extent
     */
    int getVerticalScrollRange();
}

LinkageScrollHandler接口在3.2小节解释过,这里不在赘述。这里面三个方法由子view去实现,联动容器会通过这三个方法获取子view与滚动条相关的值。下面我们看下联动容器中关于ScrollBar的详细逻辑:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {
    
    /** 构造方法 */
    public ELinkageScrollLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        // ...省略了无关代码
        // 确保联动容器调用onDraw()方法
        setWillNotDraw(false);
        // enable vertical scrollbar
        setVerticalScrollBarEnabled(true);
    }

    /** child view的滚动事件 */
    private ChildLinkageEvent mChildLinkageEvent = new ChildLinkageEvent() {
        // ...省略了无关代码
        @Override
        public void onContentScroll(View target) {
            // 收到子view滚动事件,显示滚动条
            awakenScrollBars();
        }
    }

    @Override
    protected int computeVerticalScrollExtent() {
        // 使用缺省的extent值
        return super.computeVerticalScrollExtent();
    }

    @Override
    protected int computeVerticalScrollRange() {
        int range = 0;
        // 遍历所有子view,获取子view的Range
        for (View child : mLinkageChildren) {
            ILinkageScroll linkageScroll = (ILinkageScroll) child;
            int childRange = linkageScroll.provideScrollHandler().getVerticalScrollRange();
            range += childRange;
        }
        return range;
    }

    @Override
    protected int computeVerticalScrollOffset() {
        int offset = 0;
        // 遍历所有子view,获取子view的offset
        for (View child : mLinkageChildren) {
            ILinkageScroll linkageScroll = (ILinkageScroll) child;
            int childOffset = linkageScroll.provideScrollHandler().getVerticalScrollOffset();
            offset += childOffset;
        }
        // 加上联动容器自身在Y方向上的滚动偏移
        offset += getScrollY();
        return offset;
    }
}

以上就是联动容器实现ScrollBar的核心代码,注释也非常详细,这里再重点强调几点:

  • 系统为了提高效率,ViewGroup默认不调用onDraw()方法,这样就不会走ScrollBar的绘制逻辑。所以在第7行,需要调用setWillNotDraw(false)打开ViewGroup绘制流程;
  • 第18行,收到子view的滚动回调,调用awakenScrollBars()触发滚动条的绘制;
  • 对于extent,直接使用缺省的extent,即联动容器的高度;
  • 对于range,对所有子view的range进行求和,最后得到值即为联动容器的range;
  • 对于offset,同样先对所有子view的offset进行求和,之后还需要加上联动容器自身的scrollY值,最终得到的值即为联动容器的offset。

大家可以返回到文章开头,再看下Demo中滚动条的效果,相比于市面上其它使用类似联动技术的App,本文对滚动条的实现非常接近原生了。

五、总结

联动容器和各子view之间交替消费fling手势,联动容器需要处理好边界处速度的传递,保证整体滑动的连续性;

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值