Android可持续滑动布局:ConsecutiveScrollerLayout

ConsecutiveScrollerLayout是我在GitHub开源的一个Android自定义滑动布局,它可以让多个滑动布局和普通控件在界面上像一个整体一样连续顺畅地滑动。

试想我们有这样一个需求,在一个界面上有轮播图、像九宫格一样的分类布局、几个样式不一样的列表,中间还夹杂着各种广告图和展示各类活动的布局,这样的设计在大型的app首页上非常常见。又比如像咨询类的文章详情页或者电商类的商品详情页这种一个WebView加上原生的评论列表、推荐列表和广告位。这种复杂的布局实现起来往往比较困难,而且对于页面的滑动流畅性和布局的显示效率要求较高。在以前我遇到这种复杂的布局,会使用我在Github开源的项目GroupedRecyclerViewAdapter 实现。当初设计GroupedRecyclerViewAdapter,是为了能让RecyclerView方便地实现二级列表、分组列表和在一个RecyclerView上显示不同的列表。由于GroupedRecyclerViewAdapter支持设置不同item类型的头部、尾部和子项,所有它能在一个RecyclerView上显示多种不同的布局和列表,也符合实现复杂布局的需求。但是由于GroupedRecyclerViewAdapter并非为这种复杂布局设计的,用它来实现这种布局,需要使用者在GroupedRecyclerViewAdapter的子类上管理好页面的数据和各种类型布局的显示逻辑,显得臃肿又麻烦。如果不把它整合在一个RecyclerView上,而是使用布局的嵌套实现,不仅严重影响布局的性能,而且解决滑动冲突也是个令人头疼的问题。尽管Google为了更好地解决滑动布局间的滑动冲突问题,在Android 5.0的时候推出了NestedScrolling机制,不过要自己来处理各种滑动问题,依然不是一件容易的事情。

无论多么复杂的页面,它都是由一个个小控件组成的。如果能有一个布局容器帮我们处理好布局内所有的子View的滑动问题,使得无论是普通控件还是滑动布局,在这个容器里都能像一个整体一样滑动,滑动它就好像是滑动一个普通的ScrollView一样。那么我们是否就可以不用再关心布局的滑动冲突和滑动性能问题。无论多么复杂的布局,我们都只需要考虑布局的各个小部分该用什么控件就用什么控件,任何复杂的布局都将不再复杂。ConsecutiveScrollerLayout正是基于这样的需求而设计的。

设计思路

在构思ConsecutiveScrollerLayout时,我是考虑使用NestedScrolling机制实现的,但是后来我放弃了这种方案,主要原因有二:

1、NestedScrolling机制主要是协调父布局和子布局的滑动冲突,分发滑动事件,至于布局的滑动是由它们自己各自完成的。这不符合我希望把ConsecutiveScrollerLayout的所有子View当作一个滑动整体的构思,我希望把子View的内容视作是ConsecutiveScrollerLayout内容的一部分,无论是ConsecutiveScrollerLayout自身还是它的子View,都由ConsecutiveScrollerLayout来统一处理滑动事件。

2、NestedScrolling机制要求父布局实现NestedScrollingParent接口,所有可滑动的子View实现NestedScrollingChild接口。而我希望ConsecutiveScrollerLayout在使用上尽可能的没有限制,任何View放进它都可以很好的工作,而且子View无需关心它是怎么滑动的。

否决了NestedScrolling机制后,我尝试从View的内容滑动的相关方法来寻找突破点。我发现Android几乎所有的View都是通过scrollBy() -> scrollTo()方法滑动View的内容,而且大部分的滑动布局也是直接或者间接调用这个方法来实现滑动的。所以这两个方法是处理布局滑动的入口,通过重写这两个方法可以重新定义布局的滑动逻辑。

