重拾安卓:自定义View之表格封装实现

今天开始更新【重拾安卓】系列文章。

因业务需要又要做一个 Android 原生的项目,记录下时隔几年之后再开发安卓的那些事。讲的不会太基础,基本上是自定义View封装,复杂功能的实现等等,有需要的小伙伴可以关注~


安卓对表格的支持不是太友好,前端很快能实现的简单表格,安卓写的话要费很大精力。

拿到需求之后,稍微复杂点的功能在 github 上搜一下有没有好用的第三方框架,无疑是最节省时间的。表格还真有几个不错的框架,star 最多的是 smartTable ,的确很强大,只需设置数据就能自动生成表格。

但考虑各种因素还是决定自己撸一个表格,一是后端返回的数据结构还没定,二是需求并不是太复杂,只是个简单表格,三是找找手感~

一、需求分析及实现原理

最终效果:

image

实现目标:

  1. 行数不固定,超出父容器可以上下滚动
  2. 列数不固定,不管有多少列,都平分父容器宽度,每列的宽度一致
  3. 表头设置灰色背景,单元格是白色背景

实现原理:

两层 RecyclerView 嵌套,最外层是垂直方向的 RecyclerView,每一行是一个 item。每行又包含一个内层 RecyclerView,每行的每个单元格是内层 RecyclerViewitem

二、代码实现

为了方便重用,我们把这个课表封装成自定义 View,并对外暴露一个方法设置数据。

Android 自定义 View 有三种方式:组合、扩展、重写。我们这里用的是组合的方式,即把已有的控件组合起来形成符合需求的自定义控件。

2.1 自定义View 主文件 StudentWorkTableView

新建一个 Java 类 StudentWorkTableView 并继承 LinearLayout ,实现它的构造方法,就创建了一个自定义 View。

为什么继承 LinearLayout ?其实继承其他的 RelativeLayoutConstraintLayout 都可以,一般是你的 xml 最外层用的是什么布局,就继承什么。

构造方法要实现三个,因为不同的创建方式走的构造方法不一样,所以都要求实现。

构造方法小技巧:把前两个参数少的构造方法里的 super 改成 this,并填充默认值变成三个参数,就会都调用三个参数的构造方法了,业务逻辑只需写在最后一个构造方法里即可。

这个 View 很简单,先在构造方法里绑定 xml 布局,再执行初始化方法初始数据,然后在 onLayout 中计算每个单元格的宽度,最后对外暴露一个方法设置数据。自定义 View 基本都是这个套路。

注意这里用到了第三方框架 ButterKnife ,简化了 findViewById ,不熟悉的同学可以查查相关资料。

代码注释写的比较详细,就不多说了直接看代码。

public class StudentWorkTableView extends LinearLayout {

    @BindView(R.id.recycler_view_week_table)
    RecyclerView recyclerView;

    private Context mContext;

    private List<TableListModel> mList;
    private int mCellWidth;
    private StudentWorkTableAdapter mTableAdapter;

    public StudentWorkTableView(Context context) {
        this(context, null, 0);
    }

    public StudentWorkTableView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public StudentWorkTableView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        View view = View.inflate(context, R.layout.view_student_work_table, this);
        ButterKnife.bind(view, this);
        mContext = context;
    }

    /**
     * 对外暴露的方法,设置表格的数据
     *
     * @param list
     */
    public void setData(List<TableListModel> list) {
        mList = list;
        init();
    }

    /**
     * 初始化方法
     */
    private void init() {
        LinearLayoutManager lm = new LinearLayoutManager(mContext, LinearLayoutManager.VERTICAL, false);
        recyclerView.setLayoutManager(lm);
        recyclerView.setItemAnimator(new DefaultItemAnimator());
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        // onLayout 时 View 的宽高已经确定了,可以拿到比较准确的值
        int width = getWidth();
        // 计算每列即每个单元格的宽度。用 View 总宽度除以列数就得到了每个单元格的宽度
        mCellWidth = width / mList.get(0).getTableList().size();
        if (mTableAdapter == null) {
            //把单元格宽度传给 Adapter,在 Adapter 中对单元格重设宽度
            mTableAdapter = new StudentWorkTableAdapter(mContext, mCellWidth, R.layout.item_student_work_table_view, mList);
            recyclerView.setAdapter(mTableAdapter);
        }
    }

}

2.2 布局文件 view_student_work_table.xml

对应的布局文件 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">

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

</LinearLayout>

