Excel report imitating wechat developed by fluent project

Posted by davidlenehan on Fri, 28 Jan 2022 14:11:43 +0100

Flutter imitates wechat excel function

preface

In project development, report is a very common function, which is helpful for users to see the trend and law of data at a glance. It is very suitable for viewing and comparing a large number and a wide variety of data. Although Flutter provides Table, DataTable and other related components, in the actual project development, the function, scalability, practicability and flexibility are very limited. It can be said that it is almost impossible to be directly used in production projects without adjustment and modification. The author will explain in detail how to use Flutter technology to develop reports this time.

Effect preview

Demand realization

Implementation requirementsWhether to realize
Android, iOS cross platform
First column fixed
Title line fixed
The first column slides up and down with the content column
The title line slides left and right with the content line

development environment

Required environmentFunction description
Flutter 1.22.6.stableCross platform UI framework
flutter_screenutil: 4.0.4+1Shutter screen adapter

In pubspec Add dependency to yaml file

flutter_screenutil: 4.0.4+1

technical analysis

The whole report is divided into four parts: fixed column header on the top left, fixed column on the bottom left, header row on the top right and content on the bottom right. It can be seen that Table and Datatable are not suitable for the development of this function, so the author decided to use ListView to realize this requirement. The fixed column, title row and content adopt ListView. The fixed column on the left is a vertically sliding ListView, the title row on the upper right is a horizontally sliding ListVie, and the lower right is a ListView that can slide both vertically and horizontally, while the fixed column title on the upper left uses ordinary components. In this way, the report imitating Excel can be realized. The function decomposition can be referred to as shown in the figure below

Technical realization

Preliminary definition of components

All the components need is the data source and title line. In addition, some style parameters are added. The code is as follows

class DataGrid extends StatefulWidget {
  final List<Map> datas; // data source

  final List<String> titleRow; // Title Line

  final Alignment cellAlignment; // Cell alignment

  final EdgeInsets cellPadding; // Inner margin of cell

  final Color borderColor; // Border color

  final String fixedKey;	// Fixed column key

  final String fixedTitle;	 // Fixed column header

  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();
}
......

Cell

There are two kinds of cell rendering: merged cells and ordinary cells. Ordinary cells need to have four borders, while merged cells need to remove the corresponding borders.

Define the method of rendering the border and control the border rendering of cells. The code is as follows

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)
  );
}

The method of component cell is as follows:

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),
              ))
      )
  );
}

Warm tip: the IntrinsicHeight component in the above code may be rarely used. It is a component that can be automatically adjusted according to the height of sub components, similar to Android wrap_content

Component empty cell

  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),
            )
        )
    );
  }

Technical difficulties

  1. How to make the title column move when sliding the content vertically
  2. How to move the title line when sliding the content horizontally
  3. How to make content slide horizontally and vertically

Solution

For questions 1 and 2, each scrollable component can use a ScrollController to control, monitor and record the scrolling position, so we can use the ScrollController to realize linkage. Define four ScrollController objects, one for fixed columns, one for Title rows, one for horizontal scrolling of content, and one for vertical scrolling of content. The code is as follows

//Define controllable scrolling components
ScrollController firstColumnController = ScrollController();
ScrollController secondColumnController = ScrollController();

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

 // Fixed column width
 final double columnWidth = 780.0.w;

In the initState method, bind the linkage relationship of each ScrollController object

  @override
  void initState() {
    super.initState();
    //Monitor fixed column scrolling
    firstColumnController.addListener(() {
      if (firstColumnController.offset != secondColumnController.offset) {
        secondColumnController.jumpTo(firstColumnController.offset);
      }
    });

    //Listen for vertical scrolling of the content line
    secondColumnController.addListener(() {
      if (firstColumnController.offset != secondColumnController.offset) {
        firstColumnController.jumpTo(secondColumnController.offset);
      }
    });

    //Listen for scrolling of header lines
    firstRowController.addListener(() {
      if (firstRowController.offset != secondedRowController.offset) {
        secondedRowController.jumpTo(firstRowController.offset);
      }
    });

    //Listen for horizontal scrolling of the content line
    secondedRowController.addListener(() {
      if (firstRowController.offset != secondedRowController.offset) {
        firstRowController.jumpTo(secondedRowController.offset);
      }
    });
  }

Fixed column and fixed cell codes are as follows

                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()),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),

Realize the content that can scroll vertically and horizontally

To solve problem 3, you can use the SingleChildScrollView component nested inside the ListView to realize both horizontal and vertical scrolling of content. SingleChildScrollView is responsible for horizontal scrolling, ListView is responsible for vertical scrolling, and SingleChildScrollView must have a certain width. Refer to the following for specific codes

