RecyclerView双列表联动效果开发细节

Posted 苦逼程序员_

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了RecyclerView双列表联动效果开发细节相关的知识,希望对你有一定的参考价值。

目录

1.简介
2.静态联动效果实现
3.动态联动效果实现
4.无侵入式粘性Header

1.简介

双列表联动,一般由左右两个列表组成,在这个例子中,左侧列表显示分类,右侧列表显示每个分类的具体项,在每一组具体项的顶部由一个header进行区分。

点击左侧列表某一个分类时,右侧列表滚动到对应分类的首个header位置。滑动右侧列表显示下一个或上一个分类的数据时,左侧对应的分类选中状态自动变化。

最后会介绍RecyclerView通过ItemDecoration实现无侵入式的header吸顶效果。

2.静态联动效果实现

这里会把联动效果分为两部分来实现,第一部分先实现静态的联动,这里指的静态联动意思是当点击左侧的分类时右侧直接跳到对应分类的第一个item位置,跳转过程无滑动效果。静态联动效果的实现相对来说需要考虑的状态更少,专注于通用滑动逻辑的抽离,在完成第一步后再改造成动态联动效果即可。

联动列表的关键点:
两个列表中的每一个item都需要绑定对方列表中的关键item位置

假设现在有ABCD四个分类,对于左侧的分类列表来说,A item需要绑定右侧列表中对应A分类的首个item,它的作用是当用户点击左侧A分类的时候,可以拿到右侧列表中需要跳转的对应位置,右侧列表直接跳转到这个绑定的位置即可。

同样假设ABCD四个分类,对于右侧的列表来说,当用户向下滑动列表使B分类的数据到达屏幕可显示的第一个位置时,可以获取到第一个item绑定的左侧分类位置,左侧列表使该位置的item直接出现选中效果即可。

定义联动列表的通用bean,里面只有一个属性bindPosition,代表绑定另一个列表跳转的位置。

public class BaseBindPositionBean 

    public int bindPosition; //绑定另一个列表自动跳转位置

定义大分类列表BaseTitleAdapter和数据列表BaseValueAdapter,这里定义的Adapter使用了BaseRecyclerViewAdapterHelper库,专注于业务逻辑的编写,不了解的可以到github看看该库。

BaseTitleAdapter定义了一个abstract方法updateSelectedPos(),实现类只要在此位置做item的高亮实现即可。
再看BaseValueAdapter,里面好像什么内容都没有,是的它就是什么内容都没有,定义这个类是为了后面联动工具类中可以使用这个类型做强制转换处理一些通用逻辑。

public abstract class BaseTitleAdapter<T extends BaseBindPositionBean, K extends BaseViewHolder> extends BaseQuickAdapter<T, K> 

    public BaseTitleAdapter(int layoutResId, @Nullable List<T> data) 
        super(layoutResId, data);
    

    /**
     * 子类实现该方法做高亮逻辑处理
     * 点击title列表某一个item时会进入该回调
     * 当value列表滑动时会进入该回调方法
     * 需要注意recyclerView onScroll方法滑动时会一直触发,所以在方法内部需要判断pos是否与当前高亮pos相同,相同则不做处理,优化性能
     * @param pos 被选中高亮的item位置
     */
    public abstract void updateSelectedPos(int pos);

public abstract class BaseValueAdapter<T extends BaseBindPositionBean, K extends BaseViewHolder> extends BaseQuickAdapter<T, K> 

    public BaseValueAdapter(@Nullable List<T> data) 
        super(data);
    

下面是两个列表的bean实现类以及adapter实现类,这里拿年份月份两个列表来做示例,实现效果如下图。

title列表的bean实现以及adapter实现,代码非常简单给选中的item加个背景色即可,数据也只有一个年份。Adapter继承了BaseTitleAdapter并实现了updateSelectedPos方法,在方法内做了item高亮选中的处理。

//bean
public class YearTitleBean extends BaseBindPositionBean 
    public String year;

//adapter
public class YearTitleAdapter extends BaseTitleAdapter<YearTitleBean, BaseViewHolder> 

    private String selectedYear;

    public YearTitleAdapter(List<YearTitleBean> data) 
        super(R.layout.year_item, data);
        if (data != null && data.size() > 0) 
            selectedYear = data.get(0).year;
        
    

    @Override
    protected void convert(BaseViewHolder helper, YearTitleBean item) 
        String text = item.year + "年";
        helper.setText(R.id.year_text, text);
        if (item.year.equals(selectedYear)) 
            //设置选中
            helper.setBackgroundColor(R.id.year_text, mContext.getResources().getColor(android.R.color.white));
         else 
            helper.setBackgroundColor(R.id.year_text, mContext.getResources().getColor(android.R.color.transparent));
        
    

    public void updateSelectedPos(int pos) 
        YearTitleBean item = getItem(pos);
        if (item != null && !item.year.equals(selectedYear)) 
            selectedYear = item.year;
            notifyDataSetChanged();
        
    

