Day28-find知乎CoordinatorLayout的实现

  • 本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

tips:
CoordinatorLayout(协调员布局)能实现一种类似协调视图之间改变效果的布局

文章分为三部分

  1. CoordinatorLayout 概念的简介
  2. 仿写知乎自定义简单behavior.(非gif演示)
  3. 完整的拆包分析知乎和仿写知乎包括 MainActivity, 首页回答的 ListFragment, 和点击首页回答打开的回答详情的 DetailFragment. (gif演示的实现)

比较啰嗦, 建议阅读时间9分钟.
可以直接跳到代码部分

Demo地址

FindZhihu.gif

「协调员」CoordinatorLayout

「协调」概念

  1. 铺垫: 正常的事件分发传递, 如果某一层 View 消费掉了 Down事件. 之后的 MOVE, UP 事件都是传递到这一层, 无法直接实现子 view 和其他子 view的「协调」. 于是谷歌在15年的Design
    SupportLibrary包 Revision 22.2.0 加入了 CoordinatorLayout.
  2. 作为「协调员」的核心外部控件 CoordinatorLayout: 是实现了 NestedScrollingParent 接口的 ViewGroup. 根据谷歌的描述, 它作为一个顶级布局, 同时作为子视图交互的容器 FrameLayout.
    那么它的子视图有哪些呢?
    • 被依赖子View, 即可滚动的view, 实现 NestedScrollingChild 的子 view .(RecyclerView已实现)
    • 依赖子View, 如 AppbarLayout(随着滑动的进行而跟着操作的view)

而依赖子view 依靠 behavior 控制. 谷歌对于behavior的描述是: CoordinatorLayout的子view的交互行为插件, 一个 behavior 可以实现一个或者多个交互

Behavior 里的方法

分类1: view 监听另一个 view 的状态变化, 比如大小, 位置, 显示.

  1. layoutDependsOn 用来确定子View是否有另一个同级的View作为布局从属
  2. onDependentViewChanged 响应被依赖子View的变化

分类2: view 监听 CoordinatorLayout 里的滑动状态

  1. onStartNestedScroll 我们要不要关心, 要关心什么样的滑动
  2. onNestedPreScroll 在嵌套滑动进行时,对象消费滚动距离前回调(使用的最频繁)

「协调」Demo-简单Behavior链接


下面我们开始仿造知乎的详情页来自定义一个 Behavior

知乎Behavior归纳

1. MainActivity 里的 Listfragment(首页)

  1. 滑动一定距离就隐藏
  2. 触发效果后, 手不松, 在新的位置重置触发第1步所需的距离
  3. 和点击无关
    如果尝试过默认的behavior就会发现很像系统默认的 behavior. app:layout_behavior="@string/appbar_scrolling_view_behavior"

2. 详情 DetailFragment

  1. 文件内容够长才开启滑动
  2. 刚进来时底view显示
  3. 快速滑动才显示/隐藏view
  4. 底view隐藏时, 单击显示/隐藏顶和底view.
  5. 下拉到底放出顶和底view.

3. MainActivity 的底部 TabLayout 再分析

  1. 如果极慢的滑动会发现, 对于同一个动画效果, 底部 TabLayout先于顶部 Toolbar 执行, 可以得出app第一页所看到的顶部和底部不是一个behavior控制的

我们首先用behavior来写一个仿知乎的效果

