Flutter用700行代码纯手工自定义绘制表格控件KqTable
我们项目中往往需要使用到自定义表格,系统提供的表格控件只支持简单的展示功能,并不能很好的满足我们项目的自定义,然而自定义最大自由度的还是自己绘制,所以我选则了自己从头开始自定义绘制一个表格,绘制中不仅需要考虑到事件的处理,还需要考虑到表格固定行列的处理,绘制时采用了数据预处理策略,先对需要绘制的数据进行层级排序,然后根据层级排序,控制绘制顺序。而且项目中还用到了局部绘制的概念,即只绘制出当前正在展示的表格,根据表格的滑动,动态绘制需要展示的内容。感兴趣的伙伴可以直接复制代码使用和修改。
- 演示
- 功能
1.支持动态数据绘制。数据格式[[row],[row],...]。
2.支持表格中文字的大小与颜色设置。
3.支持控件宽高设置。
4.支持设置表格的格子背景颜色与边框颜色。
5.支持固定上下左右行列,固定遵循格式,代表上下左右固定的行与列数:[int,int,int,int]
6.支持指定任意行列的颜色,设置格式:[TableColor,TableColor,...],TableColor有两个子函数。设置行颜色的RowColor与设置列颜色的RowColor。
7.支持单元格点击回调,回调会返回所点击的单元格的对应数据对象T。
8.支持行列拖拽宽度和高度。
9.支持点击表头选中行列,并高亮。
10.手把手自定义事件处理。
- 代码
import 'dart:async';import 'package:flutter/material.dart';
import 'dart:ui' as ui;class KqTable<T extends ITableData> extends StatefulWidget {/// 控件数据final List<List<T>> data;/// 文本大小final double fontSize;/// 文本颜色final Color textColor;/// 控件宽度final double width;/// 控件高度final double height;/// 表格颜色final Color tableColor;/// 表格边框颜色final Color tableBorderColor;/// 表格点击选中颜色final Color tableClickChosenColor;/// 表格长按选中颜色final Color tableLongPressChosenColor;/// 表格点击选中边框颜色final Color tableClickChosenBorderColor;/// 表格长按选中边框颜色final Color tableLongPressChosenBorderColor;/// 上下左右固定行数值[int,int,int,int]final List<int> lockList;/// 指定特定行或者列的颜色,行使用[RowColor],列使用[ColumnColor]final List<TableColor> colorList;/// 点击单元格回调final Function(T data)? onTap;const KqTable({super.key,required this.data,this.fontSize = 14,this.textColor = Colors.black,this.width = 300,this.height = 200,this.tableColor = Colors.white,this.tableBorderColor = const Color.fromARGB(255, 189, 189, 189),this.lockList = const [3, 0, 1, 0],this.colorList = const [RowColor(0, Color.fromARGB(255, 238, 238, 238)),RowColor(5, Color.fromARGB(255, 238, 238, 238)),RowColor(6, Color.fromARGB(255, 238, 238, 238)),ColumnColor(0, Color.fromARGB(255, 238, 238, 238)),ColumnColor(7, Color.fromARGB(255, 238, 238, 238))],this.onTap,this.tableClickChosenColor =const Color.fromARGB(102, 130, 177, 255), //a=40% bluethis.tableClickChosenBorderColor =const Color.fromARGB(230, 130, 177, 255), //90% bluethis.tableLongPressChosenColor =const Color.fromARGB(102, 29, 233, 182), //a=40% greenthis.tableLongPressChosenBorderColor =const Color.fromARGB(230, 29, 233, 182)}); //a=90% green@overrideState<StatefulWidget> createState() => _KqTableState<T>();
}class _KqTableState<T extends ITableData> extends State<KqTable<T>> {/// 长按判定等待时间100毫秒final int _waitTime = 100;/// x方向偏移量double _offsetDx = 0;/// y方向偏移量double _offsetDy = 0;/// x方向误差量double _diffOffsetDx = 0;/// y方向误差量double _diffOffsetDy = 0;/// 行数int _xLength = 0;/// 列数int _yLength = 0;/// 每列的行文本最大宽度列表[[原宽度,调整后宽度],[原宽度,调整后宽度],...]final List<List<double>> _xWidthList = [];/// 每行的文本高度,高度也可以变化,所以不能用一个值表达[[原高度,调整后高度],[原高度,调整后高度],...]final List<List<double>> _yHeightList = [];/// 按下时当前单元格的对象T? _opTableData;/// 当前手势是否滑动bool _opIsMove = false;/// 当前是否是长按bool _opIsLongPress = false;/// 绘制对象缓存final List<ITableData> _tempDrawData = <ITableData>[];/// 计时器Timer? timer;/// 点击选中行int _opClickChosenX = -1;/// 点击选中列int _opClickChosenY = -1;/// 长按选中行int _opLongPressChosenX = -1;/// 长按选中的行或者列的宽度或者高度值;double _opLongPressChosenWH = 0;/// 长按选中列int _opLongPressChosenY = -1;/// 点击是否同时选中行列bool _opIsClickChosenXY = false;/// 长按是否同时选中行列bool _opIsLongPressChosenXY = false;@overridevoid initState() {super.initState();_initData();}@overridevoid dispose() {//退出时关闭计时器防止内存泄露_stopLongPressTimer();super.dispose();}void _initData() {_xLength = widget.data[0].length;_yLength = widget.data.length;double columnHeight = 0;for (int i = 0; i < _xLength; i++) {double maxWidth = 0;for (int j = 0; j < _yLength; j++) {ITableData tableData = widget.data[j][i];TextPainter textPainter = TextPainter(text: TextSpan(text: tableData.text,style: TextStyle(color: widget.textColor, fontSize: widget.fontSize)),maxLines: 1,textDirection: TextDirection.ltr)..layout(minWidth: 0, maxWidth: double.infinity);if (maxWidth < textPainter.width) {maxWidth = textPainter.width;}columnHeight = textPainter.height;}_xWidthList.add([maxWidth, maxWidth]);}for (int j = 0; j < _yLength; j++) {_yHeightList.add([columnHeight, columnHeight]);}}void _startLongPressTimer(VoidCallback callback) {//计时器,每[_waitTime]毫秒执行一次var period = Duration(milliseconds: _waitTime);if (timer != null && timer!.isActive) {timer?.cancel();}timer = Timer(period, () {if (mounted) {_opIsLongPress = true;callback();}});}void _stopLongPressTimer() {timer?.cancel();}@overrideWidget build(BuildContext context) {return GestureDetector(onVerticalDragUpdate: (_) {},child: RepaintBoundary(child: SizedBox(width: widget.width,height: widget.height,child: ClipRect(child: Listener(child: CustomPaint(painter: _TablePainter(this,_offsetDx,_offsetDy,widget.data,_xLength,_yLength,_xWidthList,_yHeightList,_tempDrawData,_opClickChosenX,_opClickChosenY,_opLongPressChosenX,_opLongPressChosenY,_opIsClickChosenXY,_opIsLongPressChosenXY),),onPointerDown: (PointerDownEvent event) {_opIsMove = false;_opIsLongPress = false;_opIsLongPressChosenXY = false;_opIsClickChosenXY = false;_opLongPressChosenX = -1;_opLongPressChosenY = -1;_opClickChosenX = -1;_opClickChosenY = -1;//事件点击的中心位置Offset? eventOffset = event.localPosition;_diffOffsetDx = eventOffset.dx - _offsetDx;_diffOffsetDy = eventOffset.dy - _offsetDy;///判定按下在哪个单元格,并获取单元格内容//点击的横向坐标int y = 0;//点击的纵向坐标int x = 0;//计算横向和纵向坐标ITableData? tempX;ITableData? tempY;for (ITableData tableData in _tempDrawData) {if (eventOffset.dx < (tableData.left! + tableData.width!) &&eventOffset.dx > tableData.left!) {if (tempX == null || tempX.level! < tableData.level!) {tempX = tableData;}}if (eventOffset.dy < (tableData.top! + tableData.height!) &&eventOffset.dy > tableData.top!) {if (tempY == null || tempY.level! < tableData.level!) {tempY = tableData;}}}if (tempX != null) {x = tempX.x!;}if (tempY != null) {y = tempY.y!;}// 单击单元格判定if (x == 0 && y == 0) {_opIsClickChosenXY = true;} else if (x == 0) {_opClickChosenY = y;} else if (y == 0) {_opClickChosenX = x;} else {_opIsClickChosenXY = false;_opClickChosenX = -1;_opClickChosenY = -1;}//获取坐标对应的值_opTableData = widget.data[y][x];/// 长按拖拽判定_startLongPressTimer(() {if (y == 0 && x != 0) {// 判断宽度拖拽_opLongPressChosenX = x;_opLongPressChosenWH = _xWidthList[_opLongPressChosenX][1];} else if (x == 0 && y != 0) {//判断高度拖拽_opLongPressChosenY = y;_opLongPressChosenWH = _yHeightList[_opLongPressChosenY][1];} else if (y == 0 && x == 0) {//判断宽度和高度同时拖拽_opIsLongPressChosenXY = true;}if (_opLongPressChosenX != -1 ||_opLongPressChosenY != -1 ||_opIsLongPressChosenXY) {_opClickChosenX = -1;_opClickChosenY = -1;_opIsClickChosenXY = false;}setState(() {});});},onPointerMove: (PointerMoveEvent event) {_opIsMove = true;_stopLongPressTimer();//事件点击的中心位置Offset? eventOffset = event.localPosition;if (_opLongPressChosenX != -1) {///表格宽度拖拽if (_xWidthList[_opLongPressChosenX][1] >_xWidthList[_opLongPressChosenX][0] ||((eventOffset.dx - _diffOffsetDx) > 0 &&_xWidthList[_opLongPressChosenX][1] ==_xWidthList[_opLongPressChosenX][0])) {_xWidthList[_opLongPressChosenX][1] =_opLongPressChosenWH + eventOffset.dx - _diffOffsetDx;} else {_xWidthList[_opLongPressChosenX][1] =_xWidthList[_opLongPressChosenX][0];}} else if (_opLongPressChosenY != -1) {///表格高度拖拽if (_yHeightList[_opLongPressChosenY][1] >_yHeightList[_opLongPressChosenY][0] ||((eventOffset.dy - _diffOffsetDy) > 0 &&_yHeightList[_opLongPressChosenY][1] ==_yHeightList[_opLongPressChosenY][0])) {_yHeightList[_opLongPressChosenY][1] =_opLongPressChosenWH + eventOffset.dy - _diffOffsetDy;} else {_yHeightList[_opLongPressChosenY][1] =_yHeightList[_opLongPressChosenY][0];}} else if (_opIsLongPressChosenXY) {///宽高同时拖拽if (eventOffset.dx >= _xWidthList[0][0]) {_xWidthList[0][1] = eventOffset.dx;} else {_xWidthList[0][1] = _xWidthList[0][0];}if (eventOffset.dy >= _yHeightList[0][0]) {_yHeightList[0][1] = eventOffset.dy;} else {_yHeightList[0][1] = _yHeightList[0][0];}} else {///表格移动_offsetDx = eventOffset.dx - _diffOffsetDx;_offsetDy = eventOffset.dy - _diffOffsetDy;}/// 边界处理// 当有固定行时// 上边限定if (_offsetDy >= 0) {_offsetDy = 0;}// 左边限定if (_offsetDx >= 0) {_offsetDx = 0;}// 右边限定double rightOffset = 0;double tableWidth = _TableUtils.getTableRealWidth(_xWidthList);double tableHeight = _TableUtils.getTableRealHeight(_yHeightList);for (int i = 0; i < widget.lockList[3]; i++) {rightOffset += _xWidthList[_xWidthList.length - i - 1][1];}if (_offsetDx <= (widget.width + rightOffset) - tableWidth) {_offsetDx = (widget.width + rightOffset) - tableWidth;}// 下边限定List<double> reversalCellHeights =_TableUtils.reversalCellHeights(_yLength, _yHeightList);if (_offsetDy <=(widget.height +reversalCellHeights[widget.lockList[1] == 0? 0: widget.lockList[1] - 1]) -tableHeight) {_offsetDy = (widget.height +reversalCellHeights[widget.lockList[1] == 0? 0: widget.lockList[1] - 1]) -tableHeight;}//当表格宽度小于控件宽度,则不能水平移动if (tableWidth <= widget.width) {_offsetDx = 0;}//当表格高度小于控件高度,则不能上下移动if (tableHeight <= widget.height) {_offsetDy = 0;}setState(() {});},onPointerUp: (PointerUpEvent event) {if (_opIsLongPress) {//长按setState(() {_opLongPressChosenX = -1;_opLongPressChosenY = -1;_opIsLongPressChosenXY = false;});} else if (!_opIsMove) {//单击setState(() {_stopLongPressTimer();widget.onTap?.call(_opTableData as T);});}},)),)));}
}class _TablePainter<T> extends CustomPainter {/// statefinal _KqTableState state;/// x方向偏移量final double _offsetDx;/// y方向偏移量final double _offsetDy;final List<List<T>>? _data;/// 行数final int _xLength;/// 列数final int _yLength;/// 每列的行文本最大宽度列表final List<List<double>> _xWidthList;/// 每行的文本高度列表final List<List<double>> _columnHeightList;/// 绘制对象缓存final List<ITableData> _tempDrawData;/// 点击选中行final int _opClickChosenX;/// 点击选中列final int _opClickChosenY;/// 长按选中行final int _opLongPressChosenX;/// 长按选中列final int _opLongPressChosenY;/// 点击是否同时选中行列final bool _opIsClickChosenXY;/// 长按是否同时选中行列final bool _opIsLongPressChosenXY;_TablePainter(this.state,this._offsetDx,this._offsetDy,this._data,this._xLength,this._yLength,this._xWidthList,this._columnHeightList,this._tempDrawData,this._opClickChosenX,this._opClickChosenY,this._opLongPressChosenX,this._opLongPressChosenY,this._opIsClickChosenXY,this._opIsLongPressChosenXY);@overridevoid paint(Canvas canvas, Size size) {//表格边框画笔final Paint paint1 = Paint()..strokeCap = StrokeCap.square..isAntiAlias = true..style = PaintingStyle.stroke..color = state.widget.tableBorderColor;//表格背景画笔final Paint paint2 = Paint()..strokeCap = StrokeCap.square..isAntiAlias = true..style = PaintingStyle.fill..color = state.widget.tableColor;_tempDrawData.clear();drawTable(canvas, size, paint1, paint2);}void drawTable(Canvas canvas, Size size, Paint paint1, Paint paint2) {List<double> reversalRowWidths =_TableUtils.reversalCellWidths(_xLength, _xWidthList);List<double> reversalColumnHeights =_TableUtils.reversalCellHeights(_yLength, _columnHeightList);double totalCellWidth = 0;double cellWidth = 0;for (int i = 0; i < _xLength; i++) {totalCellWidth += cellWidth;cellWidth = _xWidthList[i][1];double totalCellHeight = 0;double cellHeight = 0;if (totalCellWidth + _offsetDx <= state.widget.width) {for (int j = 0; j < _yLength; j++) {String str = (_data![j][i] as ITableData).text;totalCellHeight += cellHeight;cellHeight = _columnHeightList[j][1];if (totalCellHeight + _offsetDy <= state.widget.height) {if (j < state.widget.lockList[0]) {//上if (i < state.widget.lockList[2]) {//左上角drawTableAdd(str, totalCellWidth, totalCellHeight, cellWidth,cellHeight, j, i,level: 2);} else if (i >= _xLength - state.widget.lockList[3]) {//右上角drawTableAdd(str,state.widget.width - reversalRowWidths[_xLength - i - 1],totalCellHeight,cellWidth,cellHeight,j,i,level: 2);} else {drawTableAdd(str, totalCellWidth + _offsetDx, totalCellHeight,cellWidth, cellHeight, j, i,level: 1);}} else if (i < state.widget.lockList[2]) {//左if (j >= _yLength - state.widget.lockList[1]) {//左下角drawTableAdd(str,totalCellWidth,state.widget.height -reversalColumnHeights[_yLength - j - 1],cellWidth,cellHeight,j,i,level: 2);} else {drawTableAdd(str, totalCellWidth, totalCellHeight + _offsetDy,cellWidth, cellHeight, j, i,level: 1);}} else if (j >= _yLength - state.widget.lockList[1]) {//下if (i >= _xLength - state.widget.lockList[3]) {// 右下角drawTableAdd(str,state.widget.width - reversalRowWidths[_xLength - i - 1],state.widget.height -reversalColumnHeights[_yLength - j - 1],cellWidth,cellHeight,j,i,level: 2);} else {drawTableAdd(str,totalCellWidth + _offsetDx,state.widget.height -reversalColumnHeights[_yLength - j - 1],cellWidth,cellHeight,j,i,level: 1);}} else if (i >= _xLength - state.widget.lockList[3]) {//右drawTableAdd(str,state.widget.width - reversalRowWidths[_xLength - i - 1],totalCellHeight + _offsetDy,cellWidth,cellHeight,j,i,level: 1);} else {drawTableAdd(str, totalCellWidth + _offsetDx,totalCellHeight + _offsetDy, cellWidth, cellHeight, j, i);}}}}}drawTableReal(canvas, size, paint1, paint2);}/// 把需要绘制的数据先放入内存中void drawTableAdd(String text, double left, double top, double width,double height, int y, int x,{int? level}) {if (top <= state.widget.height && left <= state.widget.width) {_tempDrawData.add(ITableData(text,left: left,top: top,width: width,height: height,y: y,x: x,level: level ?? 0));}}/// 遍历存好的数据进行绘制void drawTableReal(Canvas canvas, Size size, Paint paint1, Paint paint2) {//绘制层级排序_tempDrawData.sort((a, b) => a.level!.compareTo(b.level!));//绘制for (ITableData data in _tempDrawData) {if (data.top! <= state.widget.height &&data.left! <= state.widget.width) {//构建文字ui.ParagraphBuilder paragraphBuilder =ui.ParagraphBuilder(ui.ParagraphStyle())..pushStyle(ui.TextStyle(color: state.widget.textColor, fontSize: state.widget.fontSize))..addText(data.text);//先初始化paint2的颜色paint2.color = state.widget.tableColor;//表格有指定颜色的颜色if (state.widget.colorList.isNotEmpty) {for (TableColor tableColor in state.widget.colorList) {if (tableColor is RowColor && tableColor.index == data.y) {paint2.color = tableColor.color;} else if (tableColor is ColumnColor &&tableColor.index == data.x) {paint2.color = tableColor.color;}}}///画表格背景canvas.drawRect(Rect.fromLTWH(data.left!, data.top!, data.width!, data.height!),paint2);//画笔颜色调整,主要针点击背景覆盖层和边框绘制if ((_opLongPressChosenX != -1 && data.x == _opLongPressChosenX) ||(_opLongPressChosenY != -1 && data.y == _opLongPressChosenY) ||(_opIsLongPressChosenXY && (data.x == 0 || data.y == 0))) {paint2.color = state.widget.tableLongPressChosenColor;paint1.color = state.widget.tableLongPressChosenBorderColor;///画表格覆盖背景canvas.drawRect(Rect.fromLTWH(data.left!, data.top!, data.width!, data.height!),paint2);} else if ((_opClickChosenX != -1 && data.x == _opClickChosenX) ||(_opClickChosenY != -1 && data.y == _opClickChosenY) ||(_opIsClickChosenXY && (data.x == 0 || data.y == 0))) {paint2.color = state.widget.tableClickChosenColor;paint1.color = state.widget.tableClickChosenBorderColor;///画表格覆盖背景canvas.drawRect(Rect.fromLTWH(data.left!, data.top!, data.width!, data.height!),paint2);}///画表格边框canvas.drawRect(Rect.fromLTWH(data.left!, data.top!, data.width!, data.height!),paint1);///画表格文本canvas.drawParagraph(paragraphBuilder.build()..layout(ui.ParagraphConstraints(width: size.width)),Offset(data.left!, data.top!));}}}@overridebool shouldRepaint(covariant CustomPainter oldDelegate) {return true;}
}abstract class TableColor {final int index;final Color color;const TableColor(this.index, this.color);
}class RowColor extends TableColor {const RowColor(super.index, super.color);
}class ColumnColor extends TableColor {const ColumnColor(super.index, super.color);
}class ITableData {final String text;double? left;double? top;double? width;double? height;int? y;int? x;int? level = 0;ITableData(this.text,{this.left,this.top,this.width,this.height,this.y,this.x,this.level});@overrideString toString() {return "text=$text,left=$left,top=$top,width=$width,height=$height,row=$y,column=$x,level=$level";}
}class _TableUtils {/// 单元格宽度反向长度列表static List<double> reversalCellWidths(int xLength, List<List<double>> xWidthList) {List<double> totalReversalCellWidthList = [];double totalReversalCellWidth = 0;for (int i = xLength - 1; i >= 0; i--) {totalReversalCellWidth += xWidthList[i][1];totalReversalCellWidthList.add(totalReversalCellWidth);}return totalReversalCellWidthList;}/// 单元格高度反向高度列表static List<double> reversalCellHeights(int yLength, List<List<double>> yHeightList) {List<double> totalReversalCellHeightList = [];double totalReversalCellHeight = 0;for (int i = yLength - 1; i >= 0; i--) {totalReversalCellHeight += yHeightList[i][1];totalReversalCellHeightList.add(totalReversalCellHeight);}return totalReversalCellHeightList;}/// 获取表格宽高static double getTableRealWidth(List<List<double>> xWidthList) {double totalWidth = 0;for (int i = 0; i < xWidthList.length; i++) {totalWidth += xWidthList[i][1];}return totalWidth;}/// 获取表格宽高static double getTableRealHeight(List<List<double>> yHeightList) {double totalHeight = 0;for (int i = 0; i < yHeightList.length; i++) {totalHeight += yHeightList[i][1];}return totalHeight;}
}
- 使用
构建测试数据:
class TestTableData extends ITableData {TestTableData(super.text);
}List<List<TestTableData>> _getTableTestData2() {//模拟数据List<List<TestTableData>> data = [];Random random = Random();for (int i = 0; i < 20; i++) {List<TestTableData> dataList = [];for (int j = 0; j < 10; j++) {int seed = random.nextInt(100);dataList.add(TestTableData(" $seed "));}data.add(dataList);}return data;
}
使用:
KqTable<TestTableData>(data: _getTableTestData2(),onTap: (TestTableData data) {KqToast.showNormal(data.text);},
)
代码注释全面,有需要的朋友可以直接撸,如有问题,欢迎指正。