拓展CollapsingToolbarLayout

前言

CollapsingToolbarLayout 是 android material deign 里一个十分优秀的组件,它可以用非常少的代码实现很漂亮的滑动效果。关于这个组件的用法,网上已经很多了,这里不再赘述。今天项目中有一个需求,大概是这个样子的:

out.gif

CollapsingToolbarLayout 只支持 Title 的折叠和滚动,不支持其他组件一起滚动,所以就有必要修改一下。

源码解析

CollapsingToolbarLayout 源码在 android.support.design.widget 包下,AndroidStudio里按住 Ctrl 键鼠标左键点进去就能看到,或者在线查看(比如 androidos.net.cn)。
那么首先是构造函数

    public CollapsingToolbarLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        ThemeUtils.checkAppCompatTheme(context);

        mCollapsingTextHelper = new CollapsingTextHelper(this);
       ...省略部分代码...
        TypedArray a = context.obtainStyledAttributes(attrs,
                R.styleable.CollapsingToolbarLayout, defStyleAttr,
                R.style.Widget_Design_CollapsingToolbar);

       ... 省略部分代码 ...

        a.recycle();

        setWillNotDraw(false);

       ViewCompat.setOnApplyWindowInsetsListener(this,
                new android.support.v4.view.OnApplyWindowInsetsListener() {
                    @Override
                    public WindowInsetsCompat onApplyWindowInsets(View v,
                            WindowInsetsCompat insets) {
                        return onWindowInsetChanged(insets);
                    }
                });
    }

这部分比较简单,就是解析 xml 中设置的属性,比如 expandedTitleMarginexpandedTitleTextAppearance 等等。
然后在 onAttachedToWindow方法中,发现注册了 AppbarLayout的监听器

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();

        // 添加 AppbarLayout 监听器
        final ViewParent parent = getParent();
        if (parent instanceof AppBarLayout) {
            // 从父 AppbarLayout 中获得 fitSystemStatus 属性
            ViewCompat.setFitsSystemWindows(this, ViewCompat.getFitsSystemWindows((View) parent));

            if (mOnOffsetChangedListener == null) {
                mOnOffsetChangedListener = new OffsetUpdateListener();
            }
            ((AppBarLayout) parent).addOnOffsetChangedListener(mOnOffsetChangedListener);

            // 请求适配到状态栏
            ViewCompat.requestApplyInsets(this);
        }
    }

相应地,onDetachedFromWindow 方法中解除了注册

    @Override
    protected void onDetachedFromWindow() {
        // 移除 AppbarLayout 监听器
        final ViewParent parent = getParent();
        if (mOnOffsetChangedListener != null && parent instanceof AppBarLayout) {
            AppBarLayout appBar = (AppBarLayout) parent;
            appBar.removeOnOffsetChangedListener(mOnOffsetChangedListener);
        }
       super.onDetachedFromWindow();
    }

接下来分析下,随着 AppbarLayout 的滚动,CollapsingToolbarLayout 做了些什么


        @Override
        public void onOffsetChanged(AppBarLayout layout, int verticalOffset) {
            mCurrentOffset = verticalOffset;

            final int insetTop = mLastInsets != null ? mLastInsets.getSystemWindowInsetTop() : 0;

            for (int i = 0, z = getChildCount(); i < z; i++) {
                final View child = getChildAt(i);
                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
                final ViewOffsetHelper offsetHelper = getViewOffsetHelper(child);

                // 根据子 view 的折叠模式(collapseMode)分别处理
                switch (lp.mCollapseMode) {
                    case LayoutParams.COLLAPSE_MODE_PIN:
                        // 别针模式(pin):会同时下移相同的偏移量,故在 y 方向上不动
                        offsetHelper.setTopAndBottomOffset(MathUtils.clamp(
                                -verticalOffset, 0, getMaxOffsetForPinChild(child)));
                        break;
                    case LayoutParams.COLLAPSE_MODE_PARALLAX:
                        // 视差模式(parallax):向下移一段小于 verticalOffset 的距离,
                        // 这个值取决于 mParallaxMulti 这个小数
                        offsetHelper.setTopAndBottomOffset(
                                Math.round(-verticalOffset * lp.mParallaxMult));
                        break;
                }
            }

            // 更新背景
            updateScrimVisibility();

            if (mStatusBarScrim != null && insetTop > 0) {
                ViewCompat.postInvalidateOnAnimation(CollapsingToolbarLayout.this);
            }

            // 更新 title 的位置和大小等
            final int expandRange = getHeight() - ViewCompat.getMinimumHeight(
                    CollapsingToolbarLayout.this) - insetTop;
            mCollapsingTextHelper.setExpansionFraction(
                    Math.abs(verticalOffset) / (float) expandRange);
        }

