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不配置的效果:


    在這里插入圖片描述

    NestedScrollView的內容在ToolBar之上滾動

  • 其中13行app:layout_scrollFlags="scroll|exitUntilCollapsed"如果不配置效果圖如下:


    在這里插入圖片描述

    如果沒有配置則CollapsingToolbarLayout包裹內容內容就會固定在頂部,不會滾動

  • 28行 app:layout_collapseMode="pin"不配置,效果圖:


    在這里插入圖片描述

    ToolBar會跟隨NestedScrollView的滾動而滾動,而不會固定在布局頂部位置

  • 14行app:titleEnabled="false"不配置,效果圖:


    在這里插入圖片描述

    即使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按鈕就進入了我們的測試頁面,效果圖如下:

在這里插入圖片描述

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,963評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,348評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 178,083評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,706評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,442評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,802評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,795評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,983評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,542評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,287評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,486評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,030評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,710評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,116評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,412評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,224評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,462評論 2 378

推薦閱讀更多精彩內容