RecyclerView使用StaggeredGridLayoutManager布局的粘性头部实现

介绍RecyclerView中使用StaggeredGridLayoutManager布局时粘性头部的实现

我们经常在使用App的过程中有城市的列表选择,我们会发现App在滑动的过程中,会有粘性头部一直悬浮在最上端,其中实现的方法有很多种,当然,这里我们不讨论使用ListView或者GridView控件,也不讨论RecyclerView使用LinearLayoutManager或者GridLayoutManager布局时的实现方式。本文讨论的重点是使用StaggeredGridLayoutManager布局时的粘性头部的实现方式。本文已展示本地图片库的图片作为例子来展开。

先上一下效果图

粘性头部

扫描本地图片库

public LocalPictureDateResult loadAllLocalPictures() {
        LocalPictureDateResult result = new LocalPictureDateResult(mContext);
        Uri mImageUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
        ContentResolver mContentResolver = mContext.getContentResolver();
        Log.i(TAG, mImageUri.getPath());
        // query only jpeg and png image type files
        Cursor mCursor = mContentResolver.query(mImageUri, null,
                MediaStore.Images.Media.MIME_TYPE + "=? or "
                        + MediaStore.Images.Media.MIME_TYPE + "=?",
                new String[]{"image/jpeg", "image/png"}, MediaStore.Images.Media.DATE_MODIFIED + " DESC");
        if (mCursor != null) {
            while (mCursor.moveToNext()) {
                //retrive image path
                String path = mCursor.getString(mCursor
                        .getColumnIndex(MediaStore.Images.Media.DATA));
                int width = mCursor.getInt(mCursor
                        .getColumnIndex(MediaStore.Images.Media.WIDTH));
                int height = mCursor.getInt(mCursor
                        .getColumnIndex(MediaStore.Images.Media.HEIGHT));
                long modifiedData = mCursor.getLong(mCursor.
                       getColumnIndex(MediaStore.Images.Media.DATE_MODIFIED));
                long addedData = mCursor.getLong(mCursor.
                       getColumnIndex(MediaStore.Images.Media.DATE_ADDED));
                if (path==null || "".equals(path) || width == 0 || height == 0 || modifiedData == 0) {
                    continue;
                }
                LocalPictureDetailInfo lpi = new LocalPictureDetailInfo(path, width, height, addedData * 1000, modifiedData * 1000);
                result.add(lpi);
            }
            mCursor.close();
        }
        return result;
    }

这里返回一个LocalPictureDateResult对象,里面存放了扫描出来的本地图片文件信息集合

这里我们看下上面代码中的result.add(lpi)方法

**LocalPictureDateResult.java** 

    /**
     * save the positions of title in the data collections
     */
    ArrayList mArrayTitlePos;

    /**
     * title collection
     */
    private HashSet<String> titleSet ;

    /**
     * pictures collection,include title and picture details
     */
    ArrayList<WrapLocalPictureDetailInfo> mLocalPictureInfos;

   /**
     * add local picture details
     *
     * @param detailInfo
     */
    public void add(LocalPictureDetailInfo detailInfo) {
        String dateTimeStr = mDateParseFilter.parse(detailInfo.getModifiedDate());
        if (!titleSet.contains(dateTimeStr)) {
            titleSet.add(dateTimeStr);
            // save title position
            mArrayTitlePos.add(mLocalPictureInfos.size());

            // add title object
            WrapLocalPictureDetailInfo titleInfo = new WrapLocalPictureDetailInfo(dateTimeStr, DATA_TYPE_TITLE);
            mLocalPictureInfos.add(titleInfo);
            // add content object
            WrapLocalPictureDetailInfo contentInfo = new WrapLocalPictureDetailInfo(detailInfo);
            mLocalPictureInfos.add(contentInfo);
        } else {
            mLocalPictureInfos.add(new WrapLocalPictureDetailInfo(detailInfo));
        }
    }

上面这段代码,把扫描出来的本地图片集,通过图片的最后修改时间,分离出时间段名称,并且把时间段名称作为数据的一部分加入到最后的图片集合中,这样图片集合中就包含了图片数据和标题数据,并且我们也有了每个标题在图片集合中的位置,这样有利于我们后面计算和寻找标题做准备。

主页面布局

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v4.widget.SwipeRefreshLayout
        android:id="@+id/srl_root"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <android.support.v7.widget.RecyclerView
            android:id="@+id/rv_content"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        </android.support.v7.widget.RecyclerView>

    </android.support.v4.widget.SwipeRefreshLayout>

    <LinearLayout
        android:id="@+id/header_one"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:visibility="invisible">

        <include layout="@layout/item_title_local_picture" />
    </LinearLayout>

</FrameLayout>

上面我们看到最外层用了个FrameLayout,用了个LinearLayout作为粘性头部,引入的头部布局和RecyclerView中的头部布局一样

RecyclerView的普通头部制作