如果要引入新的属性的话,需要修改的部分就是 LayoutParamsOffsetUpdateListener 这两个类。这里本来打算继承自 CollapsingToolbarLayout 类,结果发现很多方法和域是private的,而且 要增加declare-styleable 的属性也很麻烦,没办法使用下下策,即把源码复制过来,在此基础上修改。当然了一些支持类也要拷贝过来,比如 ThemeUtilsViewGroupUtils等。

思路整理

到这里源码的大致思路分析地差不多了,接下来就是怎么增加和处理我们自己的属性。对于一个子 view,如果要跟着 AppbarLayout 一起滚动的话,首先要得到 AppBarLayout 展开时的位置,然后是折叠时的位置,这两个位置就像一次函数的两个端点。这样以 AppbarLayout 偏移量作为x轴,就能计算得到不同位置这个 view 的具体位置。举个例子,某子view 在拓展时的坐标是 (100, 280),折叠时的坐标是(150, 110),而 AppbarLayout 的折叠范围是300,当前折叠量为x,那么当前view的坐标就是((x/300 * (150-100)+100), (x/300 * (110-280) + 280))

在一般的情况下,当前折叠量可以从 onOffsetChanged() 的参数中获得,折叠范围可以按照官方已经算出来了,即

final int expandRange = getHeight() - ViewCompat.getMinimumHeight(
                    CollapsingToolbarLayout.this) - insetTop;

那么只剩下折叠时和展开时的两个坐标了。这两个坐标可以在 onLayout() 函数中计算出来。

定义 declared-style 属性

CollapsingToolbarLayout 给了我们很多属性可以自定义,如expandedTitleGravityexpandedTitleMarginStart 等等,用起来十分方便,然而只限于title。不如我们也可以仿照一下,增加几个常用的属性

<declare-styleable name="ScrollCollapsingLayout_Layout">
        <attr name="collapseMode">
            <enum name="none" value="0"/>
            <enum name="pin" value="1"/>
            <enum name="parallax" value="2"/>
            <enum name="scroll" value="3" />
        </attr>

        <attr name="collapseParallaxMultiplier" />

        <attr name="collapsedGravity">
            <flag name="top" value="0x30"/>
            <flag name="bottom" value="0x50"/>
            <flag name="left" value="0x03"/>
            <flag name="right" value="0x05"/>
            <flag name="center_vertical" value="0x10"/>
            <flag name="fill_vertical" value="0x70"/>
            <flag name="center_horizontal" value="0x01"/>
            <flag name="center" value="0x11"/>
            <flag name="start" value="0x00800003"/>
            <flag name="end" value="0x00800005"/>
        </attr>

        <attr name="collapsedMargin" format="dimension"/>
        <attr name="collapsedMarginStart" format="dimension"/>
        <attr name="collapsedMarginTop" format="dimension"/>
        <attr name="collapsedMarginEnd" format="dimension"/>
        <attr name="collapsedMarginBottom" format="dimension"/>
    </declare-styleable>

需要注意的是,collapseMode 是仿照 layout_collapseMode 来的,因为后者已经被定义了,没办法重新定义。之所以给了 collapsedGravity 接口而没有 expandedGravity,是考虑到 view 已经有了 layout_gravitylayout_margin等属性,干脆就直接拿来用了。

