UI系列一Android多子view嵌套通用解决方案

UI系列一Android多子view嵌套通用解决方案

原创 zhanghao 百度App技术

转载自掘金:https://juejin.im/post/5e5e1145f265da5741120b5a

1.多子view嵌套应用背景

百度App在17年的版本中实现2个子view嵌套滚动,用于Feed落地页(webview呈现文章详情 + recycle呈现Native评论)。原理是在外层提供一个UI容器(我们称之为”联动容器”)处理WebView和Recyclerview连贯嵌套滚动。

当时的联动容器对子view限制比较大,仅支持WebView和Recyclerview进行联动滚动,数量也只支持2个子View。

随着组件化进程的推进,为方便各业务解耦,对联动容器提出了更高的要求,需要支持任意类型、任意数量的子view进行联动滚动,也就是本文要阐述的多子view嵌套滚动通用解决方案。

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

2. 多子view嵌套实现原理

同大多数自定义控件类似,联动容器也需要处理子view的测量、布局以及手势处理。测量和布局对联动容器的场景来说非常简单,手势处理相对复杂些。

从demo效果可以看出,联动容器需要处理好和子view嵌套滑动问题。嵌套滑动的处理方案有两种

基于Google的NestedScrolling机制实现嵌套滑动;

是由联动容器内部处理和子view嵌套滑动的逻辑。

百度App早期版本的联动容器采用的方案2实现的,下图为方案2联动容器手势处理流程:

笔者对方案2联动容器的实现代码做了开源,感兴趣的同学可以参考:github.com/baiduapp-te… 基于google的NestedScrolling实现多子view嵌套能节省不少开发量,故笔者对多子view嵌套的实现采用方案一。

3. 核心逻辑

3.1 Google嵌套滑动机制

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


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

3.2 接口设计

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


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

LinkageScrollHandler接口中的方法联动容器会在需要时主动调用,以通知子view完成一些功能,比如:获取子view是否可滚动,获取子view滚动条相关数据等。

ChildLinkageEvent接口定义了子view的一些事件信息,比如子view的内容滚动到顶部或底部。当发生这些事件后,子view主动调用对应方法,这样联动容器收到子view一些事件后会做出相应的反应,保证正常的联动效果。

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

接下来我们详细分析下联动容器对手势处理细节,根据手势类型,将嵌套滑动分为两种情况来分析: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下的滚动情况。

第10行,当scrollY == topEdge时,只要子view没有滚动到顶或者底,都由子view正常消费滚动距离,否则由联动容器消费滚动距离,并将消费的距离通过consumed变量通知子view,子view会根据consumed变量中的内容决定自己的滑动距离。

第17行,当scrollY > topEdge时,也就是说当触摸的子view头部已经滑出联动容器,此时如果手指向上滑动,滑动距离全部由联动容器消费,如果手指向下滑动,联动容器会先消费部分距离,当联动容器的scrollY达到topEdge后,剩余的滑动距离由子view继续消费。

第32行,当scrollY < topEdge这个和上一个第17行判断类似,这里不做过多解释。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手势,反之不消费。

第6行根据velocityY正负值记录本次的fling的方向;

第7行,当联动容器scrollY值等于触摸子view的top值,fling手势由子view处理,同时联动容器对本次fling手势的速度进行追踪,目的是当子view内容滚到顶或者底时,能够获得剩余速度以让联动容器继续fling;

第12行,由联动容器消费本次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的速度传递分为:

从联动容器向子view传递;2. 从子view向联动容器传递。

先看速度从联动容器向子view传递。核心代码在computeScroll()回调方法中。第9行,获取联动容器下一个滚动边界值,如果达到下一个滚动边界值,联动容器需要将剩余速度传给下个子view,让其继续滚动。

第46行,getNextEdge()方法内部整体逻辑:遍历所有子view,将联动容器当前的scrollY与子view的top/bottom进行比较来获取下一个滑动边界。

第34行,当联动容器检测到滑动到下个边界时,则调用ILinkageScroll.flingContent()让子view根据剩余速度继续滚动。

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


4. 滚动条

4.1 Android系统的ScrollBar

对于内容可滚动的页面,ScrollBar则是一个不可或缺的UI组件,所以,ScrollBar也是联动容器必须要实现的功能。

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

/** *

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. * *

The range is expressedinarbitrary units that must be the same as the units used by {@link#computeVerticalScrollRange()} and* {@link#computeVerticalScrollExtent()}.</p>* * @returnthe 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 intcomputeVerticalScrollExtent() {returngetHeight();}/** *

Compute the vertical range that the vertical scrollbar represents.

* *

The range is expressedinarbitrary units that must be the same as the units used by {@link#computeVerticalScrollExtent()} and* {@link#computeVerticalScrollOffset()}.</p>* * @returnthe total vertical range represented by the vertical scrollbar */protected intcomputeVerticalScrollRange() {returngetHeight();}


对于垂直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的绘制逻辑。所以在第6行,需要调用setWillNotDraw(false)打开ViewGroup绘制流程;

第16行,收到子view的滚动回调,调用awakenScrollBars()触发滚动条的绘制;

对于extent,直接使用缺省的extent,即联动容器的高度;

对于range,对所有子view的range进行求和,最后得到值即为联动容器的range;

对于offset,同样先对所有子view的offset进行求和,之后还需要加上联动容器自身的scrollY值,最终得到的值即为联动容器的offset。

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

5. 注意事项

联动容器执行fling操作时,借助OverScroller工具类完成的。代码如下:

private void parentFling(float velocityY) {

    // ... 省略了无关代码

    mScroller.fling(0, getScrollY(),

                0, (int) velocityY,

                0, 0,

                Integer.MIN_VALUE, Integer.MAX_VALUE);

    invalidate();

}

借助OverScroller.fling()方法完成联动容器的fling行为,这段代码在小米手机上运行联动会出现问题,mScroller.getCurrVelocity()一直是0。

原因是小米手机Rom重写了OverScroller,当fling()方法第三个参数传0时,OverScroller.mCurrVelocity一直为NaN,导致无法计算出正确剩余速度。

为了解决小米手机的问题,我们需要将第三个参数传个非0值,这里给1即可。

private void parentFling(float velocityY) {

    // ... 省略了无关代码

    mScroller.fling(0, getScrollY(),

                1, (int) velocityY,

                0, 0,

                Integer.MIN_VALUE, Integer.MAX_VALUE);

    invalidate();

}

6. 总结

多子view嵌套实现原理并不复杂,对手势处理的边界条件比较琐碎,需要来回调试完善,欢迎业内的朋友一起交流学习。

Sample地址: github.com/baiduapp-te…

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容