前言
最近项目中接触的表格设计需求有点多,表格很简单,但是这个表格的滑动需求是:左右滑动时行标题那一列不能动,列标题是可以左右滑动的;同理,上下滑动时列标题那一列不能动,行标题是可以上下滑动的。自己也研究了网上一些博客和开源项目,总体上来说大同小异,最核心的东西就是如何控制滑动,现在先来看看效果:
分析
我们先来分析左右方向的滑动,左边的row头部和右边cell(这里指的是行列组成的右下角部分)内容是分离的,左边的row头先不管,cell内容(不包括上方的列标题)可以左右滑动我们可以选择HorizontalScrollView作为cell的容器,里面的列表展示可以用RecyclerView,至于RecyclerView的item是什么我们先不管。这时左边的row头部也是列表,我们也可以用垂直方向的RecyclerView,左上角“表格”不管上下左右滑动都是不动的,所以它不可能是滑动控件的一部分。这里我将它当作是列标题的一部分,因为列表题是可以左右滑动的,我们也用HorizontalScrollView作为列标题的容器,至于列标题的具体内容先不管,但是这里列标题还应包括固定不变的“表格”View,可以用线性布局把固定不变的左上角部分和列标题的容器放在一起。这时侯我们就可以把列表题的HorizontalScrollView和cell内容的HorizontalScrollView关联起来,互相监听滑动就可以简单地实现cell和列标题的左右滑动,我们只监听左右,当cell上下滑动时就不会影响列标题了。
同理表格的上下滑动就变得比较简单了,我们可以用NestedScrollView作为行标题和cell上下滑动的父容器,其实分析了这么多,整体的布局都已经出来了,大概设计:
至于HorizontalScrollView1和HorizontalScrollView2的item如何布局呢?考虑列标题的数量,我们采用RecyclerView作为其子View,这样我们就可以控制item的数量来控制列标题了,增加列标题或控制点击事件都比较方便(数量少的话你也可以罗列所有列标题内容);同理,cell内容子View是竖直方向的RecyclerView,但是其item这里我考虑的是嵌套横向的RecyclerView,方便控制其横向(和列标题是对应关系)的内容数量。
代码设计
1.布局文件
主布局文件:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/ll_content_table"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFFFFF"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="@dimen/corner_view_height"
android:orientation="horizontal">
<!--表格内容左右滑动时的阴影竖直线-->
<com.lihang.ShadowLayout
android:id="@+id/shadow_layout_column"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:hl_shadowColor="@android:color/transparent"
app:hl_shadowHiddenBottom="true"
app:hl_shadowHiddenLeft="true"
app:hl_shadowHiddenRight="true"
app:hl_shadowHiddenTop="true"
app:hl_shadowLimit="2dp">
<TextView
android:id="@+id/tv_table_title"
android:layout_width="@dimen/corner_view_width"
android:layout_height="match_parent"
android:gravity="center"
android:text="表格"
android:textColor="#000000"
android:textSize="14sp" />
</com.lihang.ShadowLayout>
<com.littlejerk.multiSample.table.blog.SyncHorizontalScrollView
android:id="@+id/scroll_view_column_container"
android:layout_width="match_parent"
android:background="#EAEAEA"
android:layout_height="wrap_content">
<!--RecyclerView必须嵌套RelativeLayout,不然会显示不全-->
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_column_header"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never" />
</RelativeLayout>
</com.littlejerk.multiSample.table.blog.SyncHorizontalScrollView>
</LinearLayout>
<!-- <View-->
<!-- android:layout_width="match_parent"-->
<!-- android:background="#EAEAEA"-->
<!-- android:layout_height="1dp"/>-->
<!--下拉刷新和上拉加载-->
<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/smartRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:srlDisableContentWhenLoading="true"
app:srlEnableAutoLoadMore="false"
app:srlEnableRefresh="false">
<!--竖直滚动-->
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="none">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<!--表格内容左右滑动时的阴影竖直线-->
<com.lihang.ShadowLayout
android:id="@+id/shadow_layout_row"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:hl_shadowColor="@android:color/transparent"
app:hl_shadowHiddenBottom="true"
app:hl_shadowHiddenLeft="true"
app:hl_shadowHiddenRight="true"
app:hl_shadowHiddenTop="true"
app:hl_shadowLimit="2dp">
<!-- 左侧header的父容器 -->
<androidx.recyclerview.widget.RecyclerView
android:background="#EAEAEA"
android:id="@+id/rv_row_header"
android:layout_width="@dimen/corner_view_width"
android:layout_height="wrap_content" />
</com.lihang.ShadowLayout>
<!-- 右侧内容的父容器 实现水平滚动 -->
<com.littlejerk.multiSample.table.blog.SyncHorizontalScrollView
android:id="@+id/scroll_view_cell_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none">
<!-- 必须设置焦点,不然显示隐藏阴影会造成竖直滚动 -->
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:descendantFocusability="blocksDescendants">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_cell_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:overScrollMode="never" />
</RelativeLayout>
</com.littlejerk.multiSample.table.blog.SyncHorizontalScrollView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
</LinearLayout>
这里也可以加上下拉刷新和上拉加载控件,具体看需求咯,至于ShadowLayout是为了控制左右滑动时行标题和cell内容的阴影分割,迫于设计和产品姐姐的yin…嗯大家都懂,真的苦啊,天天改需求,天天改bug,天天加班,天天…!
其它item的布局文件:
- 列标题布局文件item_table_noreuse_column:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="@dimen/column_width"
android:layout_height="@dimen/corner_view_height"
android:layout_gravity="center">
<TextView
android:id="@+id/tv_name"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="测试"
android:padding="3dp"
android:textSize="14sp" />
</LinearLayout>
- 行标题布局文件item_table_noreuse_row:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="@dimen/corner_view_width"
android:layout_height="@dimen/row_height"
android:layout_gravity="center">
<TextView
android:id="@+id/tv_name"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:padding="3dp"
android:text="测试"
android:textSize="14sp" />
</LinearLayout>
- cell的父布局文件item_table_noreuse_cell_parent:
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rv_cell_child"
android:layout_width="match_parent"
android:layout_height="@dimen/row_height"
android:orientation="horizontal"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_table_blog_cell_child" />
- cell的父布局RecyclerView的item布局文件item_table_noreuse_cell_child:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="@dimen/column_width"
android:layout_height="@dimen/row_height"
android:layout_gravity="center">
<TextView
android:id="@+id/tv_name"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="测试"
android:padding="3dp"
android:textSize="14sp" />
</LinearLayout>
dimens文件:
<dimen name="corner_view_width">50dp</dimen>
<dimen name="corner_view_height">50dp</dimen>
<dimen name="column_width">100dp</dimen>
<dimen name="row_height">40dp</dimen>
2.同步滑动的SyncHorizontalScrollView文件
public class SyncHorizontalScrollView extends HorizontalScrollView {
private View mView;
private ShadowLayout shadowLayout;
private boolean isShowShadow;
public SyncHorizontalScrollView(Context context) {
this(context, null);
}
public SyncHorizontalScrollView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SyncHorizontalScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//取消滑动到最前和最后是出现的蓝色颜色阴影块
setOverScrollMode(View.OVER_SCROLL_NEVER);
}
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
//隐藏显示滑动边界阴影
if (shadowLayout != null) {
if (l == 0) {
if (isShowShadow) {
shadowLayout.setShadowHiddenRight(true);
isShowShadow = false;
}
} else {
if (!isShowShadow) {
shadowLayout.setShadowHiddenRight(false);
//绘制边界阴影颜色
shadowLayout.setShadowColor(Color.parseColor("#1a000000"));
shadowLayout.setShadowOffsetY(2);
isShowShadow = true;
}
}
}
//设置控件滚动监听,得到滚动的距离,然后让传进来的view也设置相同的滚动具体
if (mView != null) {
mView.scrollTo(l, t);
}
}
/**
* 设置联动阴影
*
* @param shadowLayout
*/
public void setShadowLayout(ShadowLayout shadowLayout) {
this.shadowLayout = shadowLayout;
}
/**
* 设置跟它联动的view
*
* @param view
*/
public void setScrollView(View view) {
mView = view;
}
}
这个View只要是让左右滑动能够联动起来,代码很简单。
3.Activity逻辑
public class VHSlideTableActivity extends AppCompatActivity {
//如果有分页
private SmartRefreshLayout mRefreshLayout;
private ShadowLayout mShadowLayoutColumn;
private ShadowLayout mShadowLayoutRow;
private SyncHorizontalScrollView mSyncHsvColumnContainer;
private SyncHorizontalScrollView mSyncHsvCellContainer;
private RecyclerView mRvColumnHeader;
private RecyclerView mRvRowHeader;
private RecyclerView mRvCellParent;
private CommonAdapter mColumnAdapter, mRowAdapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_vhslide_table_noreuse);
mRefreshLayout = findViewById(R.id.smartRefresh);
mShadowLayoutRow = findViewById(R.id.shadow_layout_row);
mShadowLayoutColumn = findViewById(R.id.shadow_layout_column);
mSyncHsvCellContainer = findViewById(R.id.scroll_view_cell_container);
mSyncHsvColumnContainer = findViewById(R.id.scroll_view_column_container);
mRvColumnHeader = findViewById(R.id.rv_column_header);
mRvRowHeader = findViewById(R.id.rv_row_header);
mRvCellParent = findViewById(R.id.rv_cell_parent);
List<String> columnList = getColumnHeaderList();
List<String> rowList = getRowHeaderList();
List<List<String>> cellList = getCellList();
mSyncHsvColumnContainer.setScrollView(mSyncHsvCellContainer);
// mSyncHsvColumnContainer.setShadowLayout(mShadowLayoutColumn);
mRvColumnHeader.setLayoutManager(new LinearLayoutManager(this, RecyclerView.HORIZONTAL, false));
mColumnAdapter = new CommonAdapter(R.layout.item_table_noreuse_column, columnList);
mColumnAdapter.setOnItemChildClickListener((adapter, view, position) -> {
String name = mColumnAdapter.getItem(position);
Toast.makeText(this, name, Toast.LENGTH_SHORT).show();
});
mRvColumnHeader.setAdapter(mColumnAdapter);
mRvColumnHeader.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.HORIZONTAL));
mRvCellParent.setLayoutManager(new LinearLayoutManager(this));
mRvCellParent.setAdapter(new CellParentAdapter(R.layout.item_table_noreuse_cell_parent, cellList));
mRvCellParent.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
mSyncHsvCellContainer.setScrollView(mSyncHsvColumnContainer);
// mSyncHsvCellContainer.setShadowLayout(mShadowLayoutRow);
mRvRowHeader.setLayoutManager(new LinearLayoutManager(this));
mRowAdapter = new CommonAdapter(R.layout.item_table_noreuse_row, rowList);
mRowAdapter.setOnItemChildClickListener((adapter, view, position) -> {
String name = mRowAdapter.getItem(position);
Toast.makeText(this, name, Toast.LENGTH_SHORT).show();
});
mRvRowHeader.setAdapter(mRowAdapter);
mRvRowHeader.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
}
public class CellParentAdapter extends BaseQuickAdapter<List<String>, BaseViewHolder> {
public CellParentAdapter(int layoutResId, @Nullable List<List<String>> data) {
super(layoutResId, data);
}
@Override
protected void convert(@NotNull BaseViewHolder holder, List<String> data) {
RecyclerView recyclerView = holder.getView(R.id.rv_cell_child);
recyclerView.setLayoutManager(new LinearLayoutManager(getContext(), RecyclerView.HORIZONTAL, false));
CommonAdapter commonAdapter = new CommonAdapter(R.layout.item_table_noreuse_cell_child, data);
if (recyclerView.getItemDecorationCount() == 0) {
recyclerView.addItemDecoration(new DividerItemDecoration(getContext(), DividerItemDecoration.HORIZONTAL));
}
commonAdapter.setOnItemChildClickListener((adapter, view, position) -> {
String name = commonAdapter.getItem(position);
Toast.makeText(getContext(), name, Toast.LENGTH_SHORT).show();
});
recyclerView.setAdapter(commonAdapter);
}
}
public class CommonAdapter extends BaseQuickAdapter<String, BaseViewHolder> {
public CommonAdapter(int layoutResId, @Nullable List<String> data) {
super(layoutResId, data);
addChildClickViewIds(R.id.tv_name);
}
@Override
protected void convert(@NotNull BaseViewHolder holder, String s) {
holder.setText(R.id.tv_name, s);
}
}
}
4.数据
public List<String> getColumnHeaderList() {
List<String> list = new ArrayList<>();
for (int i = 0; i < 15; i++) {
list.add("column " + i);
}
return list;
}
public List<String> getRowHeaderList() {
List<String> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
list.add("row " + i);
}
return list;
}
public List<List<String>> getCellList() {
List<List<String>> cellList = new ArrayList<>();
for (int i = 0 ;i<100;i++){
List<String> list = new ArrayList<>();
for (int j = 0; j < 15; j++) {
list.add("row" + i + " column"+j);
}
cellList.add(list);
}
return cellList;
}
这里主要是数据和列表的展示,其中使用DividerItemDecoration左右内容表格的分割线,你也可以在item布局中设计你所需要的表格分割线,具体看产品姐姐的需求吧。这里用到了几个依赖:
//强大的RecyclerView适配器
implementation ‘com.github.CymChad:BaseRecyclerViewAdapterHelper:3.0.4’
//刷新控件,不用可去掉
implementation ‘com.scwang.smart:refresh-layout-horizontal:2.0.0’
implementation ‘com.scwang.smart:refresh-layout-kernel:2.0.1’
implementation ‘com.scwang.smart:refresh-header-classics:2.0.1’ //经典刷新头
implementation ‘com.scwang.smart:refresh-header-material:2.0.1’ //谷歌刷新头
//阴影库
implementation ‘com.github.lihangleo2:ShadowLayout:3.2.0’
总结
其实我也是站在巨人的肩膀上写了这篇文章,主要是给大家简单的实现了上下左右能够滑动的Table,所有文件都给出来了,我也看到过很多类似的文章和一些开源的Table,不得不说有些思路确实很棒。最后这种写法其实也是有个弊端的:不能加载大量数据,不然就会黑屏的,因为NestedScrollView嵌套了RecyclerView,导致RecyclerView不能复用了;还有就是cell的HorizontalScrollView竖直方向也会一次性加载全部的View,也是不能复用item(你可以把NestedScrollView代码注释掉验证下),所以这个table才加了刷新的功能,但是这不是最终的解决办法,这对App的性能还是有影响的,好了,不知不觉已经深夜了,睡觉了吧,明天还要与设计和产品姐姐斗智斗勇呢,哦哦哦,漏了测试小姐姐了,这个也是一生之敌,她测的bug越多,那我可能就要背上小书包了,啊啊啊,真的苦呀。。。下一篇再看看怎么处理这些问题吧。
最后附上下载地址。