具体的思路是通过拦截可滑动的子view的滑动事件,使它无法自己滑动,而把事件统一交由ConsecutiveScrollerLayout处理,ConsecutiveScrollerLayout重写scrollBy()、scrollTo()方法,在scrollTo()方法中通过计算分发滑动的偏移量,决定是由自身还是具体的子View消费滑动的距离,调用自身的super.scrollTo()和子View的scrollBy()来滑动自身和子View的内容。

说了这么多,下面让我们通过代码,分析一下ConsecutiveScrollerLayout是如何实现的。下面给出的代码是源码的一些主要片段,删除了一些与设计思路和主要流程无关的处理细节,便于大家更好的理解它的设计和实现原理。

效果图

在开始前,先让大家看一下ConsecutiveScrollerLayout实现的效果。

sample.gif
sticky.gif

onMeasure、onLayout

ConsecutiveScrollerLayout继承自ViewGroup,一个自定义布局总是免不了重写onMeasure、onLayout来测量和定位子View。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        mScrollRange = 0;
        int childTop = t + getPaddingTop();
        int left = l + getPaddingLeft();

        List<View> children = getNonGoneChildren();
        int count = children.size();
        for (int i = 0; i < count; i++) {
            View child = children.get(i);
            int bottom = childTop + child.getMeasuredHeight();
            child.layout(left, childTop, left + child.getMeasuredWidth(), bottom);
            childTop = bottom;
            // 联动容器可滚动最大距离
            mScrollRange += child.getHeight();
        }
        // 联动容器可滚动range
        mScrollRange -= getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
    }

        /**
     * 返回所有的非GONE子View
     */
    private List<View> getNonGoneChildren() {
        List<View> children = new ArrayList<>();
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() != GONE) {
                children.add(child);
            }
        }
        return children;
    }

onMeasured的逻辑很简单,遍历测量子vew即可。onLayout是把子view从上到下排列,就像一个垂直的LinearLayout一样。getNonGoneChildren()方法过滤掉隐藏的子view,隐藏的子view不参与布局。上面的mScrollRange变量是布局自身可滑动的范围,它等于所有子view的高度减去布局自身的内容显示高度。在后面,它将用于计算布局的滑动偏移和边距限制。

拦截滑动事件

前面说过ConsecutiveScrollerLayout会拦截它的可滑动的子view的滑动事件,由自己来处理所有的滑动。下面是它拦截事件的实现。

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_MOVE) {
            // 需要拦截事件
            if (isIntercept(ev)) {
                return true;
            }
        }
        return super.onInterceptTouchEvent(ev);
    }

如果是滑动事件(ACTION_MOVE),判断是否需要拦截事件,拦截则直接返回true,让事件交由ConsecutiveScrollerLayout的onTouchEvent方法处理。判断是否需要拦截的关键是isIntercept(ev)方法。

    /**
     * 判断是否需要拦截事件
     */
    private boolean isIntercept(MotionEvent ev) {
            // 根据触摸点获取当前触摸的子view
        View target = getTouchTarget((int) ev.getRawX(), (int) ev.getRawY());

        if (target != null) {
          // 判断子view是否允许父布局拦截事件
            ViewGroup.LayoutParams lp = target.getLayoutParams();
            if (lp instanceof LayoutParams) {
                if (!((LayoutParams) lp).isConsecutive) {
                    return false;
                }
            }

          // 判断子view是否可以垂直滑动
            if (ScrollUtils.canScrollVertically(target)) {
                return true;
            }
        }

        return false;
    }

public class ScrollUtils {

  static boolean canScrollVertically(View view) {
        return canScrollVertically(view, 1) || canScrollVertically(view, -1);
    }
  
  static boolean canScrollVertically(View view, int direction) {
        return view.canScrollVertically(direction);
    }
}

判断是否需要拦截事件,主要是通过判断触摸的子view是否可以垂直滑动,如果可以垂直滑动,就拦截事件,让事件由ConsecutiveScrollerLayout自己处理。如果不是,就不拦截,一般不能滑动的view不会消费滑动事件,所以事件最终会由ConsecutiveScrollerLayout所消费。之所以不直接拦截,是为了能让子view尽可能的获得事件处理和分发给下面的view的机会。

