组合式快速开始一个带有下拉刷新、滚动 TabLayout 标题栏悬停、不同framgent设置不同状态栏样式的通用流行布局开发

CoordinatorLayout 结合下拉刷新、ViewPager、RecyclerView smooth fling with AppBarLayout 的例子

Posted by afon on March 1, 2017

首页 MainActivity 实现

对于 MainActivity 里面嵌入 4 个 fragment,我是用一组 RadioGroup,按下 RadioButton 时再把 fragment add 进去实现的。

activity_main.xml 布局文件如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <RadioGroup android:id="@+id/home_tabGroup"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:background="@drawable/bg_tab"
        android:checkedButton="@+id/home_homePageTab"
        android:orientation="horizontal">

        <com.wordplat.quickstart.widget.TabButton
            android:id="@id/home_homePageTab"
            style="@style/HomeTabStyle"
            android:drawableTop="@drawable/tab_home"
            android:text="首页"/>

        <com.wordplat.quickstart.widget.TabButton
            android:id="@+id/home_newsTab"
            style="@style/HomeTabStyle"
            android:drawableTop="@drawable/tab_news"
            android:text="新闻"/>

        <com.wordplat.quickstart.widget.TabButton
            android:id="@+id/home_findTab"
            style="@style/HomeTabStyle"
            android:drawableTop="@drawable/tab_discovery"
            android:text="发现"/>

        <com.wordplat.quickstart.widget.TabButton
            android:id="@+id/home_userTab"
            style="@style/HomeTabStyle"
            android:drawableTop="@drawable/tab_user"
            android:text="我的"/>

    </RadioGroup>

    <FrameLayout
        android:id="@+id/main_content"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_above="@id/home_tabGroup" />

</RelativeLayout>

控制 fragment 的显示和隐藏,通过以下方法:


    private void hideAllFragment(FragmentManager fm) {
        FragmentTransaction ft = fm.beginTransaction();
        if (!homeFragment.isHidden()) {
            ft.hide(homeFragment);
        }
        if (!newsFragment.isHidden()) {
            ft.hide(newsFragment);
        }
        if (!findFragment.isHidden()) {
            ft.hide(findFragment);
        }
        if (!userFragment.isHidden()) {
            ft.hide(userFragment);
        }
        ft.commit();
    }

    private void showFragment(FragmentManager fm, Fragment fragment) {
        FragmentTransaction ft = fm.beginTransaction();
        ft.show(fragment);
        ft.commit();
    }

    private void addFragment(FragmentManager fm, Fragment fragment) {
        if (!fragment.isAdded()) {
            FragmentTransaction ft = fm.beginTransaction();
            ft.add(R.id.main_content, fragment);
            ft.commit();
        }
    }

经常会有按第二下 RadioButton 刷新(或回到顶部)当前 fragment 的需求,所以给每一个 RadioButton 设置一个点击监听:

    private View.OnClickListener onTabClicklistener = new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            switch (v.getId()) {
                case R.id.home_homePageTab:
                    if (homeFragment.isAdded() && !homeFragment.isHidden()) {
                        homeFragment.onTabClick((TabButton) v);
                    }
                    break;

                case R.id.home_newsTab:
                    if (newsFragment.isAdded() && !newsFragment.isHidden()) {
                        newsFragment.onTabClick((TabButton) v);
                    }
                    break;

                case R.id.home_findTab:
                    if (findFragment.isAdded() && !findFragment.isHidden()) {
                        findFragment.onTabClick((TabButton) v);
                    }
                    break;

                case R.id.home_userTab:
                    if (userFragment.isAdded() && !userFragment.isHidden()) {
                        userFragment.onTabClick((TabButton) v);
                    }
                    break;
            }
        }
    };

首页各个 Fragment 设置不同的状态栏样式

这里的不同状态栏样式是指,HomeFragment 要设置成全透明状态栏,而 NewsFragment 要设置成某个颜色的状态栏,同时它们两个 fragment 切换时状态栏也要跟着变,由于担心在非 onCreate 方法里面修改窗口属性会发生兼容性问题,我采用了保守的做法,即直接在 MainActivity 设置一个全透明状态栏,再在需要的 fragment 布局里面加一个“假的状态栏”,这个“假的状态栏”只做一件事,就是画背景。由于 Android API level 19 以下不支持全透明状态栏,因此会有 showAlways 及 showAfterSdkVersionInt 两个变量来控制是否显示。

/**
 * 自定义一个假的 状态栏
 *
 * Created by afon on 2016/12/29.
 */

public class CustomStatusBarView extends View {
    public static final String TAG = "CustomStatusBarView";

    private int statusBarHeight;
    private boolean showAlways = false;
    private int showAfterSdkVersionInt = 123454321;

    public CustomStatusBarView(Context context) {
        this(context, null);
    }