然后就是在 LayoutParams 的 LayoutParams(Context c, AttributeSet attrs) 中初始化变量。


        private int mCollapsedGravity;

        private int mCollapsedMarginStart;
        private int mCollapsedMarginTop;
        private int mCollapsedMarginEnd;
        private int mCollapsedMarginBottom;

        public LayoutParams(Context c, AttributeSet attrs) {
            super(c, attrs);

            TypedArray a = c.obtainStyledAttributes(attrs,
                    R.styleable.ScrollCollapsingLayout_Layout);

            mCollapseMode = a.getInt(R.styleable.
                    ScrollCollapsingLayout_Layout_collapseMode,
                    COLLAPSE_MODE_OFF);

            mParallaxMult = a.getFloat(R.styleable.
                    ScrollCollapsingLayout_Layout_collapseParallaxMultiplier,
                    DEFAULT_PARALLAX_MULTIPLIER);

            mCollapsedGravity = a.getInt(R.styleable.
                    ScrollCollapsingLayout_Layout_collapsedGravity,
                    Gravity.START|Gravity.CENTER_VERTICAL);

            int margin = a.getDimensionPixelSize(R.styleable.
                    ScrollCollapsingLayout_Layout_collapsedMargin, 0);
            mCollapsedMarginStart = a.getDimensionPixelSize(R.styleable.
                    ScrollCollapsingLayout_Layout_collapsedMarginStart, margin);
            mCollapsedMarginTop = a.getDimensionPixelSize(R.styleable.
                    ScrollCollapsingLayout_Layout_collapsedMarginTop, margin);
            mCollapsedMarginEnd = a.getDimensionPixelSize(R.styleable.
                    ScrollCollapsingLayout_Layout_collapsedMarginEnd, margin);
            mCollapsedMarginBottom = a.getDimensionPixelSize(R.styleable.
                    ScrollCollapsingLayout_Layout_collapsedMarginBottom, margin);

            a.recycle();
        }

接下来就是计算上面说的两个端点的位置。我们是放在 ScrollCollapsingLayoutonLayout() 方法中,因为此时所有的子view 已经测量完毕了。


    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        ... 省略部分代码 ...
        // 通知子 view 测量布局区域
        updateScrollChild(left, top, right, bottom);
    }

    private void updateScrollChild(int left, int top, int right, int bottom) {
        int collapsedLeft = left;
        int collapsedTop = top;
        int collapsedRight = right;
        int collapsedBottom = bottom;

        if (mToolbar != null) {
            collapsedLeft = mTmpRect.left;
            collapsedTop = mTmpRect.top;
            collapsedRight = mTmpRect.right;
            collapsedBottom = mTmpRect.bottom;
        }

        for (int i = 0, z = getChildCount(); i < z; i++) {
            View v = getChildAt(i);
            LayoutParams lp = (LayoutParams) v.getLayoutParams();
            if (lp.mCollapseMode == LayoutParams.COLLAPSE_MODE_SCROLL) {
                lp.setCollapsedBounds(collapsedLeft, collapsedTop,
                        collapsedRight, collapsedBottom);
                lp.setExpandedBounds(v.getLeft(), v.getTop(),
                        v.getRight(), v.getBottom());
                lp.recalculate();
            }
        }
    }