这里有一个isConsecutive的LayoutParams属性,它是ConsecutiveScrollerLayout.LayoutParams的自定义属性,用于表示一个子view是否允许ConsecutiveScrollerLayout拦截它的滑动事件,默认为true。如果把它设置为false,父布局将不会拦截这个子view的事件,而是完全交由子view处理。这使得子view有了自己处理滑动事件的机会和分发事件的主动权。这对于实现一些需要实现局部区域内滑动的特殊需求十分有用。我在GitHub中提供的demo和使用介绍中对isConsecutive有详细的说明,在这就不做过多介绍了。

滑动处理

把事件拦截后,就要在onTouchEvent方法中处理滑动事件。

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                    // 记录触摸点
                mTouchY = (int) ev.getY();
                    // 追踪滑动速度
                initOrResetVelocityTracker();
                mVelocityTracker.addMovement(ev);
                break;
            case MotionEvent.ACTION_MOVE:
                if (mTouchY == 0) {
                    mTouchY = (int) ev.getY();
                    return true;
                }
                int y = (int) ev.getY();
                int dy = y - mTouchY;
                mTouchY = y;
                    // 滑动布局
                scrollBy(0, -dy);
                                // 追踪滑动速度
                initVelocityTrackerIfNotExists();
                mVelocityTracker.addMovement(ev);
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                mTouchY = 0;

                if (mVelocityTracker != null) {
                    // 处理惯性滑动
                    mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                    int yVelocity = (int) mVelocityTracker.getYVelocity();
                    recycleVelocityTracker();
                    fling(-yVelocity);
                }
                break;
        }
        return true;
    }

        // 惯性滑动
    private void fling(int velocityY) {
        if (Math.abs(velocityY) > mMinimumVelocity) {
            mScroller.fling(0, mOwnScrollY,
                    1, velocityY,
                    0, 0,
                    Integer.MIN_VALUE, Integer.MAX_VALUE);
            invalidate();
        }
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            int curY = mScroller.getCurrY();
            // 滑动布局
            dispatchScroll(curY);
            invalidate();
        }
    }

onTouchEvent方法的逻辑非常简单,就是根据手指的滑动距离通过view的scrollBy方法滑动布局内容,同时通过VelocityTracker追踪手指的滑动速度,使用Scroller配合computeScroll()方法实现惯性滑动。

滑动距离的分发

在处理惯性滑动是时候,我们调用了dispatchScroll()方法,这个方法是整个ConsecutiveScrollerLayout的核心,它决定了应该由谁来消费这次滑动,应该滑动那个布局。其实ConsecutiveScrollerLayout的scrollBy()和scrollTo()方法最终都是调用它来处理滑动的分发的。

    @Override
    public void scrollBy(int x, int y) {
        scrollTo(0, mOwnScrollY + y);
    }

    @Override
    public void scrollTo(int x, int y) {
        //所有的scroll操作都交由dispatchScroll()来分发处理
        dispatchScroll(y);
    }

        private void dispatchScroll(int y) {
        int offset = y - mOwnScrollY;
        if (mOwnScrollY < y) {
            // 向上滑动
            scrollUp(offset);
        } else if (mOwnScrollY > y) {
            // 向下滑动
            scrollDown(offset);
        }
    }

这里有个mOwnScrollY属性,是用于记录ConsecutiveScrollerLayout的整体滑动距离的,相当于View的mScrollY属性。