接下来实现value列表的bean以及adapter。可以看到value列表的子view类型分两种,一种是显示年份的header,一种是显示月份的view,所以定义bean的时候拆了一个HeaderValueBean出来存储bean的类型和年份。对应的拆出了一个HeaderValueAdapter用于处理头部数据显示。

//带有头部的通用HeaderValueBean
public class HeaderValueBean extends BaseBindPositionBean 
    public static final int HEADER_TYPE = 0;
    public static final int ITEM_TYPE = 1;

    public int type; //item类型
    public String year; //头部显示年份

//value列表的月份bean
public class MonthDataBeanV3 extends HeaderValueBean 
    public String month;

    //子view类型的构造方法
    public MonthDataBeanV3() 
        this.type = ITEM_TYPE;
    

    //header类型的构造方法
    public MonthDataBeanV3(String year) 
        this.type = HEADER_TYPE;
        this.year = year;
    

//ValueAdapter基类,处理header的数据
public abstract class HeaderValueAdapter<T extends HeaderValueBean, K extends BaseViewHolder>
        extends BaseValueAdapter<T, K> 

    public HeaderValueAdapter(@Nullable List<T> data) 
        super(data);
        setMultiTypeDelegate(new MultiTypeDelegate<T>() 
            @Override
            protected int getItemType(T entity) 
                //根据你的实体类来判断布局类型
                return entity.type;
            
        );
        getMultiTypeDelegate()
                .registerItemType(T.HEADER_TYPE, R.layout.value_header_view)
                .registerItemType(T.ITEM_TYPE, R.layout.value_item_view);
    

    @Override
    protected void convert(K helper, T item) 
        if (item.type == T.HEADER_TYPE) 
            helper.setText(R.id.header_text, item.year + "年");
        
    

//ValueAdapter,处理子view的数据显示
public class ValueAdapter extends HeaderValueAdapter<MonthDataBeanV3, BaseViewHolder> 

        ValueAdapter(@Nullable List<MonthDataBeanV3> data) 
            super(data);
        

        @Override
        protected void convert(BaseViewHolder helper, MonthDataBeanV3 item) 
            super.convert(helper, item);
            if (item.type == HeaderValueBean.ITEM_TYPE) 
                helper.setText(R.id.primary_text, item.month + "月");
                if (helper.getAdapterPosition() == 1) 
                    helper.setText(R.id.current_tips, "本月");
                    helper.setVisible(R.id.current_tips, true);
                 else 
                    helper.setGone(R.id.current_tips, false);
                
            
        
    

bean和adapter都准备好了,接下来要注意的是数据的生成,不要忘了两个列表的bean基类都是BaseBindPositionBean,里面有一个字段是需要绑定对方列表item位置的。
下面是我们准备的一份简单json数据,从2019年6月到2018年1月的倒序数据,里面只有年份和月份。
生成数据时分别传入title列表和value列表的list,把原始数据转换成两个列表的数据,并且为各个bean分别绑定对方列表对应item的位置。

["year":2019,"month":6,"year":2019,"month":5,"year":2019,"month":4,"year":2019,"month":3,"year":2019,"month":2,"year":2019,"month":12,"year":2018,"month":11,"year":2018,"month":10,"year":2018,"month":9,"year":2018,"month":8,"year":2018,"month":7,"year":2018,"month":6,"year":2018,"month":5,"year":2018,"month":4,"year":2018,"month":3,"year":2018,"month":2,"year":2018,"month":1]
private void generateData(List<YearTitleBean> titleBeans, List<MonthDataBeanV3> valueBeans) 
    //生成month原始数据
    List<MonthDataBeanV3> sourceData = getJsonData();
    //生成month title 和 value 数据
    String currentGenerateYear = "";
    for (int i = 0; i < sourceData.size(); i++) 
        MonthDataBeanV3 valueDate = sourceData.get(i);
        if (!valueDate.year.equals(currentGenerateYear)) 
            currentGenerateYear = valueDate.year;
            
            //生成titleBean
            YearTitleBean baseTitleBean = new YearTitleBean();
            baseTitleBean.year = currentGenerateYear;
            //titleBean绑定value列表对应item的position
            baseTitleBean.bindPosition = valueBeans.size();
            titleBeans.add(baseTitleBean);
            
            //生成valueBean头部
            MonthDataBeanV3 seasonHeader = new MonthDataBeanV3(currentGenerateYear);
            //valueBean绑定title列表对应item的position
            seasonHeader.bindPosition = titleBeans.size() - 1;
            valueBeans.add(seasonHeader);
        
        //valueBean绑定title列表对应item的position
        valueDate.bindPosition = titleBeans.size() - 1;
        valueBeans.add(valueDate);
    