头部布局文件

<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/tv_title"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@android:color/holo_red_light"
    android:gravity="center_vertical"
    android:textColor="@android:color/black"
    android:textSize="20sp">

</TextView>

设置RecylerView的adapter和layoutmanager

    int mColumns = 3;
    // 
    mLayoutManager = new StaggeredGridLayoutManager(mColumns, StaggeredGridLayoutManager.VERTICAL);
    mAdapter = new LocalPicturesAdapter(this, mMainPresenter.getLocalPictureDatas(), mColumns);
    // set RecylerView's adapter and layoutmanager
    mContentView.setLayoutManager(mLayoutManager);
    mContentView.setAdapter(mAdapter);
    mContentView.setHasFixedSize(true);

LocalPicturesAdapter类,主要根据数据中的type类型来显示不同的

public class LocalPicturesAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements View.OnClickListener {

    Activity mContext;

    List<WrapLocalPictureDetailInfo> mImagePaths;

    int columnNums = 1;

    private OnRecyclerViewItemClickListener mOnItemClickListener = null;

    public LocalPicturesAdapter(Activity context, List<WrapLocalPictureDetailInfo> imagePaths, int columnNums) {
        this.mContext = context;
        this.mImagePaths = imagePaths;
        this.columnNums = columnNums;
    }

    public void setImagePaths(List<WrapLocalPictureDetailInfo> imagePaths){
        this.mImagePaths = imagePaths;
    }

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        RecyclerView.ViewHolder viewHolder;
        View view;
        if (viewType == DATA_TYPE_TITLE) {
            view = mContext.getLayoutInflater().inflate(R.layout.item_title_local_picture,null);
            viewHolder = new LocalPicturesTitleHolder(view);
        } else if (viewType == DATA_TYPE_CONTENT) {
            view = mContext.getLayoutInflater().inflate(R.layout.item_content_local_picture,null);
            viewHolder = new LocalPicturesContentHolder(view);
        } else {
            view = mContext.getLayoutInflater().inflate(R.layout.item_content_local_picture,null);
            viewHolder = new LocalPicturesContentHolder(view);
        }
        if (view != null) {
            // add listener
            view.setOnClickListener(this);
        }
        return viewHolder;
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
        if (viewHolder.getItemViewType() == DATA_TYPE_TITLE) {
            LocalPicturesTitleHolder holder = (LocalPicturesTitleHolder) viewHolder;
            holder.itemView.setTag(position);
            if (holder.mTvTitle != null) {
                WrapLocalPictureDetailInfo pictureDetailInfo = mImagePaths.get(position);
                // set title layoutparams
                StaggeredGridLayoutManager.LayoutParams layoutParams = new StaggeredGridLayoutManager.LayoutParams(
                        ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
                // set title full span
                layoutParams.setFullSpan(true);
                holder.itemView.setLayoutParams(layoutParams);
                holder.mTvTitle.setText(pictureDetailInfo.getDataTitle());
            }
        } else if (viewHolder.getItemViewType() == DATA_TYPE_CONTENT) {
            LocalPicturesContentHolder holder = (LocalPicturesContentHolder) viewHolder;
            holder.itemView.setTag(position);
            if (holder.mImageView != null) {
                WrapLocalPictureDetailInfo pictureDetailInfo = mImagePaths.get(position);
                ........
                ........
                ........
                // use glide load the image into ImageView
                Glide.with(mContext).load(new File(pictureDetailInfo.getPath())).into(holder.mImageView);
            }
        }
    }

    ......
    ......
    ......
}

我们通过显示普通title的方法是使用设置itemview的StaggeredGridLayoutManager.LayoutParams来实现的,并且setFUllSpan(true)用来让title显示一整行

RecylerView滚动监听

public RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() {

        // get the recyclerview first visable item's position,return the size same as span count
        int mFirstVisiblePosition[] = new int[mColumns];
        // get the recyclerview last visable item's position,return the size same as span count
        int mLastVisiblePosition[] = new int[mColumns];

        // sticky head view height
        private int mStickyHeadHeight = 0;

        @Override
        public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
            super.onScrollStateChanged(recyclerView, newState);
        }

        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy);
            if (dy == 0) {
                // the first time show the recyclerview items
                mLayoutManager.findFirstVisibleItemPositions(mFirstVisiblePosition);
                int titlePosision = getMinVisablePosition(mFirstVisiblePosition);
                if (titlePosision >= 0) {
                    // get the item object
                    WrapLocalPictureDetailInfo wrapLocalPictureDetailInfo = mAdapter.getItemObject(titlePosision);

                    if (wrapLocalPictureDetailInfo != null) {
                        // set the first title view text content
                        ((TextView) mHeaderOneView.getChildAt(0)).setText(wrapLocalPictureDetailInfo.getDataTitle());
                        mHeaderOneView.setVisibility(View.VISIBLE);
                        // set the current title position into view tag object
                        mHeaderOneView.setTag(titlePosision);
                        // retrive the title view height,and set into variable
                        mStickyHeadHeight = mHeaderOneView.getMeasuredHeight();
                    }
                }
            } else if (dy != 0) {  //  pull down the recyclerview then dy<0 , pull up the recyclerview then dy>0

                mLayoutManager.findFirstVisibleItemPositions(mFirstVisiblePosition);
                int minVisablePosition = getMinVisablePosition(mFirstVisiblePosition);

                mLayoutManager.findLastVisibleItemPositions(mLastVisiblePosition);
                int maxVisablePosition = getMaxVisablePosition(mLastVisiblePosition);

                if (minVisablePosition < 0) {
                    return;
                }
                /**
                 * get one title position before current minimum visable item position
                 */
                int beforeFirstItemTitlePosition = mMainPresenter.findBeforeTitlePosition(minVisablePosition);
                /**
                 * get the title position after current minimum visable item position
                 */
                int afterFirstItemTitlePosition = mMainPresenter.findAfterTitlePosition(minVisablePosition);

                // when next title item position after the current minimum visable exist,and not equals current item position
                if (afterFirstItemTitlePosition != Integer.MIN_VALUE && afterFirstItemTitlePosition != minVisablePosition) {
                    // determine whether next title item after the current minimum visable position is visable in the recyclerview
                    if (afterFirstItemTitlePosition <= maxVisablePosition) {
                        // it means that next title item is visable in the recycleview now
                        // find that suitable next title view
                        View nextTitleView = findView(afterFirstItemTitlePosition);
                        if (nextTitleView != null) {
                            float yxis = nextTitleView.getY();
                            // if next title view is scroll into first title view's area
                            if (yxis <= mStickyHeadHeight) {
                                // then fix the first title scroll y
                                mHeaderOneView.scrollTo(0, (int) (mStickyHeadHeight - yxis));
                            } else {
                                // others,next title away from the first title view
                                // then fix first title view scroll y
                                mHeaderOneView.scrollTo(0, 0);
                            }
                            // set visable to the header view always
                            mHeaderOneView.setVisibility(View.VISIBLE);
                        }
                    }
                }


                /**
                 * Determine whether need to change the title
                 */
                // when title item before the current maxinum visable position exist,and position not equals the current header view's tag
                if (beforeFirstItemTitlePosition != Integer.MIN_VALUE && mHeaderOneView.getTag() != null && (int) mHeaderOneView.getTag() != beforeFirstItemTitlePosition) {
                    /**
                     * it means that should change the title content
                     */
                    ((TextView) mHeaderOneView.getChildAt(0)).setText("");
                    // always show the title item that before the current maxinum visable position
                    WrapLocalPictureDetailInfo wpdi = mMainPresenter.getLocalPictureDatas().get(beforeFirstItemTitlePosition);
                    // set new title content
                    ((TextView) mHeaderOneView.getChildAt(0)).setText(wpdi.getDataTitle());
                    if (dy > 0) {
                        // if user pull up the recyclerview,fix the scroll y.Sometimes,because of the next title item may be not visable in current recyclerview,
                        // so,we should force set header view visable once again
                        mHeaderOneView.setVisibility(View.VISIBLE);
                        mHeaderOneView.scrollTo(0, 0);
                    }
                    // set title position in the tag
                    mHeaderOneView.setTag(beforeFirstItemTitlePosition);
                    // retrive the sticky head height
                    mStickyHeadHeight = mHeaderOneView.getMeasuredHeight();
                }
            }
        }

        ......
        ......
        ......
    };

其中最主要的原理是在RecylerView滚动的时候,判断dy的大小,查找当前RecylerView中用户可视的最小的item位置,通过之前我们初始化数据的时候,分离出的每个标题在数据集中的位置,底层使用二分查找方法,找到当前可视的最小item位置之前(下文称item之前)和之后(item之后)的标题item的位置信息。如果存在item之前的标题信息,判断和当前已经显示的粘性头部tag中存储的位置不一样,那么用新的头部信息替换当前粘性头部;如果在当前页面搜索到item之后的标题视图,获取该视图对象,判断与已经显示的粘性头部底部位置判断,如果超过了粘性头部,则把粘性头部用scrollTo方法进行向上滑动,就会出现粘性头部滑动的效果,如果没有超过,那么什么都不做

总结

可能看代码还是有点抽象,总结起来就是在初始化数据时,将标题也作为数据项加入到数据集合中,然后在Adapter中动态的判断是标题还是普通内容,采用不同的布局页面和布局,结合滚动时,通过当前页面最小位置的判断之前和之后标题item的位置,来控制滑动或者切换粘性头部,我把示例工程放在了github上RecyclerStaggeredStickyHeaderView,欢迎fork或者start。最后如果发现文章有什么问题或者写的不好的,欢迎留言交流,当然,也欢迎点赞~~~