【五種方式實現Android吸頂效果 最全總結!】列表滑動到頂部 固定頂部欄效果
如今許多app都會應用到的一種UI互動形式,列表滑動到頂部,固定頂部欄效果,我們也可以稱作其為吸頂效果。比如微博 、各大瀏覽器的首頁資訊流模組、我的頁面的設計等。

微博評論的吸頂效果
本文將循序漸進的通過多種方式實現吸頂效果。大家擇優選取適合自己的實現方式。 實現效果如圖:

demo實現
一、兩個相同的頂部欄
寫兩個一模一樣的固定懸浮欄,在一開始把外層固定欄先隱藏,當內層固定欄滑動到外層固定位置時,把內層固定欄隱藏,外層固定欄顯示。
頭部+內層懸浮欄+list 組成了scrollview

主要程式碼 監聽scrollview的滑動,隱藏顯示內外懸浮窗
scrollView.setScrollChangeListener(new MyScrollView.ScrollChangedListener() { @Override public void onScrollChangedListener(int x, int y, int oldX, int oldY) { if (y >= topHeight) { //重點 通過距離變化隱藏內外固定欄實現 llOutsideFixed.setVisibility(View.VISIBLE); insideFixedBar.setVisibility(View.GONE); recyclerView.setNestedScrollingEnabled(true); } else { llOutsideFixed.setVisibility(View.GONE); insideFixedBar.setVisibility(View.VISIBLE); recyclerView.setNestedScrollingEnabled(false); } } });
二、通過ListView
通過listview新增頭部,當listview滑動到頂部將原本隱藏的頭部佈局顯示出來。
listView.setOnScrollListener(new AbsListView.OnScrollListener() { @Override public void onScrollStateChanged(AbsListView view, int scrollState) { } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { /* 判斷ListView頭部(mHeaderView)當前是否可見 * 來決定隱藏或顯示浮動欄(mFloatBar)*/ if (firstVisibleItem >= 1) { flOutSideBar.setVisibility(View.VISIBLE); } else { flOutSideBar.setVisibility(View.GONE); } } });
這種方式需要寫重複佈局,事件監聽,當固定佈局帶有狀態時,還要將兩個狀態同這種方式實現的根本其實也是很方式一相同,也需要引入兩個相同的頂部固定欄,相比方式一不同的是:
- 方式二滑動監聽通過listview自帶的setOnScrollListener即可,方式一需要暴露介面提供滑動位移變化值。
- 當存在滑動的view時,方式二不需要處理衝突,方式一需要衝突處理。
- 佈局的引入:外部懸浮窗和頭部佈局,listview通過addHeaderView引入即可。管理起來方便。
方式一和方式二的缺點就是:
- 需要寫兩個相同的xml檔案 以及重複寫相應點選事件的邏輯。
- 邏輯複雜時,需要同步固定懸浮窗的狀態,在業務發生變化的時候可能需要同時去改動至少兩處程式碼,增加出錯的概率。
三、使用一個頂部欄 用一個空佈局動態增刪頂部欄來實現。
這種方式的實現方式就是對第一種實現方式的簡單優化,其他基本一致。
大體思路:將方式一的兩個頂部欄變成一個,利用removeView和addView根據座標點在頁面滑動的時候動態的把固定欄在內外部切換。在scrollview外部新增一個空的layout,當滑動到指定的點,就將內層懸浮窗佈局移除,新增到外層的空的佈局。這樣就解決了要同步狀態和寫兩個相同的xml佈局的問題了。

scrollView.setScrollChangeListener(new MyScrollView.ScrollChangedListener() { @Override public void onScrollChangedListener(int x, int y, int oldX, int oldY) { if (y >= topHeight) { if (rlInsideFixed.getParent() != llFixed) { insideFixedBarParent.removeView(rlInsideFixed); llFixed.addView(rlInsideFixed); recyclerView.setNestedScrollingEnabled(true); } } else { if (rlInsideFixed.getParent() != insideFixedBarParent) { llFixed.removeView(rlInsideFixed); insideFixedBarParent.addView(rlInsideFixed); recyclerView.setNestedScrollingEnabled(false); } } } });
方式三是動態的增加和移除view,缺點是當包裹內容佈局中帶有滑動特性的View(ListView,RecyclerView等),我們需要額外處理滑動衝突,並且這種包裹方式,會使得它們的 快取模式失效 。
四、藉助android5.0的新特性 CoordinatorLayout+AppbarLayout+ CollapsingToolbarLayout
首先要使用android5.0的material design風格 我們需要引入以下依賴
implementation 'com.android.support:design:28.+'
然後依次介紹這幾個UI的功能
- CoordinatorLayout 頂層佈局 類似relativelayout、linearlayout等,不同的是它可以協調子view之間的互動。產生聯動的效果。子view通過app:layout_behavior 指定相應的行為。
- AppBarLayout 是一個垂直佈局的 LinearLayout,它主要是為了實現 “Material Design” 風格的標題欄的特性,比如:滾動。可以響應使用者的手勢操作,但是必須在CoordinatorLayout下使用,否則會有許多功能使用不了。
AppBarLayout裡面的View,是通過app:layout_scrollFlags屬性來控制滑動,其中有4種Flag的型別.
- Scroll:向下滾動時,被指定了這個屬性的View會被滾出螢幕範圍直到完全不可見的位置。
- enterAlways:向上滾動時,這個View會隨著滾動手勢出現,直到恢復原來的位置。
- enterAlwaysCollapsed: 當檢視已經設定minHeight屬性又使用此標誌時,檢視-只能以最小高度進入,只有當滾動檢視到達頂部時才擴大到完整高度。
- exitUntilCollapsed: 滾動退出螢幕,最後摺疊在頂端。
- CollapsingToolbarLayout 摺疊佈局 用來協調AppBarLayout來實現滾動隱藏ToolBar的效果。繼承自 FrameLayout,它是用來實現 Toolbar 的摺疊效果,一般它的直接子 View 是 Toolbar,當然也可以是其它型別的 View。通過設定layout_collapseMode 控制摺疊屬性 。(官方說CollapsingToolbarLayout主要是配合Toolbar而設計的。但如果我們不需要 也可以不加toolbar。只不過在需要toolbar的時候配合CollapsingToolbarLayout效果更佳。)
- 不設定 跟隨NestedScrollView的滑動一起滑動,NestedScrollView滑動多少距離他就會跟著走多少距離
- parallax 視差效果 layout_collapseParallaxMultiplier視差因子 0~1之間取值
-
pin 固定效果,在摺疊的時候最後固定在頂端。在滑動過程中,此自佈局會固定在它所在的位置不動,直到CollapsingToolbarLayout全部摺疊或者全部展開。
<?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" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true"> <android.support.design.widget.AppBarLayout android:id="@+id/app_bar" android:layout_width="match_parent" android:layout_height="wrap_content" android:fitsSystemWindows="true"> <android.support.design.widget.CollapsingToolbarLayout android:id="@+id/toolbar_layout" android:layout_width="match_parent" android:layout_height="wrap_content" android:fitsSystemWindows="true" app:layout_scrollFlags="scroll|exitUntilCollapsed" app:statusBarScrim="@android:color/transparent"> <include layout="@layout/header" /> </android.support.design.widget.CollapsingToolbarLayout> <include layout="@layout/inside_fixed_bar" /> </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"> <android.support.v7.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#d2ebaf"/> </android.support.v4.widget.NestedScrollView> </android.support.design.widget.CoordinatorLayout>
這種方式的缺點就是需要只能5.0及以上機型適用,但這個既不用處理滑動衝突,也不會有快取問題。使用起來也很流暢。
五、 通過重寫RecyclerView的分割線ItemDecoration來實現。
ItemDecoration是RecyclerView下的抽象方法,允許給特定的item檢視新增特性的繪製以及佈局間隔。它可以用來實現item之間的分割線,高亮,分組邊界等。三個重要的方法:getItemOffsets、onDraw、onDrawOver(自行了解)
實現思路:比如我們之前放的微博評論的吸頂效果圖,首先是微博內容,我們把它當成是RecyclerView的HeaderView即可,也是Item的一項,然後下面的評論列表就是基礎的RecyclerView使用了,然後中間固定的佈局,就是ItemDecoration裡的getItemOffsets、onDraw、onDrawOver這三個方法來配合實現了。在onDraw方法裡判斷是否是列表的第一項 除了頭部佈局,如果是就繪製頂部欄,不是,繪製分割線。在onDrawOver裡判斷是否是頭部佈局,如果是不做處理,不是就在檢視可見的第一項上繪製頂部欄。getItemOffsets是繪製的邊距,也是分是不是頭部項的情況去判斷。如果我們只想簡單的繪製分割線,getItemOffsets讓item之間空出間隙,然後再呼叫onDraw在這個間隙上填充顏色即可。
public class FixedBarDecoration extends RecyclerView.ItemDecoration { private int mItemHeaderHeight; private Paint mLinePaint; private Paint mItemHeaderPaint; private Paint mTextPaint; private Rect mTextRect; public FixedBarDecoration(Context context) { mItemHeaderHeight = ViewUtils.dip2px(context, 40); mTextRect = new Rect(); mItemHeaderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mItemHeaderPaint.setColor(Color.BLUE); mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mLinePaint.setColor(Color.GRAY); mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mTextPaint.setTextSize(46); mTextPaint.setColor(Color.WHITE); } //吸頂效果的主要實現方法 @Override public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { if (parent.getAdapter() instanceof NormalAdapter) { NormalAdapter adapter = (NormalAdapter) parent.getAdapter(); int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition(); if (adapter.isHasHeader() && position == 0) { return; } //如果不是頭部view 那就直接在當前第一個可見的item頂部畫一個固定欄即可 //View view = parent.findViewHolderForAdapterPosition(position).itemView; c.drawRect(0, 0, parent.getWidth(), mItemHeaderHeight, mItemHeaderPaint); mTextPaint.getTextBounds("懸浮固定欄", 0, "懸浮固定欄".length(), mTextRect); c.drawText("懸浮固定欄", parent.getWidth() / 2 - mTextRect.width() / 2, mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint); } } //繪製分割線和固定欄 @Override public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { if (parent.getAdapter() instanceof NormalAdapter) { NormalAdapter adapter = (NormalAdapter) parent.getAdapter(); int count = parent.getChildCount(); for (int i = 0; i < count; i++) { View view = parent.getChildAt(i); int position = parent.getChildLayoutPosition(view); boolean isFirstItem = adapter.isFirstItem(position); if (isFirstItem) { c.drawRect(0, view.getTop() - mItemHeaderHeight, parent.getWidth(), view.getTop(), mItemHeaderPaint); mTextPaint.getTextBounds("懸浮固定欄", 0, "懸浮固定欄".length(), mTextRect); c.drawText("懸浮固定欄", parent.getWidth() / 2 - mTextRect.width() / 2, (view.getTop() - mItemHeaderHeight) + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint); } else { c.drawRect(0, view.getTop() - 1, parent.getWidth(), view.getTop(), mLinePaint); } } } } @Override public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { if (parent.getAdapter() instanceof NormalAdapter) { NormalAdapter adapter = (NormalAdapter) parent.getAdapter(); int position = parent.getChildLayoutPosition(view); boolean isFirstItem = adapter.isFirstItem(position); if (isFirstItem) { outRect.top = mItemHeaderHeight; } else { outRect.top = 1; } } } }
這種方式的缺點就是如果頂部欄的佈局複雜,難以繪製,以及頂部欄的監聽事件新增複雜。
六、擴充套件:分組加吸頂效果
思路:當我們要實現分組+吸頂效果,為了實現頂部欄固定不動,可以利用onDrawOver在RecyclerView的上繪製一個和頭部佈局一模一樣的佈局呢,讓它覆蓋住了第一個頭佈局,在視覺上我們是不會有所察覺的,然後當列表滑動的時候,其實“原來的頭佈局”早已經滑動走了,留下的其實是我們繪製的固定佈局而已,等到下一個頭部佈局“碰頭”的時候,讓它隨著滑動的速度慢慢改變佈局的高度,當佈局高度為0的時候,也就是被頂出去的時候,然後再讓高度改變回來,覆蓋住第二個佈局,然後不斷重複以上步驟即可。
參考文章 吸頂+分組效果的實現