Android RecyclerView實現頭部懸浮吸頂效果

之前我在GitHub上開源了一個可以實現RecyclerView列表分組的通用Adapter: GroupedRecyclerViewAdapter。有一些朋友在使用的時候給我反饋,希望能實現頭部懸浮吸頂的效果。我當初設計GroupedRecyclerViewAdapter的初衷,是想要實現一個能方便管理RecyclerView多種item類型的Adapter,特別是能實現兩級列表的Adapter,因為這樣的需求在開發中很常見。所以當初我并沒有考慮頭部懸浮的功能。直到接到這些使用者的反饋,我才開始考慮添加這樣的功能。不過想來也確實應該添加這樣的功能,因為頭部懸浮一般出現在兩級分組的列表,而我的GroupedRecyclerViewAdapter本來就已經實現了兩級分組的列表,再添加個頭部懸浮的功能也很合理啊。

為了給RecyclerView實現頭部懸浮的功能,我在GroupedRecyclerViewAdapter框架里添加了一個StickyHeaderLayout控件,由StickyHeaderLayout實現頭部懸浮效果并且管理懸浮吸頂的View。下面我會給出StickyHeaderLayout源碼,我在源碼中對StickyHeaderLayout的實現有了比較詳細的注釋,相信大家能很好的理解。由于StickyHeaderLayout是對GroupedRecyclerViewAdapter的功能拓展,它跟GroupedRecyclerViewAdapter密切相關。所以你在閱讀它的源碼前,需要先了解GroupedRecyclerViewAdapter,而且StickyHeaderLayout也是要與GroupedRecyclerViewAdapter一起使用的。要想了解GroupedRecyclerViewAdapter,請看我的另一篇文章:《Android 可分組的RecyclerViewAdapter
》。如果你只是想使用它的功能,而不需要了解它的實現原理,也可以直接訪問我的GitHub

StickyHeaderLayout的源碼:

/**
 * Depiction:頭部吸頂布局。只要用StickyHeaderLayout包裹{@link RecyclerView},
 * 并且使用{@link GroupedRecyclerViewAdapter},就可以實現列表頭部吸頂功能。
 * StickyHeaderLayout只能包裹RecyclerView,而且只能包裹一個RecyclerView。
 * <p>
 * Author:donkingliang
 * Dat:2017/11/14
 */
public class StickyHeaderLayout extends FrameLayout {

    private Context mContext;
    private RecyclerView mRecyclerView;

    //吸頂容器,用于承載吸頂布局。
    private FrameLayout mStickyLayout;

    //保存吸頂布局的緩存池。它以列表組頭的viewType為key,ViewHolder為value對吸頂布局進行保存和回收復用。
    private final SparseArray<BaseViewHolder> mStickyViews = new SparseArray<>();

    //用于在吸頂布局中保存viewType的key。
    private final int VIEW_TAG_TYPE = -101;

    //用于在吸頂布局中保存ViewHolder的key。
    private final int VIEW_TAG_HOLDER = -102;

    //記錄當前吸頂的組。
    private int mCurrentStickyGroup = -1;

    //是否吸頂。
    private boolean isSticky = true;

    public StickyHeaderLayout(@NonNull Context context) {
        super(context);
        mContext = context;
    }

