XTableView——一个带侧滑菜单的二维表格组件

最近在开发一个二维滚动表格的类似于Excel效果的组件并且同时支持侧滑,网上大部分的实现场景都是没有带侧滑菜单的,由于刚好侧滑手势与横向滚动冲突,所以需要同时兼容二者,只能自己动手了,最终部分效果如下:


XTableView.gif

 

实现思路

可以看到,想要实现的效果,是无论表格如何滚动,顶部第一行一直处于屏幕的上端,左边第一列一直处于屏幕的左端,就类似于Excel那样,行头和列头的位置是不变的,只有内容区域可以上下左右滚动


表格截图

二维列表示意图.png

基于这样一个基本的规律,一共尝试了以下几种方案:

1)列头+行头+基于GridLayoutManager的内容区

即分为三部分,列头部采用 RecyclerViewHorizontalScrollView ,行头部采用 RecyclerView ,除了列头和行头之外的内容区域,直接采用 GridLayoutManager 去实现,这样的好处是不用自己维护多个 RecyclerView 的同步滚动,
由于侧滑是要整体平移腾出空间显示菜单,但是这种方案行头和内容是分开来的,并不是作为一个整体,所以当出现侧滑菜单的时候会有显示的问题。

2)纵向RecyclerView+多个横向RecyclerView

即每一行都是一个 RecyclerView ,然后整个纵向列表又是一个 RecyclerView 的,
这种方案的好处是横向和纵向均有复用,但是会有个问题,调用 scrollTo 同步滚动所有子 RecyclerView 的时候,无法惯性滚动,横向滑动体验不太好。

3)纵向RecyclerView+多个LinearLayout+Scroller滑动处理

与双 RecyclerView 的区别在于,每个横向均采用 LinearLayout 实现,但 HorizontalScrollView 本身 scrollTo 是没有惯性的,需要依靠 Scrollerfling 来实现惯性的处理。

综上决定采用第三种方案,通过Scroller自行处理滑动逻辑以及滑动边界值的限定,在纵向数据量大的情况也能流畅滚动。
 

如何实现同步滚动?

这里写了一个 ScrollHelper 类,专门协调所有横向LinearLayout的同步滑动逻辑和监听。
由于是 RecyclerView,所以要实现同步滚动,所以需要记录当前的全局Scroll值,作为所有横向列表滑动距离的唯一参考:

public class ScrollHelper {
    private int mScrollX;
    public void setScrollX(int x) {
        mScrollX = x;
    }

    public int getCurScrollX() {
        return mScrollX;
    }
}

另外在 ScrollHelper 中维护一个集合,集合里面维护已经创建的item视图:

private SparseArray<TableItemView> itemViewsMap;

然后在adapter每次onBindViewHolder的时候,将item视图以下标position为key,存入集合中,并且滑动至当前的全局mScrollX值。每次onCreateViewHolder的时候,将每个横向布局跟ScrollHelper关联起来,即每个横向LinearLayout都持有这个ScrollHelper实例,以便其中一个LinearLayout滑动的时候,可以通过ScrollHelper中维护的集合通知其它所有LinearLayout。
比如其中某个LinearLayout的滑动触发了其 computeScroll 方法:

@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        mScrollHelper.notifyScroll(scrollDistance);
        mScrollHelper.setScrollX(scrollDistance);
        postInvalidate();
    }
    super.computeScroll();
}

将当前这个LinearLayout滚动的最新值去更新全局scrollX值,并且通知 ScrollHelper 触发其它LinearLayout同步滚动:

public class ScrollHelper {
    ...
    public void notifyScroll(int x) {
        for (int i = 0; i < itemViewsMap.size(); i++) {
            itemViewsMap.get(itemViewsMap.keyAt(i)).notifyScroll(x);
        }
    }
    ...
}

当然,记得在View被销毁的时候清除集合数据,避免泄露
 

如何实现惯性滚动?

主要借助于 Scrollerfling 方法,fling可以让View在滑动到某个位置之后以一定的速度惯性滚到一段距离。因此在收到 MotionEvent.ACTION_UP 事件时(即手指抬起时),将惯性滚动前的初始ScrollX值作为fling的起始参数,然后依旧是在 computeScroll 方法里面获取最新滚动到的ScrollX值去同步给其他LinearLayout,实现所有横向列表整体惯性。

如何处理侧滑与横向列表的滑动冲突?

这里的侧滑实现是基于 SwipeRecyclerView 这个库的基础上进行修改,所以这里自定义一个 RecyclerView 继承于 SwipeRecyclerView 重写其 onInterceptTouchEvent 方法,每次收到 MotionEvent.ACTION_MOVE 事件的时候,判断当前内容区是否滑到了最左侧界线,分别做如下处理:

如果此时位于最左侧界线,判断当前手势是左滑还是右滑,如果是右滑,那就通过super.onInterceptTouchEvent(e)交由SwipeRecyclerView去处理侧滑逻辑,如果是左滑,则需要return true拦截事件,然后在onTouchEvent中做横向滚动处理。
如果当前不在左侧界线,说明无论左滑还是右滑,横向列表皆有滑动的空间,同样return true拦截事件交由onTouchEvent中做横向滚动处理。

 

如何使用

在项目根目录的build.gradle添加:

allprojects {
    repositories {
        ...
        maven { url 'https://jitpack.io' }
    }
}

在项目的build.gradle添加如下依赖:

implementation 'com.github.GitHubZJY:XTableView:v1.0.0'

1.在xml中引用XTableView