Demo-自定义Behavior, 仿知乎布局

  1. 创建 CoordinatorLayout 布局

    <CoordinatorLayout>
          <RecyclerView/>            
          <LinearLayout
            app:layout_behavior="@string/my_behavior"
            />          
    </CoordinatorLayout>
    

    如果一定要listview, 请判断版本 api21 后再setNestedScrollingEnabled
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { listView.setNestedScrollingEnabled(true); }

  2. 自定义底部隐藏的动画

    public class MyBehaviorAnim {
    
        private View mBottomView;
        private float mOriginalY;
    
        public BottomBehaviorAnim(View bottomView) {
            mBottomView = bottomView;
            mOriginalY = mBottomView.getY();
        }
    
    
        public void show() {
            ValueAnimator animator = ValueAnimator.ofFloat(mBottomView.getY(), mOriginalY);
            animator.setDuration(400);
            animator.setInterpolator(new LinearOutSlowInInterpolator());
            animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator valueAnimator) {
                    mBottomView.setY((Float) valueAnimator.getAnimatedValue());
                }
            });
            animator.start();
        }
    
    
        public void hide() {
            ValueAnimator animator = ValueAnimator.ofFloat(mBottomView.getY(), mOriginalY + mBottomView.getHeight());
            animator.setDuration(400);
            animator.setInterpolator(new LinearOutSlowInInterpolator());
            animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator valueAnimator) {
                    mBottomView.setY((Float) valueAnimator.getAnimatedValue());
                }
            });
            animator.start();
        }
    }
    
  1. 自定义底部隐藏的Behavior

    • 判断手势
    • 计算距离
    • 触发动画
    public class MyBehavior extends CoordinatorLayout.Behavior<View> {
    
        protected BottomBehaviorAnim mBottomAnim;
        private boolean isHide;
        private boolean canScroll = true;
        private int mTotalScrollY;
        protected boolean isInit = true; //防止new Anim导致的parent 和child坐标变化
    
        private int mDuration = 400;
        private Interpolator mInterpolator = new LinearOutSlowInInterpolator();
        private int minScrollY = 5;//触发滑动动画最小距离
        private int scrollYDistance = 40;//设置最小滑动距离
    
        //1. 必须重写两个参数的构造方法, 因为behavior的实例化�是反射这个构造方法实现的
        public BottomBehavior(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
    
        //2. 关心谁
        @Override
        public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
            return super.layoutDependsOn(parent, child, dependency);
        }
    
        /**
         * 触发滑动嵌套滚动之前调用的方法
         *
         * @param coordinatorLayout coordinatorLayout父布局
         * @param child             使用Behavior的子View
         * @param target            触发滑动嵌套的View(实现NestedScrollingChild接口)
         * @param dx                滑动的X轴距离
         * @param dy                滑动的Y轴距离
         * @param consumed          父布局消费的滑动距离,consumed[0]和consumed[1]代表X和Y方向父布局消费的距离,默认为0
         */
        @Override
        public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, View child, View target,
                                      int dx, int dy, int[] consumed) {
            super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed);
        }
    
        /**
         * 滑动嵌套滚动时触发的方法
         *
         * @param coordinatorLayout coordinatorLayout父布局
         * @param child             使用Behavior的子View
         * @param target            触发滑动嵌套的View
         * @param dxConsumed        TargetView消费的X轴距离
         * @param dyConsumed        TargetView消费的Y轴距离
         * @param dxUnconsumed      未被TargetView消费的X轴距离
         * @param dyUnconsumed      未被TargetView消费的Y轴距离(如RecyclerView已经到达顶部或底部,而用户继续滑动,此时dyUnconsumed的值不为0,可处理一些越界事件)
         */
        @Override
        public void onNestedScroll(CoordinatorLayout coordinatorLayout, View child, View target,
                                   int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
            super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
            if (canScroll) {
                mTotalScrollY += dyConsumed;
                if (Math.abs(dyConsumed) > minScrollY || Math.abs(mTotalScrollY) > scrollYDistance) {
                    if (dyConsumed < 0) {
                        if (isHide) {
                            mBottomAnim.show();
                            isHide = false;
                        }
                    } else if (dyConsumed > 0) {
                        if (!isHide) {
                            mBottomAnim.hide();
                            isHide = true;
                        }
                    }
                    mTotalScrollY = 0;
                }
            }
        }
    
        @Override
        public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int nestedScrollAxes) {
            if (isInit) {
                mBottomAnim = new BottomBehaviorAnim(child);
                isInit = false;
            }
            return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
        }
    
        @Override
        public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, View child, final View target) {
            super.onStopNestedScroll(coordinatorLayout, child, target);
        }
    
    }
    
  2. 关联xml和behavior
    先在 string 里添加 behavior, 再在依赖子 view 中添加

    <resources>
        <string name="my_behavior">com.clickdemo.Behavior.MyBehavior</string>
    </resources>
    
    <LinearLayout
      app:layout_behavior="@string/my_behavior"
      />    
    

思考: 自定义behavior确实能做到类似知乎的隐藏 TabView 的效果, 但是知乎真的是这么实现的吗? Activity 和 Fragment 都用上了 CoordinatorLayout?

知乎