    public StickyHeaderLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
    }

    public StickyHeaderLayout(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mContext = context;
    }

    @Override
    public void addView(View child, int index, ViewGroup.LayoutParams params) {
        if (getChildCount() > 0 || !(child instanceof RecyclerView)) {
            //外界只能向StickyHeaderLayout添加一個RecyclerView,而且只能添加RecyclerView。
            throw new IllegalArgumentException("StickyHeaderLayout can host only one direct child --> RecyclerView");
        }
        super.addView(child, index, params);
        mRecyclerView = (RecyclerView) child;
        addOnScrollListener();
        addStickyLayout();
    }

    /**
     * 添加滾動監聽
     */
    private void addOnScrollListener() {
        mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                // 在滾動的時候,需要不斷的更新吸頂布局。
                if (isSticky) {
                    updateStickyView();
                }
            }
        });
    }

    /**
     * 添加吸頂容器
     */
    private void addStickyLayout() {
        mStickyLayout = new FrameLayout(mContext);
        LayoutParams lp = new LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT,
                FrameLayout.LayoutParams.WRAP_CONTENT);
        mStickyLayout.setLayoutParams(lp);
        super.addView(mStickyLayout, 1, lp);
    }

    /**
     * 更新吸頂布局。
     */
    private void updateStickyView() {
        RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
        //只有RecyclerView的adapter是GroupedRecyclerViewAdapter的時候,才會添加吸頂布局。
        if (adapter instanceof GroupedRecyclerViewAdapter) {
            GroupedRecyclerViewAdapter gAdapter = (GroupedRecyclerViewAdapter) adapter;

            //獲取列表顯示的第一個項。
            int firstVisibleItem = getFirstVisibleItem();
            //通過顯示的第一個項的position獲取它所在的組。
            int groupPosition = gAdapter.getGroupPositionForPosition(firstVisibleItem);

            //如果當前吸頂的組頭不是我們要吸頂的組頭,就更新吸頂布局。這樣做可以避免頻繁的更新吸頂布局。
            if (mCurrentStickyGroup != groupPosition) {
                mCurrentStickyGroup = groupPosition;

                //通過groupPosition獲取當前組的組頭position。這個組頭就是我們需要吸頂的布局。
                int groupHeaderPosition = gAdapter.getPositionForGroupHeader(groupPosition);
                if (groupHeaderPosition != -1) {
                    //獲取吸頂布局的viewType。
                    int viewType = gAdapter.getItemViewType(groupHeaderPosition);

                    //如果當前的吸頂布局的類型和我們需要的一樣,就直接獲取它的ViewHolder,否則就回收。
                    BaseViewHolder holder = recycleStickyView(viewType);

                    //標志holder是否是從當前吸頂布局取出來的。
                    boolean flag = holder != null;

                    if (holder == null) {
                        //從緩存池中獲取吸頂布局。
                        holder = getStickyViewByType(viewType);
                    }

                    if (holder == null) {
                        //如果沒有從緩存池中獲取到吸頂布局,則通過GroupedRecyclerViewAdapter創建。
                        holder = gAdapter.onCreateViewHolder(mStickyLayout, viewType);
                        holder.itemView.setTag(VIEW_TAG_TYPE, viewType);
                        holder.itemView.setTag(VIEW_TAG_HOLDER, holder);
                    }

                    //通過GroupedRecyclerViewAdapter更新吸頂布局的數據。
                    //這樣可以保證吸頂布局的顯示效果跟列表中的組頭保持一致。
                    gAdapter.onBindViewHolder(holder, groupHeaderPosition);

                    //如果holder不是從當前吸頂布局取出來的,就需要把吸頂布局添加到容器里。
                    if (!flag) {
                        mStickyLayout.addView(holder.itemView);
                    }
                } else {
                    //如果當前組沒有組頭,則不顯示吸頂布局。
                    //回收舊的吸頂布局。
                    recycle();
                }
            }

            //這是是處理第一次打開時,吸頂布局已經添加到StickyLayout,但StickyLayout的高依然為0的情況。
            if (mStickyLayout.getChildCount() > 0 && mStickyLayout.getHeight() == 0) {
                mStickyLayout.requestLayout();
            }

            //設置mStickyLayout的Y偏移量。
            mStickyLayout.setTranslationY(calculateOffset(gAdapter, firstVisibleItem, groupPosition + 1));
        }
    }

    /**
     * 判斷是否需要先回收吸頂布局,如果要回收,則回收吸頂布局并返回null。
     * 如果不回收,則返回吸頂布局的ViewHolder。
     * 這樣做可以避免頻繁的添加和移除吸頂布局。
     *
     * @param viewType
     * @return
     */
    private BaseViewHolder recycleStickyView(int viewType) {
        if (mStickyLayout.getChildCount() > 0) {
            View view = mStickyLayout.getChildAt(0);
            int type = (int) view.getTag(VIEW_TAG_TYPE);
            if (type == viewType) {
                return (BaseViewHolder) view.getTag(VIEW_TAG_HOLDER);
            } else {
                recycle();
            }
        }
        return null;
    }

    /**
     * 回收并移除吸頂布局
     */
    private void recycle() {
        if (mStickyLayout.getChildCount() > 0) {
            View view = mStickyLayout.getChildAt(0);
            mStickyViews.put((int) (view.getTag(VIEW_TAG_TYPE)),
                    (BaseViewHolder) (view.getTag(VIEW_TAG_HOLDER)));
            mStickyLayout.removeAllViews();
        }
    }

    /**
     * 從緩存池中獲取吸頂布局
     *
     * @param viewType 吸頂布局的viewType
     * @return
     */
    private BaseViewHolder getStickyViewByType(int viewType) {
        return mStickyViews.get(viewType);
    }

    /**
     * 計算StickyLayout的偏移量。因為如果下一個組的組頭頂到了StickyLayout,
     * 就要把StickyLayout頂上去,直到下一個組的組頭變成吸頂布局。否則會發生兩個組頭重疊的情況。
     *
     * @param gAdapter
     * @param firstVisibleItem 當前列表顯示的第一個項。
     * @param groupPosition    下一個組的組下標。
     * @return 返回偏移量。
     */
    private float calculateOffset(GroupedRecyclerViewAdapter gAdapter, int firstVisibleItem, int groupPosition) {
        int groupHeaderPosition = gAdapter.getPositionForGroupHeader(groupPosition);
        if (groupHeaderPosition != -1) {
            int index = groupHeaderPosition - firstVisibleItem;
            if (mRecyclerView.getChildCount() > index) {
                //獲取下一個組的組頭的itemView。
                View view = mRecyclerView.getChildAt(index);
                float off = view.getY() - mStickyLayout.getHeight();
                if (off < 0) {
                    return off;
                }
            }
        }
        return 0;
    }

    /**
     * 獲取當前第一個顯示的item .
     */
    private int getFirstVisibleItem() {
        int firstVisibleItem = -1;
        RecyclerView.LayoutManager layout = mRecyclerView.getLayoutManager();
        if (layout != null) {
            if (layout instanceof LinearLayoutManager) {
                firstVisibleItem = ((LinearLayoutManager) layout).findFirstVisibleItemPosition();
            } else if (layout instanceof GridLayoutManager) {
                firstVisibleItem = ((GridLayoutManager) layout).findFirstVisibleItemPosition();
            } else if (layout instanceof StaggeredGridLayoutManager) {
                int[] firstPositions = new int[((StaggeredGridLayoutManager) layout).getSpanCount()];
                ((StaggeredGridLayoutManager) layout).findFirstVisibleItemPositions(firstPositions);
                firstVisibleItem = getMin(firstPositions);
            }
        }
        return firstVisibleItem;
    }

    private int getMin(int[] arr) {
        int min = arr[0];
        for (int x = 1; x < arr.length; x++) {
            if (arr[x] < min)
                min = arr[x];
        }
        return min;
    }

    /**
     * 是否吸頂
     *
     * @return
     */
    public boolean isSticky() {
        return isSticky;
    }

    /**
     * 設置是否吸頂。
     *
     * @param sticky
     */
    public void setSticky(boolean sticky) {
        if (isSticky != sticky) {
            isSticky = sticky;
            if (mStickyLayout != null) {
                if (isSticky) {
                    mStickyLayout.setVisibility(VISIBLE);
                    updateStickyView();
                } else {
                    recycle();
                    mStickyLayout.setVisibility(GONE);
                }
            }
        }
    }
}