2.3 外层 RecyclerView 的适配器 StudentWorkTableAdapter

这个适配器是控制每行的显示。

Adapter 用到了吊炸天的 BaseRecyclerViewAdapterHelper ,节省了很多代码。只需在 convert() 方法里找到view 并设置数据 即可。

public class StudentWorkTableAdapter extends BaseQuickAdapter<TableListModel, BaseViewHolder> {

    private Context mContext;
    private int mCellWidth;
    private StudentWorkTableCellAdapter mCellAdapter;

    public StudentWorkTableAdapter(Context context, int cellWidth, int layoutResId, @Nullable List<TableListModel> data) {
        super(layoutResId, data);
        mContext = context;
        mCellWidth = cellWidth;
    }

    @Override
    protected void convert(BaseViewHolder helper, TableListModel item) {
        RecyclerView recyclerView = helper.getView(R.id.content_recycler_view);
        //注意这个RecyclerView要用横向的布局,以展示每一列
        LinearLayoutManager lm = new LinearLayoutManager(mContext, LinearLayoutManager.HORIZONTAL, false);
        recyclerView.setLayoutManager(lm);
        //设置adapter
        mCellAdapter = new StudentWorkTableCellAdapter(mContext, mCellWidth, R.layout.item_student_work_cell, item.getTableList());
        recyclerView.setAdapter(mCellAdapter);
    }
}

2.4 外层 RecyclerView 的 item 布局文件 item_student_work_table_view.xml

外层的 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">

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

</android.support.constraint.ConstraintLayout>

2.5 内层 RecyclerView 的适配器 StudentWorkTableCellAdapter

这个适配器是控制每个单元格。表头跟其他行的样式不一样,所以需要在数据上做个区分,这里简单的把表头的数据 id 都设为 111 了。判断如果是表头则改变背景样式。

public class StudentWorkTableCellAdapter extends BaseQuickAdapter<TableTitleModel, BaseViewHolder> {

    private float mCellWidth;
    private TextView tvTitle;
    private Context mContext;

    public StudentWorkTableCellAdapter(Context context, float cellWidth, int layoutResId, @Nullable List<TableTitleModel> data) {
        super(layoutResId, data);
        mCellWidth = cellWidth;
        mContext = context;
    }

    @Override
    protected void convert(BaseViewHolder helper, TableTitleModel item) {
        tvTitle = helper.getView(R.id.tv_item_cell_table);
        tvTitle.setText(item.getName());

        ViewGroup.LayoutParams layoutParams = tvTitle.getLayoutParams();
        layoutParams.width = (int)mCellWidth;

        if (item.getId().equals("111")){
            //根据标记判断是表头还是普通单元格,如果是表头就改变背景色
          tvTitle.setBackground(mContext.getResources().getDrawable(R.drawable.rect_table_title));
        }
    }
}

2.6 内层 RecyclerView 的 item 布局文件 item_student_work_cell.xml

这是每个单元格的布局文件,无论多复杂的布局都可以做,这里只放一个 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"
    >

    <TextView
        android:id="@+id/tv_item_cell_table"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        tools:text="第一节"
        android:textSize="14sp"
        android:textColor="@color/text_normal"
        android:gravity="center"
        android:padding="10dp"
        android:background="@drawable/rect_table_cell"
        />

</android.support.constraint.ConstraintLayout>

2.7 其他

普通单元格的背景样式

<?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 文件的数据呢?

  1. 先建一个 assets 目录,位置是 src/main/assets,跟 javares 平级。
  2. 在 assets 目录下新建并编写 json 文件
  3. 在 java 代码中读取 json
image

读取 json 封装成了个工具类 AssetsUtils

/**
 * 读取 assets 文件夹中的文件工具类
 */
public class AssetsUtils {

    /**
     * 获取assets中的json
     * @param fileName
     * @param context
     * @return
     */
    public static String getJson(String fileName, Context context){
        StringBuilder stringBuilder = new StringBuilder();
        try {
            InputStream is = context.getAssets().open(fileName);
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(is));
            String line;
            while ((line=bufferedReader.readLine()) != null){
                stringBuilder.append(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        return 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": "李四"
      }
    ]
  }
]

五、下集预告

简单的表格不过瘾?再撸一个有合并单元格的复杂表头表格吧,效果图如下:

image

这基本能覆盖大部分场景了,依然是纯手撸,不用其他框架,敬请期待~

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

推荐阅读更多精彩内容