ListView(
	controller: thirdColumnController,
	children: [
	  SingleChildScrollView(
	    controller: secondedRowController,
	    scrollDirection: Axis.horizontal,
	    child: IntrinsicWidth(
	        child: Container(
	          padding: EdgeInsets.only(bottom: 10.h),
	          // Avoid that the bottom border disappears when the number of rows is not filled
	          child: ...
	          width: 1000.w
	        )
	      )
	    )
	],
)

The complete code is as follows

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';

///Data table
class DataGrid extends StatefulWidget {

  final List<Map> datas; // data source

  final List<String> titleRow; // Title Line

  final Alignment cellAlignment; // Cell alignment

  final EdgeInsets cellPadding; // Inner margin of cell

  final Color borderColor; // Border color

  final String fixedKey;	// Fixed column key

  final String fixedTitle;	 // Fixed column header

  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 = [];

  //Define controllable scrolling components
  final ScrollController firstColumnController = ScrollController();

  final ScrollController thirdColumnController = ScrollController();

  final ScrollController firstRowController = ScrollController();

  final ScrollController secondedRowController = ScrollController();

  // Non floating column width
  final double columnWidth = 390;

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

  @override
  void initState() {
    super.initState();
    //Monitor changes in the first column
    firstColumnController.addListener(() {
      if (firstColumnController.offset != thirdColumnController.offset) {
        thirdColumnController.jumpTo(firstColumnController.offset);
      }
    });

    //Monitor changes in the third column
    thirdColumnController.addListener(() {
      if (firstColumnController.offset != thirdColumnController.offset) {
        firstColumnController.jumpTo(thirdColumnController.offset);
      }
    });

    //Listen for changes in the first line
    firstRowController.addListener(() {
      if (firstRowController.offset != secondedRowController.offset) {
        secondedRowController.jumpTo(firstRowController.offset);
      }
    });

    //Listen for changes in the second line
    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()),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
                //Remaining columns
              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),
                                    // Avoid that the bottom border disappears when the number of rows is not filled
                                    child: Table(children: _buildTableRow()),
                                    width: 1000.w
                                  )
                                )
                              )
                            ],
                          ),
                        ),
                      ]
                    ),
                  ),
                ),
              ],
            ),
          )
        ),
      ),
    );
  }

  /*
   * Create fixed column
   * For example, if the time column is fixed, it will only respond to vertical sliding
   * When it is a non daily report, the cell line with one more line will occupy the space
   */
  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;
  }

  /*
   * Create data row
   * Render data rows
   */
  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;
  }

  /*
   * Create first row header
   * The data row will only slide left and right, and float on the top when sliding up and down
   * When it is a non daily report, one more title line will be generated
   */
  List<TableRow> _buildTableFirstRow() {
    List<TableRow> returnList = [];
    returnList.add(_buildRow(widget.titleRow, isTitle: true));
    return returnList;
  }

  /*
   * Create a column
   * The first row of the left fixed column, this cell will not have any sliding
   */
  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,
          ),
        ]
    );
  }

  /*
   * Building cells for each row of data
   * When it is a monthly report and an annual report, a channel has three cells
   */
  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
    );
  }

  /*
   * The second line title when constructing monthly report and annual report
   */
  TableRow _buildSecondTitleRow() {
    List<Widget> wd = [];
    widget.titleRow.forEach((e) {
      wd.add(_buildCell('average value', bgColor: ColorHelper.LIGHT_GREY,
          hideRight: true,
          hideTop: true,
          hideBottom: true));
      wd.add(_buildCell('Maximum', bgColor: ColorHelper.LIGHT_GREY,
          hideRight: true,
          hideTop: true,
          hideBottom: true,
          hideLeft: true));
      wd.add(_buildCell('minimum value', bgColor: ColorHelper.LIGHT_GREY,
          hideLeft: true,
          hideTop: true,
          hideBottom: true));
    });
    return TableRow(
        children: wd
    );
  }

  /*
   * Building empty cells
   */
  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),
          )
      )
    );
  }

  // Building merged cells
  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)
    );
  }
}

Use this component

The code is as follows

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('report form'),
      ),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    return Container(
      child: DataGrid(
        datas: datas,
        fixedKey: 'day',
        fixedTitle: 'date',
        titleRow: ['today', 'yesterday', 'The day before yesterday', 'This week', 'This month', 'This year'],
      ),
    );
  }

// Generate data source
  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;
  }
}

In this way, the effect in preview can be realized, and readers can adjust the code according to the actual business scenario.

matters needing attention

For components that can slide both horizontally and longitudinally, a width or height must be determined. Fluent allows you to directly set the height or width of components, or determine the width or height of components by setting the width or height of sub components

Topics: iOS Android Flutter