前端未来的主流技术方向有哪些?腾讯、京东、同城旅行等大厂都是怎么布局的?戳此了解 了解详情
写点什么

Android 多子 view 嵌套通用解决方案

2019 年 11 月 28 日

Android多子view嵌套通用解决方案

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 联动容器的实现代码做了开源,感兴趣的同学可以参考:https://github.com/baiduapp-tec/LinkageScrollLayout。


基于 google 的 NestedScrolling 实现多子 view 嵌套能节省不少开发量,故笔者对多子 view 嵌套的实现采用方案一。


3. 核心逻辑

3.1 Google 嵌套滑动机制

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



网上有很多关于 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 下的滚动情况。


第 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 的速度传递分为:


  1. 从联动容器向子 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 中的相关方法:


/** * <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 的绘制逻辑。所以在第 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 地址: https://github.com/baiduapp-tec/ELinkageScroll


本文转载自百度 App 技术。


原文链接:


https://mp.weixin.qq.com/s/I_MhjjGbYV5otC_f2QbEVg


2019 年 11 月 28 日 08:00559

评论

发布
暂无评论
发现更多内容

Linux中父进程为何要苦苦地知道子进程的死亡原因?

linux大本营

c++ Linux 后台开发 进程

架构师训练营第 1 期 - 第十一周总结

Todd-Lee

极客大学架构师训练营

【硬件篇之电源纹波噪声测试】

良知犹存

硬件

Gemini双子新约软件系统开发|Gemini双子新约APP开发

开發I852946OIIO

系统开发

一不小心,就入选Gartner魔力象限了

数据君

数据库

开源软件联盟PostgreSQL分会投稿指南

PostgreSQLChina

数据库 postgresql 软件 投稿

如何在高速发展中等一等老人 银行数字化服务显温度

CECBC区块链专委会

银行 养老服务

腾讯云区块链总经理李力:产业区块链的四大发展趋势

CECBC区块链专委会

区块链 大数据

为什么说区块链完全去中心化做不到且没有意义

CECBC区块链专委会

区块链 去中心化

字节总监首发1121道LeetCode算法刷题笔记(含答案)

Crud的程序员

程序员 面试 算法 字节 面试刷题

今天,我们和人民大学一起干了件大事!

数据君

数据库

架構師訓練營第 1 期 - 第 11 周作業

Panda

架構師訓練營第 1 期

通用软件快速开发平台对企业信息化的影响

雯雯写代码

软件 快速开发 企业信息化

第十一周课后练习

饭桶

人工智能应用实操:手把手教你用Python控制IoT智能硬件

智能物联实验室

物联网 IoT

架构师训练营第 1 期 - 第十一周作业

Todd-Lee

极客大学架构师训练营

大企软件系统问题多?归乡名企工程师:解决很简单,分分钟做个新系统

Philips

敏捷开发

架构师训练营第 1 期 -week12

习习

云图说|AI开发难!难!难!端云协同多模态AI开发套件你需要了解一下

华为云开发者社区

AI 分布式协同 开发

量化交易系统APP软件开发(现成)

开發I852946OIIO

系统开发

追忆

刘旭东

回忆 情绪

解析—MyBatis在SpringBoot中动态多数据源配置

比伯

Java 编程 程序员 架构 计算机

Reactor线程模型浅析

赖猫

c++ Linux 编程 reactor 编程语言

详解TCP IP网络协议栈底层原理到徒手实现

赖猫

c++ Linux 编程 程序 网络协议栈

每周学点 TARS——服务鉴权功能

TARS基金会

DevOps 后端 鉴权 TARS

我是因为这个才选择当程序员的,那么你呢?

Java架构师迁哥

如何利用小熊派获取MPU6050六轴原始数据

华为云开发者社区

物联网 IoT 小熊派

程序员的真实故事

Learun

敏捷开发

架構師訓練營第 1 期 - 第 11 周總結

Panda

架構師訓練營第 1 期

第11周学习总结

饭桶

Meet new Sentinel Go committers!

阿里巴巴云原生

开源 开发者 云原生 sentinel 中间件

技术为帆,纵横四海- Lazada技术东南亚探索和成长之旅

技术为帆,纵横四海- Lazada技术东南亚探索和成长之旅

Android多子view嵌套通用解决方案-InfoQ