StickyHeaderLayout具有以下的優點:
1、非ItemDecoration。StickyHeaderLayout的懸浮View是一個真實的View,而不是一個簡單的圖像,所以它可以懸浮任何的View。這有別于使用ItemDecoration實現懸浮效果的情況。
2、與GroupedRecyclerViewAdapter完美結合。懸浮布局直接交由Adapter創建和更新,這使得懸浮布局在顯示上和在處理上(事件監聽、業務邏輯等)都與列表中的item保持一致。你可以把懸浮布局看做是列表中的一個項。而且GroupedRecyclerViewAdapter支持多種item類型,所以懸浮布局也可以支持多種item類型。
3、StickyHeaderLayout對懸浮布局進行緩存復用,避免不必要的創建和更新、移除等操作。優化界面的繪制流暢。
4、使用簡單。你只需要使用StickyHeaderLayout包裹RecyclerView,并使用GroupedRecyclerViewAdapter實現兩級列表就可以了。

效果圖:

頭部吸頂的列表.gif

傳送門:https://github.com/donkingliang/GroupedRecyclerViewAdapter

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

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,692評論 25 708
  • 內容抽屜菜單ListViewWebViewSwitchButton按鈕點贊按鈕進度條TabLayout圖標下拉刷新...
    皇小弟閱讀 46,857評論 22 665
  • 你聽 雨的聲音 那是我對你的訴說 你聽 雨的聲音 那是我對你的呼喚 你聽 雨的聲音 那是心靈寂寞的敲響 你聽...
    黑冰洋諾閱讀 190評論 0 0
  • 對我來說我是全方位敞開的,所以無論我要什么,宇宙就能給我。這樣我就能完全敞開去接受。但如果你是關閉的,那么他就是進...
    axjl如意閱讀 360評論 0 0
  • 8月15日 走出托德峽谷,享用一頓豐足的午餐:雞蛋餅蔬菜塔吉鍋肉桂橘子和薄荷茶,面包餅子還是那么有嚼勁。要進沙漠了...
    沉吟檀香扇閱讀 448評論 3 1