天天看點

Android開發項目實戰:實作折疊式布局

首先實作一個 頭部固定的ExpandedListView ,然後在它的基礎上實作:在頭部加一個背景圖檔,預設狀态下他處于展開狀态,往上滑的時候背景圖檔逐漸的折疊起來,往下滑的時候背景圖檔慢慢的展開效果圖如下:

通過CoordinateLayout實作的折疊式布局

有人可能會說這不就是折疊式布局嗎?是的,這就是Android 5.0給我們提供的材料設計庫中的CoordinateLayout就是解決這個問題的,使用CoordinateLayout來協調ScrollView,NestedScrollView,ListView,RecycleView和頂部的背景圖檔、ToolBar之間的滾動關系、在很多的手機應用中,時不時會看到關于折疊布局的效果,現在我們先看看CoordinateLayout是怎麼實作的然後在講我們自定義實作一個折疊式布局,直接上代碼:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="200dp">

        <android.support.design.widget.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_scrollFlags="scroll|exitUntilCollapsed"
            app:titleEnabled="false">

            <ImageView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scaleType="centerCrop"
                android:src="@mipmap/homepage_pic_banner"
                app:layout_collapseMode="parallax" />

            <android.support.v7.widget.Toolbar
                android:id="@+id/view_toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light">

                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center"
                    android:text="新聞詳情" />
            </android.support.v7.widget.Toolbar>
        </android.support.design.widget.CollapsingToolbarLayout>
    </android.support.design.widget.AppBarLayout>

    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

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

            <TextView
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:text="hello world" />
            ...
            ...
        </LinearLayout>
    </android.support.v4.widget.NestedScrollView>
</android.support.design.widget.CoordinatorLayout>
           

以上就是實作一個折疊式布局的典型模闆布局代碼,一個簡簡單單的布局就實作了這樣的效果,但是必須要注意在AndroidMnifest.xml必須要給Activity指定它的theme為NoActionBar的樣式代碼如下:

<activity
            android:name=".test.CoordinatorLayoutTestActivity"
            android:theme="@style/Theme.AppCompat.Light.NoActionBar"/>
           

否則會出現ActionBar和ToolBar共存的情況,的顯示效果如下:

另外還需要把自己自定義的ToolBar告訴給系統,即第9行的setSupportActionBar(toolbar),否則我們的ToolBar會作為一個普通的View而存在

public class CoordinatorLayoutTestActivity extends AppCompatActivity {

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_coordinator_layout_test);
        Toolbar toolbar = findViewById(R.id.view_toolbar);
        setSupportActionBar(toolbar);
        getSupportActionBar().setDisplayShowTitleEnabled(false);
        toolbar.setNavigationIcon(R.mipmap.callback_white_icon);
        toolbar.setNavigationOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                onBackPressed();
            }
        });
    }
}
           

如果隻指定了 setSupportActionBar(toolbar),沒有AndroidMnifest.xml在指定Activity的theme為NoActionBar,那就運作就直接崩潰了,會報錯如下:

Caused by: java.lang.IllegalStateException: This Activity already has an action bar supplied by the window decor. Do not request Window.FEATURE_SUPPORT_ACTION_BAR and set windowActionBar to false in your theme to use a Toolbar instead.
        at android.support.v7.app.AppCompatDelegateImpl.setSupportActionBar(AppCompatDelegateImpl.java:345)
        at android.support.v7.app.AppCompatActivity.setSupportActionBar(AppCompatActivity.java:130)
           

意思是說Activity已經有一個ActionBar了,請在你的樣式中使用ToolBar替代