dispatchScroll()方法把滑动分成向上和向下两部分处理。让我们先看向上滑动部分的处理。

    private void scrollUp(int offset) {
        int scrollOffset = 0;  // 消费的滑动记录
        int remainder = offset; // 未消费的滑动距离
        do {
            scrollOffset = 0;
            // 是否滑动到底部
            if (!isScrollBottom()) {
                // 找到当前显示的第一个View
                View firstVisibleView = findFirstVisibleView();
                if (firstVisibleView != null) {
                    awakenScrollBars();
                    // 获取View滑动到自身底部的偏移量
                    int bottomOffset = ScrollUtils.getScrollBottomOffset(firstVisibleView);
                    if (bottomOffset > 0) {
                        // 如果bottomOffset大于0,表示这个view还没有滑动到自身的底部,那么就由这个view来消费这次的滑动距离。
                        int childOldScrollY = ScrollUtils.computeVerticalScrollOffset(firstVisibleView);
                        // 计算需要滑动的距离
                        scrollOffset = Math.min(remainder, bottomOffset);
                        // 滑动子view
                        scrollChild(firstVisibleView, scrollOffset);
                        // 计算真正的滑动距离
                        scrollOffset = ScrollUtils.computeVerticalScrollOffset(firstVisibleView) - childOldScrollY;
                    } else {
                        // 如果子view已经滑动到自身的底部,就由父布局消费滑动距离,直到把这个子view滑出屏幕
                        int selfOldScrollY = getScrollY();
                        // 计算需要滑动的距离
                        scrollOffset = Math.min(remainder,
                                firstVisibleView.getBottom() - getPaddingTop() - getScrollY());
                        // 滑动父布局
                        scrollSelf(getScrollY() + scrollOffset);
                        // 计算真正的滑动距离
                        scrollOffset = getScrollY() - selfOldScrollY;
                    }
                    // 计算消费的滑动距离,如果还没有消费完,就继续循环消费。
                    mOwnScrollY += scrollOffset;
                    remainder = remainder - scrollOffset;
                }
            }
        } while (scrollOffset > 0 && remainder > 0);
    }

    public boolean isScrollBottom() {
        List<View> children = getNonGoneChildren();
        if (children.size() > 0) {
            View child = children.get(children.size() - 1);
            return getScrollY() >= mScrollRange && !child.canScrollVertically(1);
        }
        return true;
    }

    public View findFirstVisibleView() {
        int offset = getScrollY() + getPaddingTop();
        List<View> children = getNonGoneChildren();
        int count = children.size();
        for (int i = 0; i < count; i++) {
            View child = children.get(i);
            if (child.getTop() <= offset && child.getBottom() > offset) {
                return child;
            }
        }
        return null;
    }

    private void scrollSelf(int y) {
        int scrollY = y;

        // 边界检测
        if (scrollY < 0) {
            scrollY = 0;
        } else if (scrollY > mScrollRange) {
            scrollY = mScrollRange;
        }
        super.scrollTo(0, scrollY);
    }

    private void scrollChild(View child, int y) {
        child.scrollBy(0, y);
    }

向上滑动的处理逻辑是,先找到当前显示的第一个子view,判断它的内容是否已经滑动到它的底部,如果没有,则由它来消费滑动距离。如果已经滑动到它的底部,则由ConsecutiveScrollerLayout来消费滑动距离,直到把这个子view滑出屏幕。这样下一次获取显示的第一个view就是它的下一个view了,重复以上的操作,直到把ConsecutiveScrollerLayout和所有的子view都滑动到底部,这样就整体都滑动到底部了。

这里使用了一个while循环操作,这样做是因为一次滑动距离,可能会由多个对象来消费,比如需要滑动50px的距离,但是当前显示的第一个子view还需要10px滑动到自己的底部,那么这个子view会消费10px的距离,剩下40px的距离就要进行下一次的分发,找到需要消费它的对象,以此类推。