数据准备妥当,两个列表经过上面的逻辑已经可以分别显示出数据来了。
接下绑定两个列表的联合滚动:

  1. 给Title列表的item绑定点击事件,当点击Title列表的item时item高亮选中,右侧列表跟随跳转到到指定分类的第一个item位置。在RecyclerView中直接跳转的方法有scrollTo、scrollBy和scrollToPosition,scrollToPosition这个方法最适合处理直接跳转到指定item,但这个方法跳转的时候非常懒,它对RecyclerView的位移距离一定会尽可能的少,只要item出现在屏幕中就不会再做任何移动。这是什么意思呢?

    我们可以把这个跳转分成三种情况来看:
    1. item在屏幕可视范围的上方 : 调用scrollToPosition方法后使RecyclerView进行尽可能少的移动,使item显示在屏幕中。所以item会显示在屏幕可视范围的 第一个位置
    2. item在屏幕可视范围的下方 : 同理调用scrollToPosition方法进行尽可能少的移动。最后item显示在屏幕可视范围的 最后一个位置
    3. item已经在屏幕的可视范围内 : scrollToPosition懒到不想做任何事情,调用完以后item还是在 原来的位置

    为了使跳转的位置始终保持在屏幕顶部显示的第一个位置上,翻看了scrollToPosition的源码,发现它内部调用的其实是LayoutManager.scrollToPosition方法,而在LinearLayoutManager实现类里面发现有一个scrollToPositionWithOffset方法,该方法接受一个offset参数,它可以控制你想要跳转的item跳转后与顶部的距离。方法源码如下,只要拿到RecyclerView的LayoutManager强转为LinearLayoutManager直接调用scrollToPositionWithOffset方法并传入0作为offset即可跳转item并置于列表顶部

    /**
     * If you are just trying to make a position visible, use @link #scrollToPosition(int).
     *
     * @param position Index (starting at 0) of the reference item.
     * @param offset   The distance (in pixels) between the start edge of the item view and
     *                 start edge of the RecyclerView.
     */
    public void scrollToPositionWithOffset(int position, int offset) 
        mPendingScrollPosition = position;
        mPendingScrollPositionOffset = offset;
        if (mPendingSavedState != null) 
            mPendingSavedState.invalidateAnchor();
        
        requestLayout();
    
    
  2. Value列表设置ScrollListener滚动监听,当Value列表进行滚动时,获取列表中第一个可见的item,从item中拿到绑定Title列表的对应position,直接调用TitleAdapter实现的abstract方法updateSelectedPos传入position设置高亮。
    ps:这里遇到一个坑,从RecyclerView获取第一个可见item最初使用的是RecyclerView.getChildLayoutPosition(mRecyclerView.getChildAt(0)),但发现这种使用方式经常出现不准确的情况,在某些滑动情况下RecyclerView预加载了一页内容,导致拿到的第一个item并不是屏幕中显示的第一个item,后续验证了RecyclerView.getChildCount()也会出现类似的不准确现象,在使用的时候尽量避开这两个api。获取第一个可见item从RecyclerView拿到LayoutManager强转为LinearLayoutManager调用findFirstVisibleItemPosition()方法可以拿到准确的首个可见位置。

列表联动的绑定方法代码如下:

/**
 * Created by kejie.yuan
 * Date: 2019/3/20
 * Description: 联动列表工具类
 */
public class LinkageScrollUtil 

    public static void bindStaticLinkageScroll(RecyclerView titleRv, final RecyclerView valueRv) 
        //初始化联合滚动效果
        final BaseTitleAdapter titleAdapter = (BaseTitleAdapter) titleRv.getAdapter();
        titleAdapter.setOnItemClickListener(new BaseQuickAdapter.OnItemClickListener() 
            @Override
            public void onItemClick(BaseQuickAdapter adapter, View view, int position) 
                BaseBindPositionBean item = (BaseBindPositionBean) titleAdapter.getItem(position);
                if (item != null) 
                    ((LinearLayoutManager) valueRv.getLayoutManager()).scrollToPositionWithOffset(item.bindPosition, 0);
                    titleAdapter.updateSelectedPos(position);
                
            
        );
        final BaseValueAdapter valueAdapter = (BaseValueAdapter) valueRv.getAdapter();
        valueRv.addOnScrollListener(new RecyclerView.OnScrollListener() 
            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) 
                //获取右侧列表的第一个可见Item的position
                int topPosition = ((LinearLayoutManager) valueRv.getLayoutManager()).findFirstVisibleItemPosition();
                // 如果此项对应的是左边的大类的index
                BaseBindPositionBean item = (BaseBindPositionBean) valueAdapter.getItem(topPosition);
                if (item != null) 
                    int bindPos = item.bindPosition;
                    titleAdapter.updateSelectedPos(bindPos);
                
            
        );
    

