Flutter项目开发之仿微信的Excel报表

Flutter项目开发之仿微信的Excel报表,第1张

Flutter 仿微信excel功能 前言

在项目开发中,报表是一个很常见的功能,有利于使用者一眼能看出数据的趋势与规律,非常适合数量大,且种类繁多的数据查看与对比。虽然Flutter提供了 Table,DataTable等相关的组件,但是在实际项目开发中,功能、扩展性、实用性、灵活性等十分有限,可以说几乎不可能不经调整修改能直接用于生产项目,笔者这次将详细讲解如何运用Flutter技术开发报表。

效果预览

需求实现
实现需求是否实现
Android、iOS跨平台
首列固定
标题行固定
首列跟随内容列上下滑动联动
标题行跟随内容行左右滑动联动
开发环境
所需环境功能描述
Flutter 1.22.6.stable跨平台的UI框架
flutter_screenutil: 4.0.4+1Flutter屏幕适配插件

在 pubspec.yaml 文件中添加依赖

flutter_screenutil: 4.0.4+1

技术分析

将整个报表分为4个部分,左上的固定列标题,左下的固定列、右上的标题行、右下的内容。由此看出,Table、Datatable 是不适用于这种功能的开发,所以笔者决定用 ListView来实现这个需求。固定列、标题行、内容3个部分采用ListView,左边的固定列是垂直滑动的ListView,右上面的标题行是 水平滑动的ListVie,右下面的是 既可以垂直滑动,又可以水平滑动的ListView,而左上的固定列标题使用普通的组件。这样就可以实现仿Excel的报表。功能分解可以参考如下图所示

技术实现 初步定义组件

组件所需要的无非是 数据源、标题行,另外增加了一些样式参数,代码如下

class DataGrid extends StatefulWidget {
  final List datas; // 数据源

  final List titleRow; // 标题行

  final Alignment cellAlignment; // 单元格对齐方式

  final EdgeInsets cellPadding; // 单元格 内边距

  final Color borderColor; // 边框颜色

  final String fixedKey;	// 固定列的key

  final String fixedTitle;	 // 固定列的标题

  const DataGrid({
    Key key,
    this.datas,
    this.titleRow,
    this.fixedTitle,
    this.fixedKey,
    this.cellAlignment,
    this.cellPadding,
    this.borderColor,
    }) : super(key: key);

  @override
  _DataGridState createState() => _DataGridState();
}
......
单元格

单元格的渲染分为两种,被合并的单元格与普通的单元格。普通单元格需要有四面边框,而被合并的单元格则需要去除相对应的边框。

定义渲染边框的方法,控制单元格的边框渲染,代码如下所示

Border _buildBorderSide({bool hideLeft = false, bool hideRight = false, bool hideTop = false, bool hideBottom = false}) {
  final double borderWidth = 0.33;
  return Border(
      bottom: hideBottom ?
      BorderSide.none
          :
      BorderSide(width: borderWidth, color: widget.borderColor ?? widget.LIGHT_GREY),
      top: hideTop ?
      BorderSide.none
          :
      BorderSide(width: borderWidth, color: widget.borderColor ?? widget.LIGHT_GREY),
      right: hideRight ?
      BorderSide.none
          :
      BorderSide(width: borderWidth, color: widget.borderColor ?? widget.LIGHT_GREY),
      left: hideLeft ?
      BorderSide.none
          :
      BorderSide(width: borderWidth, color: widget.borderColor ?? widget.LIGHT_GREY)
  );
}

构件单元格的方法如下所示

Widget _buildCell(String title, {
  bool hideLeft = false, bool hideRight = false, bool hideTop = false,
  bool hideBottom = false, bool hideTitle = false, Color bgColor}) {
  return IntrinsicHeight(
      child: Container(
          alignment: widget.cellAlignment ?? Alignment.center,
          padding: widget.cellPadding ?? EdgeInsets.fromLTRB(0, 15.0.h, 0, 15.h),
          decoration: BoxDecoration(
              border: _buildBorderSide(
                  hideLeft: hideLeft,
                  hideRight: hideRight,
                  hideTop: hideTop,
                  hideBottom: hideBottom
              ),
              color: bgColor
          ),
          child: Opacity(
              opacity: hideTitle ? 0 : 1,
              child: Text(
                title,
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
                style: TextStyle(fontSize: 14, color: widget.borderColor ?? ColorHelper.TEXT_BLACK),
              ))
      )
  );
}