在上面的布局檔案代碼中,根布局CoordinatorLayout 就是用來協調AppBarLayout和NestedScrollView之間滾動的,40行的NestedScrollView是我們要滾動的内容,在11行的CollapsingToolbarLayout标簽的内部就是要折疊的内容

  • 其中43行的 app:layout_behavior不配置的效果:
    ![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy8xNTM0MjU0MS0wODNjNDhmMWM5OWRkNTk2LmdpZj9pbWFnZU1vZ3IyL2F1dG8tb3JpZW50L3N0cmlwfGltYWdlVmlldzIvMi93LzM2MC9mb3JtYXQvd2VicA)
    
    NestedScrollView的内容在ToolBar之上滾動
               
  • 其中13行app:layout_scrollFlags="scroll|exitUntilCollapsed"如果不配置效果圖如下:
    ![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy8xNTM0MjU0MS02MTlhZThjOTQyZWEwODBkLmdpZj9pbWFnZU1vZ3IyL2F1dG8tb3JpZW50L3N0cmlwfGltYWdlVmlldzIvMi93LzM2MC9mb3JtYXQvd2VicA)
    
    如果沒有配置則CollapsingToolbarLayout包裹内容内容就會固定在頂部,不會滾動
               
  • 28行 app:layout_collapseMode="pin"不配置,效果圖:
    ![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy8xNTM0MjU0MS1kN2NkMGM1NWEyYmNiYmUyLmdpZj9pbWFnZU1vZ3IyL2F1dG8tb3JpZW50L3N0cmlwfGltYWdlVmlldzIvMi93LzM2MC9mb3JtYXQvd2VicA)
    
    ToolBar會跟随NestedScrollView的滾動而滾動,而不會固定在布局頂部位置
               
  • 14行app:titleEnabled="false"不配置,效果圖:
    ![image](https://imgconvert.csdnimg.cn/aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy8xNTM0MjU0MS03MmUyNzEwMGYxY2U4MmU2LmdpZj9pbWFnZU1vZ3IyL2F1dG8tb3JpZW50L3N0cmlwfGltYWdlVmlldzIvMi93LzM2MC9mb3JtYXQvd2VicA)
    
    即使33行的TextView配置了android:layout_gravity="center",title也不會居中顯示
               

我們感覺折疊式布局就是給我們的View設定相關的屬性配置,不需要進行任何編碼就能完成我們的折疊效果,我們不得的贊歎android 5.0給我們提供這一強大的功能

我們來總結一下:

CoordinatorLayout+AppBarLayout+CollapsingToolbarLayout結合起來才能産生這麼神奇的效果,不要幻想使用其中的一個控件就能完成這樣的效果

ToolBar的設定

系統預設使用的就是系統自帶的ActionBar,如果我們要使用自定義的ToolBar,就必須明确的告訴Activity不需要使用系統自帶的ActionBar即要給activity設定NoActionBar的樣式,另外必須調用setSupportActionBar(toolbar)将自己定義的ToolBar設定給Activity。

CoordinatorLayout下可滑動控件的設定

CoordinatorLayout作為整個布局的父布局容器。給你的可以滑動的控件例如RecyclerView設定如下屬性:app:layout_behavior=@string/appbar_scrolling_view_behavior

CoordinatorLayout還提供了layout_anchor 和 layout_anchorGravity屬性一起配合使用,可以用于設定FloatingActionButton的位置,此處我是放在appBar的右下角。

app:layout_anchor="@id/appbar"

app:layout_anchorGravity="bottom|right|end"

CollapsingToolbarLayout的layout_scrollFlags屬性

AppBarLayout裡面定義的子view隻要設定了app:layout_scrollFlags屬性,就可以在RecyclerView滾動事件發生的時候被觸發某種行為

例如我給CollapsingToolbarLayout控件設定了 app:layout_scrollFlags="scroll|exitUntilCollapsed"此刻如果沒有這個屬性,CollapsingToolbarLayout是不會折疊的那麼問題來了,layout_scrollFlags中的屬性值除了可以觸發折疊的行為,還有其它的屬性值嗎?并且各個屬性的意義是什麼?scroll至少有一個scroll,即可滾動。

屬性 作用
scroll 必須要給其至少有設定一個scroll,即可滾動
enterAlways 向下滾動即可見。例如下拉時,立即顯示Toolbar
exitUntilCollapsed 這個flag是定義何時收縮。當你定義了一個minHeight,這個view将在滾動到達這個最小高度的時候消失
enterAlwaysCollapsed 這個flag是定義何時展開。當你定義了一個最小高度minHeight, 同時enterAlways也定義了,那麼view将在到達這個最小高度的時候開始展示
snap 當一個滾動事件結束,它将根據顯示百分比的大小自動滾動到收縮或展開。

如果不設定該屬性,則該布局不能滑動

CollapsingToolbarLayout的其他屬性

另外還可以給CollapsingToolbarLayout設定以下屬性:

contentScrim 設定當完全折疊(收縮)後的背景顔色。
expandedTitleMarginEnd 沒有擴張的時候标題顯示的位置
expandedTitleMarginStart 擴張的時候标題向左填充的距離。
statusBarScrim 設定折疊時狀态欄的顔色

CollapsingToolbarLayout下的view的layout_collapseMode屬性

CollapsingToolbarLayout裡面定義的view隻要設定了app:layout_collapseMode屬性,就可以控制子視圖的折疊模式。

折疊模式分為兩種:

pin 固定模式。在收縮的時候最後固定在頂端(例如向上滾動的時候就固定toolBar)
parallax 視差模式,在折疊的時候會有個視差折疊的效果。(例如向下滾動的時候就展開ImageView)

CoordinatorLayout 的fitsSystemWindows屬性

fitsSystemWindows屬性可以讓view根據系統視窗來調整自己的布局,簡單點說就是我們在設定應用布局時是否考慮系統視窗布局,這裡系統視窗包括系統狀态欄、導航欄、輸入法等,包括一些手機系統帶有的底部虛拟按鍵。android:fitsSystemWindows=”true” (觸發View的padding屬性來給系統視窗留出空間) 這個屬性可以給任何view設定,隻要設定了這個屬性此view的其他所有padding屬性失效,同時該屬性的生效條件是隻有在設定了透明狀态欄(StatusBar)或者導航欄(NavigationBar)此屬性才會生效

如何監聽CollapsingToolbarLayout的展開與折疊

使用官方提供的 AppBarLayout.OnOffsetChangedListener就能實作了,不過要封裝一下才好用,自定義一個繼承了 AppBarLayout.OnOffsetChangedListener的類這裡命名為AppBarStateChangeListener

public abstract class AppBarStateChangeListener implements AppBarLayout.OnOffsetChangedListener {

    public enum State {
        EXPANDED,
        COLLAPSED,
        IDLE
    }
    private State mCurrentState = State.IDLE;

    @Override
    public final void onOffsetChanged(AppBarLayout appBarLayout, int i) {
        if (i == 0) {
            if (mCurrentState != State.EXPANDED) {
                onStateChanged(appBarLayout, State.EXPANDED);
            }
            mCurrentState = State.EXPANDED;
        } else if (Math.abs(i) >= appBarLayout.getTotalScrollRange()) {
            if (mCurrentState != State.COLLAPSED) {
                onStateChanged(appBarLayout, State.COLLAPSED);
            }
            mCurrentState = State.COLLAPSED;
        } else {
            if (mCurrentState != State.IDLE) {
                onStateChanged(appBarLayout, State.IDLE);
            }
            mCurrentState = State.IDLE;
        }
    }
    public abstract void onStateChanged(AppBarLayout appBarLayout, State state);
}
           

然後我們這樣使用它:

mAppBarLayout.addOnOffsetChangedListener(new AppBarStateChangeListener() {
            @Override
            public void onStateChanged(AppBarLayout appBarLayout, State state) {
                Log.d("STATE", state.name());
                if( state == State.EXPANDED ) {
                     //展開狀态                    
                }else if(state == State.COLLAPSED){
                    //折疊狀态                     
                }else {                
                    //中間狀态                
                }
            }
        });
           

這樣就可以在不同的狀态下根據自己的業務需求去實作相關的邏輯了

StickyLayout自定折疊式布局的實作

好了,上面就是關于通過CoordinateLayout實作的折疊式布局所有的知識點,如果說前面隻是開胃菜,現在我們就開始上主菜了,我們能不能自己實作這樣一個折疊式的布局,利用上一篇我們所講的頭部固定的ExpandedListView,把它作為具有滑動功能的主View,在它的頂部添加具有背景圖檔Header,随着ExpandedListView的滑動header實作擴充和收縮的效果,效果如下:

功能分析

其實這個效果圖在文章的一開始就展示過了,整個布局分為上下兩部分:上分部分為可折疊的Header,下半部分就是我們頭部固定的ExpandedListView,他們公共父view就是今天我們要實作的折疊式布局StickyLayout,ExpandedListView是自身所具備滑動功能的,而我們在整個螢幕上,往上滑動的時候如果header處于展開狀态則Header慢慢的要折疊起來,往下滑動的時候如果ExpandedListView頂部資料都顯示出來的情況下再往下拉的時候Header就慢慢的展開,其他的狀态就是我們的ExpandedListView在上下滑動,也就是說我們的Header在折疊和展開的狀态下的這些事件被StickyLayout攔截了,其他的事件就交給ExpandedListView進行處理進而實作了他的上下滑動,這就屬于典型的滑動沖突問題,簡言之就是我們在上下滑動的過程中的有些事件需要被StickyLayout攔截消掉來實作Header的折疊和展開效果,其他的事件就交給ExpandedListView來實作它的滑動效果

現在我們要思考的是哪些情況下被攔截:

  • 左右滑動的不需要處理,隻處理上下滑動的事件
  • 在展開的狀态下,上滑事件需要攔截
  • ExpandedListView的第0個元素處于可見狀态,此時的下滑事件需要攔截

在事件攔截方法中處理滑動沖突

public class StickyLayout extends LinearLayout {
    ...
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                mLastInterceptX = x;
                mLastInterceptY = y;
                intercept = false;
                break;
            case MotionEvent.ACTION_MOVE:
                int dx = x - mLastInterceptX;
                int dy = y - mLastInterceptY;
                if(y <= mCurrHeaderHeight){
                    intercept = false;
                }else if(Math.abs(dx) > Math.abs(dy)){
                    intercept = false;
                }else if(mState == mStateExpand && dy <= - mScaledTouchSlop){
                    //上滑
                    intercept = true;
                }else if(mGiveUpTouchEventListener.giveUpTouchEvent() && dy > mScaledTouchSlop){
                    //下滑
                    intercept = true;
                }else{
                    intercept = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                mLastInterceptX = 0;
                mLastInterceptY = 0;
                intercept = false;
                break;
        }
        return intercept;
    }
    ...
}
           