知乎拆包静态分析.

  1. 准备
    • 分析对象: 知乎的首页和回答详情页.
    • 材料:
      • 拆包拿到的xml等文件.(4.1.8包来源于酷安app端的历史版本)
      • Luyten (查看.jar文件里的混淆后的java代码)
      • Hierarchy View (AndroidStudio视图查看工具)
    • 前提: 拆包拿到的activity_main.xml中可以看到, 底部的 com.zhihu.android.base.widget.ZHTabLayout 用到了 layout_behavior.
    <?xml version="1.0" encoding="utf-8"?>
    <com.zhihu.android.app.ui.widget.ZHInsetsFrameLayout android:id="@id/content_container" android:tag="layout/activity_main_0" android:layout_width="fill_parent" android:layout_height="fill_parent"
      xmlns:android="http://schemas.android.com/apk/res/android" xmlns:zhihu="http://schemas.android.com/apk/res-auto">
        <com.zhihu.android.app.ui.widget.reveal.widget.RevealFrameLayout android:background="?zhihu.background.window" android:layout_width="fill_parent" android:layout_height="fill_parent">
            <android.support.design.widget.CoordinatorLayout android:id="@id/coordinator_layout" android:layout_width="fill_parent" android:layout_height="fill_parent">
                <com.zhihu.android.base.widget.NonSwipeableViewPager android:id="@id/main_pager" android:layout_width="fill_parent" android:layout_height="fill_parent" />
                <FrameLayout android:id="@android:id/content" android:layout_width="fill_parent" android:layout_height="fill_parent" zhihu:layout_behavior="com.zhihu.android.base.widget.SnackBarBehavior" zhihu:layout_anchorGravity="end|center|bottom" />
                <com.zhihu.android.base.widget.ZHTabLayout android:layout_gravity="end|bottom|center" android:id="@id/main_tab" android:background="?zhihu.background.navigation.tab.bottom" android:layout_width="fill_parent" android:layout_height="@dimen/bottom_navigation_height" zhihu:layout_behavior="com.zhihu.android.base.widget.FooterBehavior" zhihu:layout_anchorGravity="end|center|bottom" zhihu:tabIndicatorColor="@android:color/transparent" zhihu:tabGravity="fill" />
            </android.support.design.widget.CoordinatorLayout>
        </com.zhihu.android.app.ui.widget.reveal.widget.RevealFrameLayout>
        <com.zhihu.android.base.widget.ZHFrameLayout android:id="@id/overlay_container" android:layout_width="fill_parent" android:layout_height="fill_parent" /
    </com.zhihu.android.app.ui.widget.ZHInsetsFrameLayout>
    
    
    • 推测: MainActivity 包着四个Fragment, 底部依靠「tabLayout」切换, 然后进入详情页把内容都换一遍, 然后每一页一个 CoordinatorLayout?
    • 目的: 查看知乎Fragment页的behavior如何实现的 拿到详情和首页Fragment的xml和java文件
  2. 用 Hierarchy View 拿到 view 的 id
    模拟器打开app, 进入首页的一个回答下, 用 Hierarchy View (在Android Studio自带的tool -> Android Device Monitor) 发现是 CoordinatorLayout 包着 NonSwipeableViewPager + +ZHFrameLayout + ZHTabLayout , 之后进入详情页也是替换了 NonSwipeableViewPager 里的 FrameLayout.
    image

    activity_main的效果如上图, 此时的FrameLayout显示的是首页ListFragment
    image

    上图是FrameLayout显示的是回答详情DetailFragment

疑问1: 等等, 只有一个 CoordinatorLayout?
之前猜的是有多个CoordinatorLayout啊?

常规使用下, CoordinatorLayout 的子view的子view,只能跟着上一级view整体实现协调, 即使AppBarLayout, 协调的效果也是固定的几种

所以知乎 Fragment 里底部的view和头部的toolbar是不依赖 CoordinatorLayout 进行协调的?
还是先找到Activity和Fragment.java文件吧

  1. 根据 view 的 id 来找在哪些 xml 用到了它
    接下来我们用之前反编译好的资源开始寻找, 根据第1步, 我们看到了一个看似根布局的view, FrameInterceptLayout. 那就在res/layout里搜索下. 果然用到 FrameInterceptLayout 的地方不是很多. 而这 fragment_pager 和fragment_pager_2 看起来很可疑.

    image

  2. 根据 xml 的 id 来找在哪些 java 文件里用到了它
    那接下来看下哪些地方用到了「fragment_pager」, 通过Android逆向之旅 可以得出: apk被反编译后会产生一个至关重要的 public.xml 文件, 就在res/values/public.xml下, 打开后搜一下, 哈, 找到你了, 面码, 0x7f0400be0x7f0400bf.

    image

    但是这是俩 十六进制 的东西啊. 对, Android逆向之旅里也告诉我们怎么看 0x7f0400be:

    这里可以看到,一个id字段,都有对应的类型,名称,和id值的
    而这里的id值是一个整型值,8个字节;由三部分组成的:
    PackageId+TypeId+EntryId
    PackageId:是包的Id值,Android中如果是第三方应用的话,这个值默认就是0x7F,系统应用的话就是0x01,具体我们可以后面看aapt源码得知,他占用两个字节。
    TypeId:是资源的类型Id值,一般Android中有这几个类型:attr,drawable,layout,dimen,string,style等,而且这些类型的值是从1开始逐渐递增的,而且顺序不能改变,attr=0x01,drawable=0x02….他占用两个字节。
    EntryId:是在具体的类型下资源实体的id值,从0开始,依次递增,他占用四个字节。

    那就用计算器转换一下, 拿到十进制的 2130968766 和 2130968767
    然后打开Luyten(替代JD_GUI, 有些文件JD_GUI打开后是一片空白). 打开一个jar文件, 先搜索一下 2130968766, 找到了.


    image

    看到databinding感叹下, 原来知乎早就上了databinding.

    不过我们要的是下面这个com/zhihu/app/ui/fragment/b/i.class...
    找到了这句话

    public View a(final LayoutInflater layoutInflater, final ViewGroup viewGroup, final Bundle bundle) {
        this.b = android.databinding.e.a(layoutInflater, 2130968766, viewGroup, false);
        return this.b.h();
    }
    

    可以猜到这句话就是 fragment 的 onCreateView 啦
    这就拿到了主页Fragment的java文件和xml文件了.
    同理拿到了 详情页的ZHObservableWebView, 首页列表的ZHRecyclerView extends ObservableRecyclerView. 这个频繁出现的ObservableXXXView 根据包名, 可以找到这个项目ksoichiro/Android-ObservableScrollView