温馨提示:上面代码的 IntrinsicHeight 组件可能用的很少,这是一个智能根据子组件的高度自动调整的组件,类似于 Android 的 wrap_content

构件空单元格

  Widget _buildEmptyCell() {
    return IntrinsicHeight(
        child: Container(
            alignment: widget.cellAlignment ?? Alignment.center,
            padding: widget.cellPadding ?? EdgeInsets.fromLTRB(0, 20.0.h, 0, 20.h),
            decoration: BoxDecoration(
                border: Border(
                  bottom: BorderSide(width: 0.33, color: widget.borderColor ?? ColorHelper.LIGHT_GREY),
                  top: BorderSide(width: 0.33, color: widget.borderColor ?? ColorHelper.LIGHT_GREY),
                  right: BorderSide(width: 0.33, color: widget.borderColor ?? ColorHelper.LIGHT_GREY),
                  left: BorderSide(width: 0.33, color: widget.borderColor ?? ColorHelper.LIGHT_GREY),
                )
            ),
            child: Text(
              '',
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(fontSize: 14, color: widget.borderColor ?? ColorHelper.TEXT_BLACK),
            )
        )
    );
  }
技术难点 如何让垂直滑动内容的时候联动标题列移动如何让水平滑动内容的时候联动标题行移动如何让内容既可以水平滑动又可以垂直滑动 解决方案

针对问题 1, 2 ,每个可滚动的组件都可以用一个 ScrollController 来控制、监听、记录滚动的位置,所以我们可以利用ScrollController来实现联动 。定义4个 ScrollController 对象,一个负责固定列,一个负责标题行、一个负责内容的横向滚动,一个负责内容的纵向滚动,代码如下

//定义可控制滚动组件
ScrollController firstColumnController = ScrollController();
ScrollController secondColumnController = ScrollController();

ScrollController firstRowController = ScrollController();
ScrollController secondedRowController = ScrollController();

 // 固定列的宽度
 final double columnWidth = 780.0.w;

在 initState方法里面,绑定各个ScrollController对象的联动关系

  @override
  void initState() {
    super.initState();
    //监听固定列滚动
    firstColumnController.addListener(() {
      if (firstColumnController.offset != secondColumnController.offset) {
        secondColumnController.jumpTo(firstColumnController.offset);
      }
    });

    //监听第内容行的纵向滚动
    secondColumnController.addListener(() {
      if (firstColumnController.offset != secondColumnController.offset) {
        firstColumnController.jumpTo(secondColumnController.offset);
      }
    });

    //监听标题行的滚动
    firstRowController.addListener(() {
      if (firstRowController.offset != secondedRowController.offset) {
        secondedRowController.jumpTo(firstRowController.offset);
      }
    });

    //监听第内容行的横向滚动
    secondedRowController.addListener(() {
      if (firstRowController.offset != secondedRowController.offset) {
        firstRowController.jumpTo(secondedRowController.offset);
      }
    });
  }
