今天开始更新【重拾安卓】系列文章。
因业务需要又要做一个 Android 原生的项目,记录下时隔几年之后再开发安卓的那些事。讲的不会太基础,基本上是自定义View封装,复杂功能的实现等等,有需要的小伙伴可以关注~
安卓对表格的支持不是太友好,前端很快能实现的简单表格,安卓写的话要费很大精力。
拿到需求之后,稍微复杂点的功能在 github 上搜一下有没有好用的第三方框架,无疑是最节省时间的。表格还真有几个不错的框架,star 最多的是 smartTable ,的确很强大,只需设置数据就能自动生成表格。
但考虑各种因素还是决定自己撸一个表格,一是后端返回的数据结构还没定,二是需求并不是太复杂,只是个简单表格,三是找找手感~
最终效果:
实现目标:
实现原理:
两层 RecyclerView
嵌套,最外层是垂直方向的 RecyclerView
,每一行是一个 item
。每行又包含一个内层 RecyclerView
,每行的每个单元格是内层 RecyclerView
的 item
。
为了方便重用,我们把这个课表封装成自定义 View,并对外暴露一个方法设置数据。
Android 自定义 View 有三种方式:组合、扩展、重写。我们这里用的是组合的方式,即把已有的控件组合起来形成符合需求的自定义控件。
新建一个 Java 类 StudentWorkTableView
并继承 LinearLayout
,实现它的构造方法,就创建了一个自定义 View。
为什么继承 LinearLayout
?其实继承其他的 RelativeLayout
、ConstraintLayout
都可以,一般是你的 xml 最外层用的是什么布局,就继承什么。
构造方法要实现三个,因为不同的创建方式走的构造方法不一样,所以都要求实现。
构造方法小技巧:把前两个参数少的构造方法里的 super 改成 this,并填充默认值变成三个参数,就会都调用三个参数的构造方法了,业务逻辑只需写在最后一个构造方法里即可。
这个 View 很简单,先在构造方法里绑定 xml 布局,再执行初始化方法初始数据,然后在 onLayout
中计算每个单元格的宽度,最后对外暴露一个方法设置数据。自定义 View 基本都是这个套路。
注意这里用到了第三方框架 ButterKnife
,简化了 findViewById
,不熟悉的同学可以查查相关资料。
代码注释写的比较详细,就不多说了直接看代码。
public class StudentWorkTableView extends LinearLayout {
<span class="hljs-meta">@BindView</span>(R.id.recycler_view_week_table)
RecyclerView recyclerView;
<span class="hljs-keyword">private</span> Context mContext;
<span class="hljs-keyword">private</span> List<TableListModel> mList;
<span class="hljs-keyword">private</span> <span class="hljs-keyword">int</span> mCellWidth;
<span class="hljs-keyword">private</span> StudentWorkTableAdapter mTableAdapter;
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">StudentWorkTableView</span><span class="hljs-params">(Context context)</span> </span>{
<span class="hljs-keyword">this</span>(context, <span class="hljs-keyword">null</span>, <span class="hljs-number">0</span>);
}
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">StudentWorkTableView</span><span class="hljs-params">(Context context, @Nullable AttributeSet attrs)</span> </span>{
<span class="hljs-keyword">this</span>(context, attrs, <span class="hljs-number">0</span>);
}
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">StudentWorkTableView</span><span class="hljs-params">(Context context, @Nullable AttributeSet attrs, <span class="hljs-keyword">int</span> defStyleAttr)</span> </span>{
<span class="hljs-keyword">super</span>(context, attrs, defStyleAttr);
View view = View.inflate(context, R.layout.view_student_work_table, <span class="hljs-keyword">this</span>);
ButterKnife.bind(view, <span class="hljs-keyword">this</span>);
mContext = context;
}
<span class="hljs-comment">/**
* 对外暴露的方法,设置表格的数据
*
* <span class="hljs-doctag">@param</span> list
*/</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">setData</span><span class="hljs-params">(List<TableListModel> list)</span> </span>{
mList = list;
init();
}
<span class="hljs-comment">/**
* 初始化方法
*/</span>
<span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">void</span> <span class="hljs-title">init</span><span class="hljs-params">()</span> </span>{
LinearLayoutManager lm = <span class="hljs-keyword">new</span> LinearLayoutManager(mContext, LinearLayoutManager.VERTICAL, <span class="hljs-keyword">false</span>);
recyclerView.setLayoutManager(lm);
recyclerView.setItemAnimator(<span class="hljs-keyword">new</span> DefaultItemAnimator());
}
<span class="hljs-meta">@Override</span>
<span class="hljs-function"><span class="hljs-keyword">protected</span> <span class="hljs-keyword">void</span> <span class="hljs-title">onLayout</span><span class="hljs-params">(<span class="hljs-keyword">boolean</span> changed, <span class="hljs-keyword">int</span> l, <span class="hljs-keyword">int</span> t, <span class="hljs-keyword">int</span> r, <span class="hljs-keyword">int</span> b)</span> </span>{
<span class="hljs-keyword">super</span>.onLayout(changed, l, t, r, b);
<span class="hljs-comment">// onLayout 时 View 的宽高已经确定了,可以拿到比较准确的值</span>
<span class="hljs-keyword">int</span> width = getWidth();
<span class="hljs-comment">// 计算每列即每个单元格的宽度。用 View 总宽度除以列数就得到了每个单元格的宽度</span>
mCellWidth = width / mList.get(<span class="hljs-number">0</span>).getTableList().size();
<span class="hljs-keyword">if</span> (mTableAdapter == <span class="hljs-keyword">null</span>) {
<span class="hljs-comment">//把单元格宽度传给 Adapter,在 Adapter 中对单元格重设宽度</span>
mTableAdapter = <span class="hljs-keyword">new</span> StudentWorkTableAdapter(mContext, mCellWidth, R.layout.item_student_work_table_view, mList);
recyclerView.setAdapter(mTableAdapter);
}
}
}
对应的布局文件 view_student_work_table.xml
:
布局很简单,只有一个 RecyclerView
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<span class="hljs-tag"><<span class="hljs-name">android.support.v7.widget.RecyclerView</span>
<span class="hljs-attr">android:id</span>=<span class="hljs-string">"@+id/recycler_view_week_table"</span>
<span class="hljs-attr">android:layout_width</span>=<span class="hljs-string">"match_parent"</span>
<span class="hljs-attr">android:layout_height</span>=<span class="hljs-string">"match_parent"</span>/></span>
</LinearLayout>
这个适配器是控制每行的显示。
Adapter 用到了吊炸天的 BaseRecyclerViewAdapterHelper
,节省了很多代码。只需在 convert()
方法里找到view 并设置数据 即可。
public class StudentWorkTableAdapter extends BaseQuickAdapter<TableListModel, BaseViewHolder> {
<span class="hljs-keyword">private</span> Context mContext;
<span class="hljs-keyword">private</span> <span class="hljs-keyword">int</span> mCellWidth;
<span class="hljs-keyword">private</span> StudentWorkTableCellAdapter mCellAdapter;
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">StudentWorkTableAdapter</span><span class="hljs-params">(Context context, <span class="hljs-keyword">int</span> cellWidth, <span class="hljs-keyword">int</span> layoutResId, @Nullable List<TableListModel> data)</span> </span>{
<span class="hljs-keyword">super</span>(layoutResId, data);
mContext = context;
mCellWidth = cellWidth;
}
<span class="hljs-meta">@Override</span>
<span class="hljs-function"><span class="hljs-keyword">protected</span> <span class="hljs-keyword">void</span> <span class="hljs-title">convert</span><span class="hljs-params">(BaseViewHolder helper, TableListModel item)</span> </span>{
RecyclerView recyclerView = helper.getView(R.id.content_recycler_view);
<span class="hljs-comment">//注意这个RecyclerView要用横向的布局,以展示每一列</span>
LinearLayoutManager lm = <span class="hljs-keyword">new</span> LinearLayoutManager(mContext, LinearLayoutManager.HORIZONTAL, <span class="hljs-keyword">false</span>);
recyclerView.setLayoutManager(lm);
<span class="hljs-comment">//设置adapter</span>
mCellAdapter = <span class="hljs-keyword">new</span> StudentWorkTableCellAdapter(mContext, mCellWidth, R.layout.item_student_work_cell, item.getTableList());
recyclerView.setAdapter(mCellAdapter);
}
}
外层的 item 布局文件里也只有一个 RecyclerView,外层 RecyclerView 用来展示行,内层 RecyclerView 用来展示列。
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<span class="hljs-tag"><<span class="hljs-name">android.support.v7.widget.RecyclerView</span>
<span class="hljs-attr">android:id</span>=<span class="hljs-string">"@+id/content_recycler_view"</span>
<span class="hljs-attr">android:layout_width</span>=<span class="hljs-string">"match_parent"</span>
<span class="hljs-attr">android:layout_height</span>=<span class="hljs-string">"wrap_content"</span>/></span>
</android.support.constraint.ConstraintLayout>
这个适配器是控制每个单元格。表头跟其他行的样式不一样,所以需要在数据上做个区分,这里简单的把表头的数据 id 都设为 111
了。判断如果是表头则改变背景样式。
public class StudentWorkTableCellAdapter extends BaseQuickAdapter<TableTitleModel, BaseViewHolder> {
<span class="hljs-keyword">private</span> <span class="hljs-keyword">float</span> mCellWidth;
<span class="hljs-keyword">private</span> TextView tvTitle;
<span class="hljs-keyword">private</span> Context mContext;
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">StudentWorkTableCellAdapter</span><span class="hljs-params">(Context context, <span class="hljs-keyword">float</span> cellWidth, <span class="hljs-keyword">int</span> layoutResId, @Nullable List<TableTitleModel> data)</span> </span>{
<span class="hljs-keyword">super</span>(layoutResId, data);
mCellWidth = cellWidth;
mContext = context;
}
<span class="hljs-meta">@Override</span>
<span class="hljs-function"><span class="hljs-keyword">protected</span> <span class="hljs-keyword">void</span> <span class="hljs-title">convert</span><span class="hljs-params">(BaseViewHolder helper, TableTitleModel item)</span> </span>{
tvTitle = helper.getView(R.id.tv_item_cell_table);
tvTitle.setText(item.getName());
ViewGroup.LayoutParams layoutParams = tvTitle.getLayoutParams();
layoutParams.width = (<span class="hljs-keyword">int</span>)mCellWidth;
<span class="hljs-keyword">if</span> (item.getId().equals(<span class="hljs-string">"111"</span>)){
<span class="hljs-comment">//根据标记判断是表头还是普通单元格,如果是表头就改变背景色</span>
tvTitle.setBackground(mContext.getResources().getDrawable(R.drawable.rect_table_title));
}
}
}
这是每个单元格的布局文件,无论多复杂的布局都可以做,这里只放一个 TextView 演示。
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
>
<span class="hljs-tag"><<span class="hljs-name">TextView</span>
<span class="hljs-attr">android:id</span>=<span class="hljs-string">"@+id/tv_item_cell_table"</span>
<span class="hljs-attr">android:layout_width</span>=<span class="hljs-string">"wrap_content"</span>
<span class="hljs-attr">android:layout_height</span>=<span class="hljs-string">"wrap_content"</span>
<span class="hljs-attr">tools:text</span>=<span class="hljs-string">"第一节"</span>
<span class="hljs-attr">android:textSize</span>=<span class="hljs-string">"14sp"</span>
<span class="hljs-attr">android:textColor</span>=<span class="hljs-string">"@color/text_normal"</span>
<span class="hljs-attr">android:gravity</span>=<span class="hljs-string">"center"</span>
<span class="hljs-attr">android:padding</span>=<span class="hljs-string">"10dp"</span>
<span class="hljs-attr">android:background</span>=<span class="hljs-string">"@drawable/rect_table_cell"</span>
/></span>
</android.support.constraint.ConstraintLayout>
普通单元格的背景样式
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
>
<solid android:color="#fff"/>
<stroke android:color="#E0E0E0" android:width="0.5dp"/>
</shape>
表头的背景样式
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#f1f2f3" />
<stroke
android:width="0.5dp"
android:color="#E0E0E0" />
</shape>
样式文件放在 src/main/res/drawable
目录下。
以上就是表格自定义 View 的实现和封装。
封装完之后就是使用啦,在需要使用的页面的 xml 布局文件中引入封装好的自定义 View 即可
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="20dp"
android:orientation="vertical"
>
<com.solo.presentation.view.StudentWorkTableView
android:id="@+id/work_table_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
在代码中通过 id 找到 StudentWorkTableView
,然后设置数据
@BindView(R.id.work_table_view)
StudentWorkTableView workTableView;
private List<TableListModel> tableListModels;
private void initWorkTableView() {
//从 assets 的 json 文件中读取数据
String json = AssetsUtils.getJson("work_table_data.json", getActivity());
Gson gson = new Gson();
tableListModels = gson.fromJson(json, new TypeToken<List<TableListModel>>(){}.getType());
//设置数据给 TableView
workTableView.setData(tableListModels);
}
数据是通过读取本地的 json 文件模拟的假数据,正常情况下应该请求接口获取数据的。获取到数据之后调用 workTableView.setData(tableListModels);
把数据设置进自定义 View 就可以啦。
附上 TableListModel
对象,get()、set() 方法省略
public class TableListModel {
private List<TableTitleModel> tableList;
}
TableTitleModel
对象,get()、set() 方法省略
public class TableTitleModel {
private String id;
private String name;
}
如何获取本地 json 文件的数据呢?
src/main/assets
,跟 java
和 res
平级。读取 json 封装成了个工具类 AssetsUtils
/**
* 读取 assets 文件夹中的文件工具类
*/
public class AssetsUtils {
<span class="hljs-comment">/**
* 获取assets中的json
* <span class="hljs-doctag">@param</span> fileName
* <span class="hljs-doctag">@param</span> context
* <span class="hljs-doctag">@return</span>
*/</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> String <span class="hljs-title">getJson</span><span class="hljs-params">(String fileName, Context context)</span></span>{
StringBuilder stringBuilder = <span class="hljs-keyword">new</span> StringBuilder();
<span class="hljs-keyword">try</span> {
InputStream is = context.getAssets().open(fileName);
BufferedReader bufferedReader = <span class="hljs-keyword">new</span> BufferedReader(<span class="hljs-keyword">new</span> InputStreamReader(is));
String line;
<span class="hljs-keyword">while</span> ((line=bufferedReader.readLine()) != <span class="hljs-keyword">null</span>){
stringBuilder.append(line);
}
} <span class="hljs-keyword">catch</span> (IOException e) {
e.printStackTrace();
}
<span class="hljs-keyword">return</span> stringBuilder.toString();
}
}
附上 json 文件
[
{
"tableList": [
{
"id": "111",
"name": "星期一"
},
{
"id": "111",
"name": "星期二"
},
{
"id": "111",
"name": "星期三"
},
{
"id": "111",
"name": "星期四"
},
{
"id": "111",
"name": "星期五"
},
{
"id": "111",
"name": "星期六"
},
{
"id": "111",
"name": "星期日"
}
]
},
{
"tableList": [
{
"id": "11",
"name": "小紫"
},
{
"id": "12",
"name": "小明"
},
{
"id": "13",
"name": "小红"
},
{
"id": "14",
"name": "小绿"
},
{
"id": "15",
"name": "小黄"
},
{
"id": "14",
"name": "张三"
},
{
"id": "15",
"name": "李四"
}
]
},
{
"tableList": [
{
"id": "11",
"name": "小紫"
},
{
"id": "12",
"name": "小明"
},
{
"id": "13",
"name": "小红"
},
{
"id": "14",
"name": "小绿"
},
{
"id": "15",
"name": "小黄"
},
{
"id": "14",
"name": "张三"
},
{
"id": "15",
"name": "李四"
}
]
},
{
"tableList": [
{
"id": "11",
"name": "小紫"
},
{
"id": "12",
"name": "小明"
},
{
"id": "13",
"name": "小红"
},
{
"id": "14",
"name": "小绿"
},
{
"id": "15",
"name": "小黄"
},
{
"id": "14",
"name": "张三"
},
{
"id": "15",
"name": "李四"
}
]
},
{
"tableList": [
{
"id": "11",
"name": "小紫"
},
{
"id": "12",
"name": "小明"
},
{
"id": "13",
"name": "小红"
},
{
"id": "14",
"name": "小绿"
},
{
"id": "15",
"name": "小黄"
},
{
"id": "14",
"name": "张三"
},
{
"id": "15",
"name": "李四"
}
]
},
{
"tableList": [
{
"id": "11",
"name": "小紫"
},
{
"id": "12",
"name": "小明"
},
{
"id": "13",
"name": "小红"
},
{
"id": "14",
"name": "小绿"
},
{
"id": "15",
"name": "小黄"
},
{
"id": "14",
"name": "张三"
},
{
"id": "15",
"name": "李四"
}
]
}
]
简单的表格不过瘾?再撸一个有合并单元格的复杂表头表格吧,效果图如下:
这基本能覆盖大部分场景了,依然是纯手撸,不用其他框架,敬请期待~