上面就是關于事件攔截的核心代碼,首先我們看17行:y <= mCurrHeaderHeight 如果觸摸事件是在Header之上也就不攔截了,再看19行Math.abs(dx) > Math.abs(dy),如果是橫向滑動也不是我們所需要的事件也不攔截,否則上就是上下滑動的事件了,在這個狀态下狀态Header處于展開狀态且是上滑那就需要攔截處理,也就是21行:mState == mStateExpand && dy <= - mScaledTouchSlop所處理的邏輯,在看24行:mGiveUpTouchEventListener.giveUpTouchEvent() && dy > mScaledTouchSlop,giveUpTouchEvent方法表示如果ExpandedListView的第一個可見元素是0且dy > mScaledTouchSlop(表示是上滑)此時的事件也是需要攔截的

View滑動距離常量TouchSlop

在21行細心的同學可能會看到這麼一句dy <= - mScaledTouchSlop,dy指的是滑動的距離,mScaledTouchSlop到底是什麼?其實他是Android系統給我們提供的View滑動最小距離常量TouchSlop,也就是說兩個Move事件之間的滑動距離如果小于這個常量就系統不認為他是滑動,因為滑動距離太短,反之就認為它是滑動,這個常量值和裝置有關,不同的設定上這個值可能是不相同的,我們可以通過如下方式即可擷取這個常量:

int mScaledTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
           

折疊展開的事件消費

上面的17到29行就是處理事件攔截的核心處理邏輯,事件攔截完畢,事件就交給TouchEvent方法進行消費了,下面看看Header到底具體是怎麼折疊的?其實很簡單就是不用重置Header的height就OK了,我們看看代碼:

public class StickyLayout extends LinearLayout {
    ...
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                int dx = x - mLastX;
                int dy = y - mLastY;
                mCurrHeaderHeight += dy;
                setHeaderHeight(mCurrHeaderHeight);
                break;
            case MotionEvent.ACTION_UP:
                int dest = 0;
                if(mCurrHeaderHeight <= mOriginHeaderHeight * 0.5){
                    dest = 0;
                    mState = mStateCollapsed;
                }else{
                    dest = mOriginHeaderHeight;
                    mState = mStateExpand;
                }
                smoothSetHeaderHeight(mCurrHeaderHeight,dest,500);
                break;
        }
        mLastX = x;
        mLastY = y;
        return super.onTouchEvent(event);
    }
    ...
}
           

其中12行到13行就是手指拖動狀态下的核心邏輯 ,12行計算兩次Move事件所移動的距離,13行根據手指滑動的距離來計算Header目前的高度,計算完畢就可以調用setHeaderHeight設定Header的高了