<com.zjy.xtableview.XTableView
        android:id="@+id/table_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:swipeLayout="@layout/table_swipe_menu_layout"
        app:headerHeight="50dp"
        app:rowHeight="80dp"
        app:cellWidth="130dp">

</com.zjy.xtableview.XTableView>

2.在代码中初始化XTableView

ITableView vTableView = findViewById(R.id.table_view);
//设置是否支持长按拖动列表项
vTableView.setLongPressDragEnable(true);
//设置是否支持侧滑菜单
vTableView.setSwipeEnable(true);

3.绑定数据

绑定数据的设计灵感来自于RecyclerView的adapter设计,与数据相关的操作均通过 XTableAdapter 作为中间者来进行:

1.自定义一个adapter继承于 XTableAdapter ,通过实现adapter的方法,自定义表格样式。XTableAdapter 的各个方法作用如下:

onBindHeader 创建列头部视图
onCreateTableItem 创建每一个单元格的视图
onBindTableItem 绑定每一个单元格的视图数据
onCreateRowHeader 创建每一行的头部视图
onBindRowHeader 绑定每一行的头部视图数据

public class CustomTableAdapter extends XTableAdapter<String, TableRowModel<TableRowHeaderModel, TableRowCellModel>>{

    public CustomTableAdapter(Context context) {
        super(context);
    }

    @Override
    public View onBindHeader(int position, String t) {
        TextView cellTv = new TextView(getContext());
        cellTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
        cellTv.setGravity(Gravity.CENTER);
        cellTv.setText(t);
        return cellTv;
    }

    @Override
    public View onCreateTableItem(int position) {
        View view = LayoutInflater.from(getContext()).inflate(R.layout.table_item_cell_layout, null);
        TextView cellTv = view.findViewById(R.id.cell_tv);
        cellTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
        cellTv.setGravity(Gravity.CENTER);
        return view;
    }

    @Override
    public void onBindTableItem(int position, View view, TableRowModel<TableRowHeaderModel, TableRowCellModel> rowModel) {
        final TableRowCellModel cellModel = rowModel.getRowData().get(position);
        TextView cellTv = view.findViewById(R.id.cell_tv);
        cellTv.setTextColor(cellModel.isRise() ?
                getColor(R.color.table_view_rise_txt_color)
                : getColor(R.color.table_view_fall_txt_color));
        cellTv.setText(cellModel.getContent());
        cellTv.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Toast.makeText(getContext(), cellModel.getContent(), Toast.LENGTH_SHORT).show();
            }
        });
    }

    @Override
    public View onCreateRowHeader(int position) {
        return LayoutInflater.from(getContext()).inflate(R.layout.table_item_title_layout, null);
    }

    @Override
    public void onBindRowHeader(int position, View view, TableRowModel<TableRowHeaderModel, TableRowCellModel> rowModel) {
        TextView vTitle = view.findViewById(R.id.title_tv);
        TextView vDetail = view.findViewById(R.id.detail_tv);
        String title = rowModel.rowHeader.getTitle();
        String detail = rowModel.rowHeader.getDetail();
        vTitle.setText(TextUtils.isEmpty(title) ? "" : title);
        vDetail.setText(TextUtils.isEmpty(detail) ? "" : detail);
    }
}
2.通过adapter的 bindData 绑定自定义数据源.
/**
 * 更新表格数据
 * @param header 左上角的表头数据
 * @param columnHeader 列头部数据集合
 * @param tableData 每一行的数据集合
 */
public void bindData(String header, List<T> columnHeader, List<H> tableData)
3.将adapter设置给XTableView.
vTableView.setTableAdapter(adapter);

 

4.数据变更时刷新视图

数据变更也是通过adapter对象来进行:
如果是所有数据替换,可调用 bindData 方法设置新的数据,然后通过 notifyDataSetChanged 进行更新。
如果是单条数据刷新,可调用 notifyItemData(int position, H data) 进行更新,position是对应的下标,data为新的数据。
如果是单条数据插入,可调用 notifyInsertData(int position, H data) 进行更新,position是对应的下标,data为新的数据。
 

5.设置监听回调

vTableView.setTableListener(new XTableListener() {
    @Override
    public void clickSwipeMenu(int position) {
        //点击菜单
        ...
    }

    @Override
    public void onItemMove(int fromPos, int toPos) {
        //拖拽Item
        ...
    }
});

 

6.其他属性

本库也提供了LayoutManager的一些配置,例如: setReverseLayoutsetStackFromEndscrollToPositionscrollToPositionWithOffset 等,后续会再根据需要进行扩充。

结语

完整代码已传至Github:XTableView——一个基于RecyclerView+Scroller实现的二维表格组件,同时支持侧滑菜单、拖动调整列表顺序等拓展功能,本库的自定义样式和数据类型基于适配器的模式设计,后续会继续更新,提升组件的定制性和可拔插性,欢迎issue和star~
 

欢迎关注 Android小Y 的简书,更多Android精选自定义View

『Android自定义View实战』实现一个小清新的弹出式圆环菜单
『Android自定义View实战』玩转PathMeasure之自定义支付结果动画
『Android自定义View实战』自定义弧形旋转菜单栏——卫星菜单
『Android自定义View实战』自定义带入场动画的弧形百分比进度条

GitHubGitHub-ZJYWidget
简 书Android小Y
在GitHub上建了一个炫酷自定义View的集合ZJYWidget,主要是平时实现的一些实用的自定义View源码及demo,会长期维护,欢迎Star~ 如有不足之处或建议还望指正,相互学习,相互进步~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345