IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 移动开发 -> Flutter项目开发之仿微信的Excel报表 -> 正文阅读

[移动开发]Flutter项目开发之仿微信的Excel报表

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<Map> datas; // 数据源

  final List<String> 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. 如何让水平滑动内容的时候联动标题行移动
  3. 如何让内容既可以水平滑动又可以垂直滑动

解决方案

针对问题 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<Map> datas; // 数据源

  final List<String> 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<DataGrid> {

  final List<String> fixedColumn = [];

  final List<Map> 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<TableRow> _buildTableColumnOne() {
    List<TableRow> returnList = [];
    int i = 0;
    fixedColumn?.forEach((e) {
      returnList.add(_buildSingleColumnOne(
          e, bgColor: i % 2 == 0 ? LIGHT_GREY : ColorHelper.LIGHT_GREY));
      i++;
    });
    return returnList;
  }

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

  /*
   * 创建第一行表头
   * 该数据行只会左右滑动,上下滑动时在最上面浮动
   * 当为非日报时,多生成一个标题行
   */
  List<TableRow> _buildTableFirstRow() {
    List<TableRow> 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<String> textList,
      {bool isTitle = false, Color bgColor = ColorHelper.LIGHT_GREY}) {
    List<Widget> 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<Widget> 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<TestScrollView> {

  List<Map> 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<Map> _getData() {
    List<Map> 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 允许直接设置组件高度或者宽度,也可以通过设置子组件的宽度或者高度来确定组件的宽度或者高度

  移动开发 最新文章
Vue3装载axios和element-ui
android adb cmd
【xcode】Xcode常用快捷键与技巧
Android开发中的线程池使用
Java 和 Android 的 Base64
Android 测试文字编码格式
微信小程序支付
安卓权限记录
知乎之自动养号
【Android Jetpack】DataStore
上一篇文章      下一篇文章      查看所有文章
加:2022-01-29 23:11:58  更:2022-01-29 23:13:30 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/24 12:52:43-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码