//通过工具类绑定联合滚动
LinkageScrollUtil.bindLinkageScroll(titleList, valueList);

静态联动的实现效果如下:

3.动态联动效果实现

经过上面的步骤,静态联动效果已经实现了,但是实际上手使用效果着实一般,因为点击时界面是忽然跳动的,如果value列表中的item内容很相似的话,会给人一种界面没有任何变化的错觉,所以接下来是优化联动效果使它的跳转变成滚动到达目标位置。

在改写成动态联动效果前,当然是参考一下网上现有的实现方案,发现网上大部分公开的实现方法效果都并不完善。很多方法都是使用smoothScrollToPosition()配合scrollBy()进行最多两次滑动达到目标item渐渐滑动到顶部的效果,为什么要两个API配合使用呢?在静态联动效果的介绍中说过scrollToPosition()方法,这个很多方法都是使用smoothScrollToPosition()跟scrollToPosition()是一样懒的,所以需要区分三种情况进行配合使用才能达到item贴顶的效果。

这种方案的缺点

  1. Item贴顶的代码逻辑比较复杂,需要两个api配合使用才能达到效果。
  2. 直接调用smoothScrollToPosition(),列表的滑动速度太快,并没有流畅平滑的滑动。
  3. 如果目标Item的位置距离非常远,使用smoothScrollToPosition()方法需要滑动好几秒的时间才能出现在屏幕上。

改进第一第二个缺点,我们可以从RecyclerView的smoothScrollToPosition()方法入手,通过源码可以看到内部是调用了LinearLayoutManager的smoothScrollToPosition()方法,而在方法内部初始化了一个LinearSmoothScroller处理滑动动画。LinearSmoothScroller的内部属性定义了它的滑动速度以及目标Item滑动后Snap的位置。所以重写LayoutManager内部的Scroller,不但可以控制滑动速度达到缓慢平滑的滑动,还可以控制滑动后Item的黏贴位置。

这里简单介绍一下Item滑动后黏贴位置的常量,分别有三种:

  1. SNAP_TO_START 滑动后Item直接黏贴到顶部 (完美解决第一个问题)
  2. SNAP_TO_END 滑动后Item黏贴到底部
  3. SNAP_TO_ANY 默认值,即静态联动中介绍过的尽量懒的滑动处理方式

重写的LinearLayoutManager代码如下:

public class LinearLayoutManagerWithScrollTop extends LinearLayoutManager 

    public LinearLayoutManagerWithScrollTop(Context context) 
        super(context);
    

    public LinearLayoutManagerWithScrollTop(Context context, int orientation, boolean reverseLayout) 
        super(context, orientation, reverseLayout);
    

    public LinearLayoutManagerWithScrollTop(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) 
        super(context, attrs, defStyleAttr, defStyleRes);
    

    @Override
    public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) 
        TopSnappedSmoothScroller topSnappedSmoothScroller = new TopSnappedSmoothScroller(recyclerView.getContext());
        topSnappedSmoothScroller.setTargetPosition(position);
        startSmoothScroll(topSnappedSmoothScroller);
    

    class TopSnappedSmoothScroller extends LinearSmoothScroller 

        public TopSnappedSmoothScroller(Context context) 
            super(context);
        

        @Nullable
        @Override
        public PointF computeScrollVectorForPosition(int targetPosition) 
            return LinearLayoutManagerWithScrollTop.this.computeScrollVectorForPosition(targetPosition);
        

        /**
         * MILLISECONDS_PER_INCH 默认为25,即移动每英寸需要花费25ms,如果你要速度变慢一点,把数值调大,注意这里的单位是f
         */
        protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) 
            return 50f / displayMetrics.densityDpi;
        


        @Override
        protected int getVerticalSnapPreference() 
            return SNAP_TO_START;
        
    

重写了自定义的LinearLayoutManager后,设置给ValueList使用。

以上是关于RecyclerView双列表联动效果开发细节的主要内容,如果未能解决你的问题,请参考以下文章

基于Android的医院预下单叫号排队系统

数据可视化之powerBI基础(十四)Power BI中创建联动切片器

细节处理(电商平台数据可视化实时监控系统项目)

iOS开发tableview二级联动的细节实现中注意的细节总结

简单实现仿某宝地址选择三级联动样式

[数据可视化大屏教程] 选项卡-轮播切换联动更新图表数据