設定Header的高來實作折疊效果

private void setHeaderHeight(int height) {
        if(height <= 0){
            height = 0;
        }else if(height >= mOriginHeaderHeight){
            height = mOriginHeaderHeight;
        }
        if(height == 0 ){
            mState = mStateCollapsed;
        }else{
            mState = mStateExpand;
        }
        headerView.getLayoutParams().height = height;
        headerView.requestLayout();
    }
           

其中第2行到第6行對Header高度的越界處理,第7行到11行是設定Header的狀态,第12行到13行給Header的高指派并重新整理Header來變它的位置與大小

手指擡起的自動回彈折疊展開效果

如果目前Header的高小于原始高度的一半手指擡起的時候Header進行收縮,反之就進行展開操作,核心代碼在上面的onTouchEvent(MotionEvent event)方法的的17行到25行:

int dest = 0;
  if(mCurrHeaderHeight <= mOriginHeaderHeight * 0.5){
        dest = 0;
        mState = mStateCollapsed;
  }else{
        dest = mOriginHeaderHeight;
        mState = mStateExpand;
   }
  smoothSetHeaderHeight(mCurrHeaderHeight,dest,500);
           

最後在調用smoothSetHeaderHeight實作彈性展開,折疊

private void smoothSetHeaderHeight(int from,int to,int duration) {
       ValueAnimator valueAnimator = ValueAnimator.ofInt(from, to).setDuration(duration);
       valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
           @Override
           public void onAnimationUpdate(ValueAnimator animation) {
               setHeaderHeight((Integer) animation.getAnimatedValue());
           }
       });
       valueAnimator.start();
   }
           

總結

截止目前整個折疊式自定義View就全部講完了,事件攔截這塊的判斷邏輯是整個代碼的核心,找到了判斷折疊、展開的的算法那麼其他的問題也就不是什麼大問題了,解決滑動沖突問題也是我們在自定義View開發過程中的常見問題,也是難點問題,隻要多練習,多思考就能孰能生巧最後我将整個測試代碼傳到了github上,歡迎學習下載下傳

https://github.com/mxdldev/android-custom-view/

,其中StickyLayout.java就是我們本例中的自定義View的全部代碼實作,下載下傳完整項目後直接運作安裝完畢,點選StickyLayout按鈕就進入了我們的測試頁面,效果圖如下:

作者:門心叼龍

連結:

https://www.jianshu.com/p/1ba947bc0a98

繼續閱讀