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% blue
this.tableClickChosenBorderColor =
const Color.fromARGB(230, 130, 177, 255), //90% blue
this.tableLongPressChosenColor =
const Color.fromARGB(102, 29, 233, 182), //a=40% green
this.tableLongPressChosenBorderColor =
const Color.fromARGB(230, 29, 233, 182)}); //a=90% green
@override
State<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;
@override
void initState() {
super.initState();
_initData();
}
@override
void 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();
}
@override
Widget 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 {
/// state
final _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);
@override
void 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!));
}
}
}
@override
bool 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});
@override
String 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);
},
)
代码注释全面,有需要的朋友可以直接撸,如有问题,欢迎指正。