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阅读 199,271评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,725评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,252评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,634评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,549评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,985评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,471评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,128评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,257评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,233评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,235评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,940评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,528评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,623评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,858评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,245评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,790评论 2 339

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,246评论 25 707
  • 内容抽屉菜单ListViewWebViewSwitchButton按钮点赞按钮进度条TabLayout图标下拉刷新...
    皇小弟阅读 46,682评论 22 664
  • 你听 雨的声音 那是我对你的诉说 你听 雨的声音 那是我对你的呼唤 你听 雨的声音 那是心灵寂寞的敲响 你听...
    黑冰洋诺阅读 182评论 0 0
  • 对我来说我是全方位敞开的,所以无论我要什么,宇宙就能给我。这样我就能完全敞开去接受。但如果你是关闭的,那么他就是进...
    axjl如意阅读 343评论 0 0
  • 8月15日 走出托德峡谷,享用一顿丰足的午餐:鸡蛋饼蔬菜塔吉锅肉桂橘子和薄荷茶,面包饼子还是那么有嚼劲。要进沙漠了...
    沉吟檀香扇阅读 419评论 3 1