    public CustomStatusBarView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

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

        TypedArray a = context.getTheme().obtainStyledAttributes(
                attrs, R.styleable.CustomStatusBarView, defStyleAttr, defStyleAttr);

        try {
            showAlways = a.getBoolean(R.styleable.CustomStatusBarView_showAlways, showAlways);

            if (!showAlways) {
                showAfterSdkVersionInt = a.getInt(R.styleable.CustomStatusBarView_showAfterSdkVersionInt, showAfterSdkVersionInt);
            }
        } finally {
            a.recycle();
        }

        initUI();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int w = getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec);
        int h = resolveSize(statusBarHeight, heightMeasureSpec);
        setMeasuredDimension(w, h);
    }

    private void initUI() {
        if (isInEditMode()) {
            statusBarHeight = 0;
            return ;
        }

        if (!showAlways && Build.VERSION.SDK_INT < showAfterSdkVersionInt) {
            statusBarHeight = 0;
            return ;
        }

        statusBarHeight = getStatusBarHeight(getContext());

        setBackgroundColor(getResources().getColor(R.color.colorPrimaryDark));
    }

    public static int getStatusBarHeight(Context context) {
        int statusBarHeight = -1;
        // 获取status_bar_height资源的ID
        int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android");
        if (resourceId > 0) {
            // 根据资源ID获取响应的尺寸值
            statusBarHeight = context.getResources().getDimensionPixelSize(resourceId);
        }

        if (statusBarHeight == -1) {
            try {
                Class<?> clazz = Class.forName("com.android.internal.R$dimen");
                Object object = clazz.newInstance();
                int height = Integer.parseInt(clazz.getField("status_bar_height").get(object).toString());
                statusBarHeight = context.getResources().getDimensionPixelSize(height);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        if (statusBarHeight == -1) {
            statusBarHeight = AppUtils.dpTopx(context, 25);
        }

        return statusBarHeight;
    }
}

首页 HomeFragment 实现

HomeFragment 的布局文件有三点是较重要的。

一是 AppBarLayout 要设置一个自定义的 Behavior 用来解决 AppBarLayout 搭配 RecyclerView fling 滑动的问题,这个问题将在下文解释。

二是 CollapsingToolbarLayout 设置的 minHeight 即是 TabLayout 将要悬停在标题栏顶部时距离屏幕顶的高度,为了兼容不支持全透明状态栏的情况,我通过在 style.xml 文件中根据 Android API level 来设置不同的 minHeight,因此布局里面是 android:minHeight="?attr/titleLayoutHeight" 这样写。

三是 ViewPager 也要设置一个 Behavior,这个 Behavior 起到的作用是把整个 ViewPager 排列在 AppBarLayout 布局下面。

fragment_home.xml 布局文件如下:

<?xml version="1.0" encoding="utf-8"?>
<!-- 顶部下拉刷新 -->
<com.wordplat.quickstart.widget.pulllistview.PullListLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/pullList"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:visibility="visible">

    <android.support.design.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.design.widget.AppBarLayout
            android:id="@+id/appBar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="#eeeeee"
            app:layout_behavior="com.wordplat.quickstart.widget.custom.AppBarHeaderBehavior"
            app:elevation="0dp">

            <com.wordplat.quickstart.widget.custom.CustomCollapsingToolbarLayout
                android:id="@+id/toolBar"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:minHeight="?attr/titleLayoutHeight"
                app:layout_scrollFlags="scroll|exitUntilCollapsed"
                app:statusBarScrim="?attr/colorPrimaryDark">

                <!-- 以下是滑动时会滑出屏幕的内容布局 -->

                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:orientation="vertical">

                    <include android:id="@+id/adList"
                        layout="@layout/view_list_ad" />

                    <android.support.v7.widget.RecyclerView
                        android:id="@+id/horizontalList"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:scrollbars="none"/>

                    <android.support.v7.widget.RecyclerView
                        android:id="@+id/grid3x3List"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:layout_margin="10dp"
                        android:padding="10dp"
                        android:background="@drawable/bg_grid3x3_radius10"
                        android:scrollbars="none"/>

                    <android.support.v7.widget.RecyclerView
                        android:id="@+id/grid5x2List"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:scrollbars="none"/>

                </LinearLayout>

                <!-- 以下是滑动时会固定在顶部的标题栏布局,根据标题栏布局是否已经固定在顶部,它会被代码设置为显示或隐藏 -->

                <LinearLayout android:id="@+id/titleLayout"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:orientation="vertical"
                    android:background="?attr/colorPrimaryDark"
                    android:visibility="gone"
                    app:layout_collapseMode="pin">

                    <!-- 这是个假的状态栏,用于撑出一个状态栏高度,在系统支持透明状态栏时,它会被代码设置为 visible,否则设置为 gone -->

                    <View
                        android:id="@+id/statusBar"
                        android:layout_width="match_parent"
                        android:layout_height="25dp"
                        android:visibility="gone"/>

                    <TextView
                        android:layout_width="match_parent"
                        android:layout_height="@dimen/title_height"
                        android:gravity="center"
                        android:text="首页"
                        android:textColor="#ffffff"
                        android:textSize="17sp"
                        android:visibility="visible" />

                </LinearLayout>

            </com.wordplat.quickstart.widget.custom.CustomCollapsingToolbarLayout>

            <!-- SlidingTabLayout 在滑动到顶部时停靠在标题栏布局下边  -->

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

                <com.flyco.tablayout.SlidingTabLayout
                    android:id="@+id/slidingTab"
                    android:layout_width="match_parent"
                    android:layout_height="35dp"
                    android:background="#ffffff"
                    android:paddingBottom="4dp"
                    android:paddingTop="4dp"
                    app:tl_indicator_color="#1e82d2"
                    app:tl_indicator_height="2dp"
                    app:tl_indicator_width="120dp"
                    app:tl_tab_space_equal="true"
                    app:tl_textSelectColor="#282b34"
                    app:tl_textUnselectColor="#888888"
                    app:tl_textsize="17dp"/>

                <View
                    android:layout_width="match_parent"
                    android:layout_height="0.5dp"
                    android:layout_below="@id/slidingTab"
                    android:background="#dddddd"/>
            </LinearLayout>

        </android.support.design.widget.AppBarLayout>

        <android.support.v4.view.ViewPager
            android:id="@+id/viewPager"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

    </android.support.design.widget.CoordinatorLayout>

</com.wordplat.quickstart.widget.pulllistview.PullListLayout>

下拉刷新

对于下拉刷新,我用的是 PtrFrameLayout,结合 AppBarLayout 使用时,需要根据 AppBarLayout 有没有滑动到顶部来判断是否可以下拉刷新,因此下拉刷新监听可以这样写:

    /**
     * 下拉刷新监听
     */
    private PtrDefaultHandler ptrDefaultHandler = new PtrDefaultHandler() {

        @Override
        public boolean checkCanDoRefresh(PtrFrameLayout frame, View content, View header) {
            return behavior.getCurrentVerticalOffset() == 0 && super.checkCanDoRefresh(frame, content, header);
        }

        @Override
        public void onRefreshBegin(PtrFrameLayout frame) {
            
        }
    };

behavior 的实例从 AppBarLayout 的 LayoutParams 中获取:

        behavior = (AppBarHeaderBehavior) ((CoordinatorLayout.LayoutParams) appBar.getLayoutParams()).getBehavior();

RecyclerView fling with AppBarLayout

现在开始解释上文提到的 fling 滑动的问题,这里 fling 的问题是指两个:

1.当 AppBarLayout 在屏幕中时,RecyclerView 此时 fling 往下拉,会发现 AppBarLayout fling 不起来。而 AppBarLayout 本身 fling 滑动自己就 fling 得很 happy。

2.当 AppBarLayout 在屏幕中时,RecyclerView 此时 fling 向上滑,发现 AppBarLayout 还没有完全滑出屏幕,RecyclerView 自己就 fling 起来了。

有一句话可以形容这两个现象,就是“RecyclerView 带不动 AppBarLayout,RecyclerView 在飘”。有关这些问题的讨论,可以浏览以下帖子:

https://stackoverflow.com/questions/30923889/flinging-with-recyclerview-appbarlayout

https://code.google.com/p/android/issues/detail?id=177729&q=appbarlayout&colspec=ID%20Type%20Status%20Owner%20Summary%20Stars

对于 fling 的问题,RecyclerView 内的 dispatchNestedPreFling 方法会调用到 onNestedPreFling 方法,而默认的 CoordinatorLayout.Behavior onNestedPreFling 方法永久返回 false,查看 RecyclerView fling 的源码,发现 dispatchNestedFling 和 mViewFlinger.fling(velocityX, velocityY) 是同步进行的,它们没有先后之分,由此解释了问题 2 产生的原因。

        final boolean canScrollHorizontal = mLayout.canScrollHorizontally();
        final boolean canScrollVertical = mLayout.canScrollVertically();

        if (!canScrollHorizontal || Math.abs(velocityX) < mMinFlingVelocity) {
            velocityX = 0;
        }
        if (!canScrollVertical || Math.abs(velocityY) < mMinFlingVelocity) {
            velocityY = 0;
        }
        if (velocityX == 0 && velocityY == 0) {
            // If we don't have any velocity, return false
            return false;
        }

        if (!dispatchNestedPreFling(velocityX, velocityY)) {
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            dispatchNestedFling(velocityX, velocityY, canScroll);

            if (mOnFlingListener != null && mOnFlingListener.onFling(velocityX, velocityY)) {
                return true;
            }

            if (canScroll) {
                velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
                velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
                mViewFlinger.fling(velocityX, velocityY);
                return true;
            }
        }

继续查看 dispatchNestedFling,此方法会调用到 AppBarLayout.Behavior 的 onNestedFling 方法,经过断点调试,发现 RecyclerView fling 往下拉,也就是 velocityY < 0 时,getTopBottomOffsetForScrollingSibling 方法获得的值总是大于变量 targetScroll,因此 animateOffsetTo 方法并没有执行,由此解释了问题 1 产生的原因。

        @Override
        public boolean onNestedFling(final CoordinatorLayout coordinatorLayout,
                final AppBarLayout child, View target, float velocityX, float velocityY,
                boolean consumed) {
            boolean flung = false;

            if (!consumed) {
                // It has been consumed so let's fling ourselves
                flung = fling(coordinatorLayout, child, -child.getTotalScrollRange(),
                        0, -velocityY);
            } else {
                // If we're scrolling up and the child also consumed the fling. We'll fake scroll
                // up to our 'collapsed' offset
                if (velocityY < 0) {
                    // We're scrolling down
                    final int targetScroll = -child.getTotalScrollRange()
                            + child.getDownNestedPreScrollRange();
                    if (getTopBottomOffsetForScrollingSibling() < targetScroll) {
                        // If we're currently not expanded more than the target scroll, we'll
                        // animate a fling
                        animateOffsetTo(coordinatorLayout, child, targetScroll, velocityY);
                        flung = true;
                    }
                } else {
                    // We're scrolling up
                    final int targetScroll = -child.getUpNestedPreScrollRange();
                    if (getTopBottomOffsetForScrollingSibling() > targetScroll) {
                        // If we're currently not expanded less than the target scroll, we'll
                        // animate a fling
                        animateOffsetTo(coordinatorLayout, child, targetScroll, velocityY);
                        flung = true;
                    }
                }
            }

            mWasNestedFlung = flung;
            return flung;
        }

我想要不通过自定义 AppBarLayout 和自定义 RecyclerView 就能解决这两个问题,最大降低代码侵入性,因此我参考了上述第一个帖子链接里的第三个回答,加以改进,自定义 AppBarLayout.Behavior 实现。

自定义 AppBarLayout.Behavior 的 onNestedPreFling 方法的返回值非常重要,它控制着 RecyclerView 会不会执行 fling。因此我记录了 AppBarLayout 的偏移量,用这个偏移量判断 TabLayout 是否滚动到顶部,只有 TabLayout 滚动到顶部时才让 RecyclerView 执行 fling 操作:

    @Override
    public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, float velocityX, float velocityY) {
        // RecyclerView 只有在 TabLayout 滚动到顶部时才继续执行 fling 操作
        final boolean consumed = currentVerticalOffset == minVerticalOffset;

        if (target instanceof RecyclerView) {
            final RecyclerView recyclerView = (RecyclerView) target;
            currentScrollTarget = recyclerView;

            if (scrollListenerMap.get(recyclerView) != null) {
                scrollListenerMap.get(recyclerView).setVelocity(velocityY);
            }
        }

        onNestedFling(coordinatorLayout, child, target, velocityX, velocityY, consumed);

        // 返回 true 让 RecyclerView 不要立即 fling,只有在 TabLayout 滚动到顶部时才继续执行 fling 操作
        return currentVerticalOffset > minVerticalOffset;
    }

为了优化多次 fling 的体验,当新的一次 onStartNestedScroll 事件开始时,需停止上一次滚动,这里用到了反射:

    private ScrollerCompat getScroller() {
        ScrollerCompat scroller = null;
        try {
            Field scrollerField = getClass().getSuperclass().getSuperclass().getDeclaredField("mScroller");
            scrollerField.setAccessible(true);
            scroller = (ScrollerCompat) scrollerField.get(this);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }

        return scroller;
    }

    @Override
    public boolean onStartNestedScroll(CoordinatorLayout parent, AppBarLayout child, View directTargetChild, View target, int nestedScrollAxes) {
        if (currentVerticalOffset > minVerticalOffset) {
            parent.onStopNestedScroll(target);

            if (scroller == null) {
                scroller = getScroller();
            }
            if (scroller != null) {
                scroller.abortAnimation();
            }
        }

        return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes);
    }

完整的代码在这里:

https://github.com/wordplat/TabNavigation/blob/master/app/src/main/java/com/wordplat/quickstart/widget/custom/AppBarHeaderBehavior.java

预览

最后实现的效果如下:

下载 Demo:

app-debug.apk

Github 地址:

https://github.com/wordplat/TabNavigation