接下来就是在 LayoutParams 中具体计算


        void setCollapsedBounds(int l, int t, int r, int b) {
            collapsedRect.left = l;
            collapsedRect.top = t;
            collapsedRect.right = r;
            collapsedRect.bottom = b;
        }

        void setExpandedBounds(int l, int t, int r, int b) {
            expandedRect.left = l;
            expandedRect.top = t;
            expandedRect.right = r;
            expandedRect.bottom = b;
        }

        void recalculate() {

            boolean isRtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL;

            final int collapsedAbsGravity = Gravity.getAbsoluteGravity(
                    mCollapsedGravity, getLayoutDirection());

            switch (collapsedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) {
                case Gravity.BOTTOM:
                    collapsedRect.bottom -= mCollapsedMarginBottom;
                    collapsedRect.top = collapsedRect.bottom - height;
                    break;
                case Gravity.TOP:
                    collapsedRect.top += mCollapsedMarginTop;
                    collapsedRect.bottom = collapsedRect.top + height;
                    break;
                case Gravity.CENTER_VERTICAL:
                default:
                    collapsedRect.top = (collapsedRect.top + collapsedRect.bottom) /2
                            - height /2;
                    collapsedRect.bottom = collapsedRect.top + height;
                    break;
            }

            switch (collapsedAbsGravity & Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK) {
                case Gravity.RIGHT:
                    collapsedRect.right -= isRtl ? mCollapsedMarginStart : mCollapsedMarginEnd;
                    collapsedRect.left -= collapsedRect.right - width;
                    break;
                case Gravity.CENTER_HORIZONTAL:
                    collapsedRect.left = (collapsedRect.left + collapsedRect.right) /2
                            - width /2;
                    collapsedRect.right = collapsedRect.left + width;
                    break;
                case Gravity.LEFT:
                default:
                    collapsedRect.left += isRtl ? mCollapsedMarginEnd : mCollapsedMarginStart;
                    collapsedRect.right = collapsedRect.left + width;
                    break;
            }
        }

计算完成之后,接下来就是在 OffsetUpdateListener 中处理偏移量


        @Override
        public void onOffsetChanged(AppBarLayout layout, int verticalOffset) {
            ... 省略部分代码 ...
            final int expandRange = getHeight() - getMinimumHeight() - insetTop;
            final float percent = -1.0f * verticalOffset / expandRange;

                    ... 省略部分代码 ...
                    case LayoutParams.COLLAPSE_MODE_SCROLL:
                        offsetHelper.setLeftAndRightOffset((int)
                                (percent * (lp.collapsedRect.left - lp.expandedRect.left)));
                        // 这里要多加一个偏移量,把竖直方向纠正过来
                        offsetHelper.setTopAndBottomOffset(-verticalOffset + (int)
                                (percent * (lp.collapsedRect.top - lp.expandedRect.top)));
                        break;
                ... 省略部分代码 ...
            }
        }

效果检验

<android.support.design.widget.CoordinatorLayout>
    <android.support.design.widget.AppBarLayout>
        <cn.nlifew.scrollcollapsinglayout.widget.ScrollCollapsingLayout>

            <android.support.v7.widget.Toolbar
                ... />

            <ImageView
                android:id="@+id/activity_main_image"
                android:src="@drawable/ic_account_circle"
                android:layout_width="50dp"
                android:layout_height="50dp"
                android:layout_gravity="bottom|start"
                android:layout_marginStart="25dp"
                android:layout_marginBottom="15dp"
                app:collapseMode="scroll" />
        </cn.nlifew.scrollcollapsinglayout.widget.ScrollCollapsingLayout>
    </android.support.design.widget.AppBarLayout>
</android.support.design.widget.CoordinatorLayout>

效果的话大概就是如图上所示了

说明

  • 因为要用到CollapsingToolbar 的一些私有变量和方法,这里不是用的继承的方式,而是直接拷贝源码的方式,不建议这么做
  • 移动 view 的方式有很多种,这里有的是ViewOffsetHelper这个类,本质上还是 view.offsetTopAndBottomview.offsetLeftAndRight这两个方法,读者可自行修改。
  • 因为 AndroidStudio 本身的 bug,在xml中使用自定义属性时可能会提示错误Unexpected namespace prefix 'app' ...,但其实编译运行都没问题的,可以在 File-Settings-Editor-Inspections-Android-Lint-Correctness-Missing Android XML namespace去掉勾选

下载

项目已开源至 github

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

推荐阅读更多精彩内容