固定列与固定单元格 代码如下
                Container(
                  width: 300.w,
                  height: 1900.h,
                  child: Column(
                    children: [
                      Table(
                        children: [
                          TableRow(
                            children: [
                              _buildCell(
                                '${widget.fixedTitle ?? ''}',
                                hideBottom: true,
                                hideTop: true,
                                hideLeft: true,
                                bgColor: ColorHelper.LIGHT_GREY
                              ),
                            ]
                          ),
                        ],
                      ),
                      Expanded(
                        child: ListView(
                          controller: firstColumnController,
                          children: [
                            Table(children: _buildTableColumnOne()),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
实现既可以纵向滚动又可以横向滚动的内容

针对问题3,可以采用 ListView内部嵌套 SingleChildScrollView组件来实现内容的既可以横向滚动又可以纵向滚动,SingleChildScrollView负责横向滚动,ListView负责纵向滚动,并且SingleChildScrollView必须有一个确定的宽度。具体代码可参考如下所示

ListView(
	controller: thirdColumnController,
	children: [
	  SingleChildScrollView(
	    controller: secondedRowController,
	    scrollDirection: Axis.horizontal,
	    child: IntrinsicWidth(
	        child: Container(
	          padding: EdgeInsets.only(bottom: 10.h),
	          // 避免行数未填满时,下边框消失
	          child: ...
	          width: 1000.w
	        )
	      )
	    )
	],
)
完整代码如下
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_study/common/util/color_helper.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';

/// 数据表格
class DataGrid extends StatefulWidget {

  final List datas; // 数据源

  final List titleRow; // 标题行

  final Alignment cellAlignment; // 单元格对齐方式

  final EdgeInsets cellPadding; // 单元格 内边距

  final Color borderColor; // 边框颜色

  final String fixedKey;	// 固定列的key

  final String fixedTitle;	 // 固定列的标题

  const DataGrid({
    Key key,
    this.datas,
    this.titleRow,
    this.fixedTitle,
    this.fixedKey,
    this.cellAlignment,
    this.cellPadding,
    this.borderColor,
    }) : super(key: key);

  @override
  _DataGridState createState() => _DataGridState();
}

class _DataGridState extends State {

  final List fixedColumn = [];

  final List datas = [];

  //定义可控制滚动组件
  final ScrollController firstColumnController = ScrollController();

  final ScrollController thirdColumnController = ScrollController();

  final ScrollController firstRowController = ScrollController();

  final ScrollController secondedRowController = ScrollController();

  // 非浮动的列宽
  final double columnWidth = 390;

  final Color LIGHT_GREY = Color.fromRGBO(244, 247, 252, 1);

  @override
  void initState() {
    super.initState();
    //监听第一列变动
    firstColumnController.addListener(() {
      if (firstColumnController.offset != thirdColumnController.offset) {
        thirdColumnController.jumpTo(firstColumnController.offset);
      }
    });

    //监听第三列变动
    thirdColumnController.addListener(() {
      if (firstColumnController.offset != thirdColumnController.offset) {
        firstColumnController.jumpTo(thirdColumnController.offset);
      }
    });

    //监听第一行变动
    firstRowController.addListener(() {
      if (firstRowController.offset != secondedRowController.offset) {
        secondedRowController.jumpTo(firstRowController.offset);
      }
    });

    //监听第二行变动
    secondedRowController.addListener(() {
      if (firstRowController.offset != secondedRowController.offset) {
        firstRowController.jumpTo(secondedRowController.offset);
      }
    });
    widget.datas.forEach((e) {
      fixedColumn.add(e[widget.fixedKey].toString());
      e.remove(widget.fixedKey);
      datas.add(e);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: NotificationListener(
        child: Scaffold(
          body: Container(
            height: 1900.h,
            width: 1080.w,
            color: ColorHelper.DAY_TEXT,
            child: Row(
              children: [
                Container(
                  width: 300.w,
                  height: 1900.h,
                  child: Column(
                    children: [
                      Table(
                        children: [
                          TableRow(
                            children: [
                              _buildCell(
                                '${widget.fixedTitle ?? ''}',
                                hideBottom: true,
                                hideTop: true,
                                hideLeft: true,
                                bgColor: ColorHelper.LIGHT_GREY
                              ),
                            ]
                          ),
                        ],
                      ),
                      Expanded(
                        child: ListView(
                          controller: firstColumnController,
                          children: [
                            Table(children: _buildTableColumnOne()),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
                //其余列
              Expanded(
                child: Container(
                  child: Column(
                    children: [
                      SingleChildScrollView(
                        scrollDirection: Axis.horizontal, //horizontal
                        controller: firstRowController,
                        child: IntrinsicWidth(
                          child: Container(
                            child: Table(children: _buildTableFirstRow()),
                            width: 1000.w,
                          )
                        )
                      ),
                      Expanded(
                        child: ListView(
                          controller: thirdColumnController,
                          children: [
                            SingleChildScrollView(
                              controller: secondedRowController,
                              scrollDirection: Axis.horizontal,
                              child: IntrinsicWidth(
                                  child: Container(
                                    padding: EdgeInsets.only(bottom: 10.h),
                                    // 避免行数未填满时,下边框消失
                                    child: Table(children: _buildTableRow()),
                                    width: 1000.w
                                  )
                                )
                              )
                            ],
                          ),
                        ),
                      ]
                    ),
                  ),
                ),
              ],
            ),
          )
        ),
      ),
    );
  }

  /*
   * 创建固定列
   * 比如时间列固定,只会响应垂直滑动
   * 当为非日报时,会多一行的单元行占位
   */
  List _buildTableColumnOne() {
    List returnList = [];
    int i = 0;
    fixedColumn?.forEach((e) {
      returnList.add(_buildSingleColumnOne(
          e, bgColor: i % 2 == 0 ? LIGHT_GREY : ColorHelper.LIGHT_GREY));
      i++;
    });
    return returnList;
  }

  /*
   * 创建数据行
   * 渲染数据行
   */
  List _buildTableRow() {
    List returnList = [];
    int i = 0;
    this.datas.forEach((e) {
      Color bgColor = i % 2 == 0 ? LIGHT_GREY : ColorHelper.LIGHT_GREY;
      List vals = [];
      e.values.forEach((v) {
        vals.add(v.toString());
      });
      returnList.add(
          _buildRow(vals, isTitle: false, bgColor: bgColor));
      i++;
    });
    return returnList;
  }

  /*
   * 创建第一行表头
   * 该数据行只会左右滑动,上下滑动时在最上面浮动
   * 当为非日报时,多生成一个标题行
   */
  List _buildTableFirstRow() {
    List returnList = [];
    returnList.add(_buildRow(widget.titleRow, isTitle: true));
    return returnList;
  }

  /*
   * 创建一列
   * 左固定的列的第一行,这个单元格不会有任何的滑动
   */
  TableRow _buildSingleColumnOne(String text,
      {bool isTitle = false, Color bgColor}) {
    return TableRow(
        children: [
          _buildCell(
            isTitle ? '${widget.fixedTitle ?? ''}' : '${text ?? ''}',
            bgColor: bgColor,
            hideLeft: true,
            hideTop: true,
            hideBottom: true,
          ),
        ]
    );
  }

  /*
   * 构建每行数据的单元格
   * 当为月报与年报的时候,一个通道拥有3个单元格
   */
  TableRow _buildRow(List textList,
      {bool isTitle = false, Color bgColor = ColorHelper.LIGHT_GREY}) {
    List wd = [];
    textList.forEach((e) {
      wd.add(_buildCell(e, hideRight: true,
          hideTop: true,
          hideBottom: true,
          hideLeft: true,
          bgColor: bgColor));
    });
    return TableRow(
        children: wd
    );
  }

  /*
   * 构建月报与年报时的 第二行标题
   */
  TableRow _buildSecondTitleRow() {
    List wd = [];
    widget.titleRow.forEach((e) {
      wd.add(_buildCell('平均值', bgColor: ColorHelper.LIGHT_GREY,
          hideRight: true,
          hideTop: true,
          hideBottom: true));
      wd.add(_buildCell('最大值', bgColor: ColorHelper.LIGHT_GREY,
          hideRight: true,
          hideTop: true,
          hideBottom: true,
          hideLeft: true));
      wd.add(_buildCell('最小值', bgColor: ColorHelper.LIGHT_GREY,
          hideLeft: true,
          hideTop: true,
          hideBottom: true));
    });
    return TableRow(
        children: wd
    );
  }

  /*
   * 构建空的单元格
   */
  Widget _buildEmptyCell() {
    return IntrinsicHeight(
      child: Container(
          alignment: widget.cellAlignment ?? Alignment.center,
          padding: widget.cellPadding ??
              EdgeInsets.fromLTRB(0, 20.0.h, 0, 20.h),
          decoration: BoxDecoration(
              border: Border(
                bottom: BorderSide(width: 0.33,
                    color: widget.borderColor ?? ColorHelper.LIGHT_GREY),
                top: BorderSide(width: 0.33,
                    color: widget.borderColor ?? ColorHelper.LIGHT_GREY),
                right: BorderSide(width: 0.33,
                    color: widget.borderColor ?? ColorHelper.LIGHT_GREY),
                left: BorderSide(width: 0.33,
                    color: widget.borderColor ?? ColorHelper.LIGHT_GREY),
              )
          ),
          child: Text(
            '',
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: TextStyle(fontSize: 14,
                color: widget.borderColor ?? ColorHelper.TEXT_BLACK),
          )
      )
    );
  }

  // 构建被合并的单元格
  Widget _buildCell(String title, {
    bool hideLeft = false, bool hideRight = false, bool hideTop = false,
    bool hideBottom = false, bool hideTitle = false, Color bgColor}) {
    return IntrinsicHeight(
        child: Container(
            alignment: widget.cellAlignment ?? Alignment.center,
            padding: widget.cellPadding ??
                EdgeInsets.fromLTRB(0, 15.0.h, 0, 15.h),
            decoration: BoxDecoration(
                border: _buildBorderSide(
                    hideLeft: hideLeft,
                    hideRight: hideRight,
                    hideTop: hideTop,
                    hideBottom: hideBottom
                ),
                color: bgColor
            ),
            child: Opacity(
                opacity: hideTitle ? 0 : 1,
                child: Text(
                  title,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: TextStyle(fontSize: 14,
                      color: widget.borderColor ?? ColorHelper.TEXT_BLACK),
                ))
        )
    );
  }

  Border _buildBorderSide(
      {bool hideLeft = false, bool hideRight = false, bool hideTop = false, bool hideBottom = false}) {
    final double borderWidth = 0.33;
    return Border(
        bottom: hideBottom ?
        BorderSide.none
            :
        BorderSide(
            width: borderWidth, color: widget.borderColor ?? LIGHT_GREY),
        top: hideTop ?
        BorderSide.none
            :
        BorderSide(
            width: borderWidth, color: widget.borderColor ?? LIGHT_GREY),
        right: hideRight ?
        BorderSide.none
            :
        BorderSide(
            width: borderWidth, color: widget.borderColor ?? LIGHT_GREY),
        left: hideLeft ?
        BorderSide.none
            :
        BorderSide(
            width: borderWidth, color: widget.borderColor ?? LIGHT_GREY)
    );
  }
}
使用该组件

代码如下

import 'package:flutter/material.dart';
import 'package:flutter_study/common/ui/datagrid.dart';

class TestScrollView extends StatefulWidget {
  const TestScrollView({Key key}) : super(key: key);

  @override
  _TestScrollViewState createState() => _TestScrollViewState();
}

class _TestScrollViewState extends State {

  List datas = [];

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    datas = _getData();
    return Scaffold(
      appBar: AppBar(
        title: Text('报表'),
      ),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    return Container(
      child: DataGrid(
        datas: datas,
        fixedKey: 'day',
        fixedTitle: '日期',
        titleRow: ['今日', '昨日', '前日', '本周', '本月', '本年'],
      ),
    );
  }

// 生成数据源
  List _getData() {
    List datas = [];
    for (int i = 0; i < 100; i++) {
      Map data = {};
      data['day'] = '2020-06-12';
      data['today'] = 49899;
      data['yesterday'] = 49899;
      data['beforeday'] = 49899;
      data['week'] = 49899;
      data['month'] = 49899;
      data['year'] = 49899;
      datas.add(data);
    }
    return datas;
  }
}

这样就能实现预览中的效果,读者可以根据实际业务场景调整代码。

注意事项

如果是既可以横向滑动又可以纵向滑动的组件必须确定 一个宽度或者高度。Flutter 允许直接设置组件高度或者宽度,也可以通过设置子组件的宽度或者高度来确定组件的宽度或者高度

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/web/992153.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-21
下一篇 2022-05-21

发表评论

登录后才能评论

评论列表(0条)

保存