向下滑动的处理跟向上滑动是一摸一样的,只是判断的对象和滑动的方向不同。

    private void scrollDown(int offset) {
        int scrollOffset = 0;  // 消费的滑动记录
        int remainder = offset;  // 未消费的滑动距离
        do {
            scrollOffset = 0;
            // 是否滑动到顶部
            if (!isScrollTop()) {
                // 找到当前显示的最后一个View
                View lastVisibleView = findLastVisibleView();
                if (lastVisibleView != null) {
                    awakenScrollBars();
                    // 获取View滑动到自身顶部的偏移量
                    int childScrollOffset = ScrollUtils.getScrollTopOffset(lastVisibleView);
                    if (childScrollOffset < 0) {
                        // 如果childScrollOffset大于0,表示这个view还没有滑动到自身的顶部,那么就由这个view来消费这次的滑动距离。
                        int childOldScrollY = ScrollUtils.computeVerticalScrollOffset(lastVisibleView);
                        // 计算需要滑动的距离
                        scrollOffset = Math.max(remainder, childScrollOffset);
                        // 滑动子view
                        scrollChild(lastVisibleView, scrollOffset);
                        // 计算真正的滑动距离
                        scrollOffset = ScrollUtils.computeVerticalScrollOffset(lastVisibleView) - childOldScrollY;
                    } else {
                        // 如果子view已经滑动到自身的顶部,就由父布局消费滑动距离,直到把这个子view完全滑动进屏幕
                        int scrollY = getScrollY();
                        // 计算需要滑动的距离
                        scrollOffset = Math.max(remainder,
                                lastVisibleView.getTop() + getPaddingBottom() - scrollY - getHeight());
                        // 滑动父布局
                        scrollSelf(scrollY + scrollOffset);
                        // 计算真正的滑动距离
                        scrollOffset = getScrollY() - scrollY;
                    }
                    // 计算消费的滑动距离,如果还没有消费完,就继续循环消费。
                    mOwnScrollY += scrollOffset;
                    remainder = remainder - scrollOffset;
                }
            }
        } while (scrollOffset < 0 && remainder < 0);
    }

public boolean isScrollTop() {
        List<View> children = getNonGoneChildren();
        if (children.size() > 0) {
            View child = children.get(0);
            return getScrollY() <= 0 && !child.canScrollVertically(-1);
        }
        return true;
    }

public View findLastVisibleView() {
        int offset = getHeight() - getPaddingBottom() + getScrollY();
        List<View> children = getNonGoneChildren();
        int count = children.size();
        for (int i = 0; i < count; i++) {
            View child = children.get(i);
            if (child.getTop() < offset && child.getBottom() >= offset) {
                return child;
            }
        }
        return null;
    }

到这里,关于ConsecutiveScrollerLayout到实现思路和核心代码就分析完了。由于篇幅问题,我把对布局吸顶功能的分析写了另一篇文章:Android滑动布局ConsecutiveScrollerLayout实现布局吸顶功能

另外我还写了一篇文章是专门介绍ConsecutiveScrollerLayout的使用的,有兴趣的朋友可以看一下:Android持续滑动布局ConsecutiveScrollerLayout的使用

下面给出ConsecutiveScrollerLayout到项目地址,如果你喜欢我的作品,或者这个布局对你有所帮助,请给我点个star呗!

https://github.com/donkingliang/ConsecutiveScroller

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

推荐阅读更多精彩内容

  • 第一章:Activity生命周期和启动模式 Activity关闭时会调用onPause()和onStop(),如果...
    loneyzhou阅读 872评论 0 2
  • 开发中,为了增加更多炫丽的效果,我们经常在应用中添加滑动效果,今天就来分析一下 View 中滑动效果的实现原理以及...
    Ad大成阅读 331评论 0 2
  • 一个社会在发展,如果精神跟不上时代的步伐,社会发展了又有什么用? 从今天起,正式连载故乡系列,我带你们去我的家乡看...
    怀侠阅读 600评论 0 8
  • 村里文江他们弄的稻田蟹成熟了,这几天开始放钓了,九点多钟带着烁妈和儿子回村钓蟹,中午就在蟹坑旁边三五好友涮这火锅吃...
    易如人生阅读 114评论 0 0
  • 工作~ 城建图纸整理,并画家家悦图纸 学习~ 1.本周读书《委婉说话的艺术》并画思维导图 2.每天一篇普通话练习 ...
    迎风奔跑2021阅读 151评论 0 0