当然了, 如果在第1步的HierarchyView里能找到特殊的id的view可以直接搜索, 比如详情页可以靠fragment_paging_layout搜到.详情页的view是fragment_paging.xml, 如下图


image

破案了! 结合疑问1, 可以得出:
知乎的 Fragment 们的协调不是通过 CoordinatorLayout 的 behavior, 而是使用了观察者模式的开源项目.

总结一下(省略了过程):
  1. MainActivity 的底部 TabLayout 使用的是 CoordinatorLayout 的 layout_behavior
  2. 首页ListFragment 的顶部toolbar可能是 ZHObservableRecyclerView 也可能是 FrameInterceptLayout控制, xml文件是 fragment_paging, Java 文件在
  3. 首页详情DetailFragment 的顶部和底部是 ZHObservableWebView 控制 xml文件是 fragment_pager, Java 文件在com/zhihu/app/ui/fragment/b/i.class

为啥首页详情页这么肯定是 ZHObservableWebView 控制的呢, 因为在Luyten里查看到的知乎源码中重写了onScrollChange 方法, 多返回了int l, t, oldl, oldt 四个参数, 而我自己在写的时候发现也需要这四个参数才方便实现结果.

大致效果如图:


image

具体Demo 地址

核心部分原理描述:
从 MainActivity 的 ListFragment 切换到 DetailFragment 时, 把 ListFragment 的 toolbar 和 MainActivity 的 tabLayout 恢复原样(先GONE掉).
从 DetailFragment 切换回 ListFragment 时, 直接VISIBLE 底部tabLayout.

弯路

  1. 如何只在快速下滑时才触发 behavior 的 onNestedScroll(仿知乎详情页)
    • 原思路: 通过event监听手势Y轴速度. 传给behavior. 又因为「协调」时 onTouchEvent 无法接收到事件, behavior不属于ViewGroup, 无法在 behavior 里调用 dispatchTouchEvent, 只能在Activity里调, 所以采用 activity 监听的dispatchTouchEvent里event的 Y 轴速度, 再回调给behavior.
    • 现思路: 其实 onNestedScroll 的参数 dyConsumed (Y 轴偏移量的大小)就是速度...
  2. 如何判断滑到底
    • 原思路: 在 「协调」监听 onNestedScroll 里, dyConsumed == 0 时表示滑到边界了, dyUnconsumed > 0 表示滑到边界了还在下拉, 所以通过(dyConsumed == 0 && dyUnconsumed > 0)来判断
    • 现思路: boolean view.canScrollVertically(1) 的返回值表示能够下拉, -1的返回值表示上拉
  3. 详情Fragment覆盖了本该在前面的TabLayout, 即如何让xml中被TabLayout挡住的ViewPager, 反过来挡住 TabLayout.
    • 原思路: 看到源代码中 afollestad/material-dialogs, 误以为新开的Fragment都是 DialogFragment
    • 原思路: 通过 View.bringToFront
    • 现思路:
      1. DialogFragment 在Hierarchy View里能看到多开了一个 MainActivity 进程. 里面重新从 DecorView 开始的布局,
      2. View.bringToFront 即使不开启新的 DecorView, 也改变了原进程, 无法在Hierarchy View上与知乎的布局一样.
  4. 为啥不直接使用 dispatchTouchEvent
    • behavior 里没有重写 dispatchTouchEvent
  5. 为啥不 onTouchEvent
    • behavior 里在拿到一段ACTION_MOVE后会拦截掉直接扔给 Behavior 一个 ACTION_CANCEL

参考

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