import 'package:flutter/material.dart'; import 'package:vitalapp/components/scroll_list.dart'; import 'table_column.dart'; const _cellPadding = EdgeInsets.symmetric( horizontal: 14, vertical: 12, ); const _cellTextStyle = TextStyle(fontSize: 14); const _headerBgColor = Color.fromRGBO(233, 237, 240, 1); /// 数据列表 class VitalTable extends StatefulWidget { VitalTable({ Key? key, required this.columns, required this.source, this.loading = false, this.autoHeight = true, this.showSelect = false, this.selecteds, this.onAllRowsSelected, this.onRowSelected, this.onRowTap, this.headerDecoration, this.rowDecoration, this.selectedDecoration, this.headerTextStyle, this.rowTextStyle, this.selectedTextStyle, this.noDataHintText, this.currectSelected, this.checkCellWidth = 90, this.cellPadding = _cellPadding, }) : assert(() { if (showSelect == true && selecteds == null) { throw FlutterError( "Property `selecteds` must not be null while `selecteds` is not null."); } return true; }()), super(key: key); final double checkCellWidth; final EdgeInsets? cellPadding; ///无数据提示 final String? noDataHintText; /// 列配置集合 final List> columns; /// 数据源 final List? source; /// 加载中 final bool loading; /// 自适应高度 final bool autoHeight; /// 是否显示选择列 final bool showSelect; /// 已选中行索引 final List? selecteds; /// 全部勾选事件 /// /// [value] 选中/取消选中 /// /// [selectedIndexs] 当前已选所有索引 final void Function(bool value, List selectedIndexs)? onAllRowsSelected; /// 列勾选事件 /// /// [value] 选中/取消选中 /// /// [index] 当前勾选行 /// /// [selectedIndexs] 当前已选所有索引 final void Function(bool value, int index, List selectedIndexs)? onRowSelected; /// 行点击事件 /// /// [value] 选中/取消选中 /// /// [index] 当前选中行 /// /// [selectedIndexs] 当前已选所有索引 final void Function(int index)? onRowTap; /// 表头装饰器 final BoxDecoration? headerDecoration; /// 行装饰器 final BoxDecoration? rowDecoration; /// 选中行装饰器 final BoxDecoration? selectedDecoration; /// 表头文本样式 final TextStyle? headerTextStyle; /// 行文本样式 final TextStyle? rowTextStyle; /// 选中行文本样式 final TextStyle? selectedTextStyle; /// 当前选中的 int? currectSelected = -1; @override State createState() => _VitalTableState(); } class _VitalTableState extends State> { final ScrollController _scrollController = ScrollController(); List _selectedIdxs = []; List _source = []; late int _currectSelected = widget.currectSelected ?? -1; @override void didUpdateWidget(VitalTable oldWidget) { _loadData(); if (widget.currectSelected != oldWidget.currectSelected) { _currectSelected = widget.currectSelected ?? -1; setState(() {}); } super.didUpdateWidget(oldWidget); } @override void initState() { _loadData(); super.initState(); } void _loadData() { _selectedIdxs = [if (widget.selecteds != null) ...widget.selecteds!]; _source = widget.source ?? []; } @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.start, children: [ // header if (widget.columns.isNotEmpty) buildHeader(), // loading if (widget.loading) const SizedBox( height: 5, child: LinearProgressIndicator(), ), // rows if (widget.autoHeight) Column(children: createRows()) else Expanded( child: AlwaysScrollListView( scrollController: _scrollController, child: ListView( padding: const EdgeInsets.only(right: 10), controller: _scrollController, children: createRows(), ), ), ), const SizedBox(height: 20), ], ); } static Alignment headerAlignSwitch(TextAlign? textAlign) { switch (textAlign) { case TextAlign.center: return Alignment.center; case TextAlign.left: return Alignment.centerLeft; case TextAlign.right: return Alignment.centerRight; default: return Alignment.center; } } Widget buildHeader() { final decoration = widget.headerDecoration ?? const BoxDecoration( color: _headerBgColor, // border: Border( // bottom: BorderSide(color: Colors.grey.shade300, width: 1), // ), ); final cells = []; if (widget.showSelect) { final checked = _selectedIdxs.isNotEmpty && _source.isNotEmpty && _selectedIdxs.length == _source.length; cells.add( buildCheckCell( value: checked, onChanged: (value) { final checked = value == true; setState(() { _selectedIdxs = checked ? List.generate(_source.length, (index) => index) : []; }); widget.onAllRowsSelected?.call(checked, _selectedIdxs); }, ), ); } cells.addAll( widget.columns.map( (column) { final child = column.headerRender != null ? column.headerRender!() : Container( padding: widget.cellPadding, alignment: headerAlignSwitch(column.textAlign), child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ Text( column.headerText!, textAlign: column.textAlign, style: widget.headerTextStyle ?? const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, ), ) ], ), ); final cell = buildCellBox(column, child); return Expanded(flex: column.actualFlex, child: cell); }, ), ); return Container( decoration: decoration, child: Row( mainAxisSize: MainAxisSize.min, children: cells, ), ); } List createRows() { if (_source.isEmpty) { if (widget.loading) return const []; var noDataHintText = widget.noDataHintText; return [ Container( height: 50, alignment: Alignment.center, child: Text( noDataHintText ?? "No data found", style: const TextStyle(fontSize: 20), ), ) ]; } final rowDecoration = widget.rowDecoration ?? const BoxDecoration( // border: Border( // bottom: BorderSide(color: Colors.grey.shade300, width: 1), // ), ); final selectedDecoration = widget.selectedDecoration ?? rowDecoration; final rows = []; for (var i = 0, len = _source.length; i < len; i++) { final rowData = _source[i]; rows.add(buildRow(rowData, i, rowDecoration, selectedDecoration)); } return rows; } Widget buildRow( T rowData, int index, BoxDecoration rowDecoration, BoxDecoration selectedDecoration, ) { var isSelected = false; var decoration = rowDecoration; final textStyle = widget.rowTextStyle ?? _cellTextStyle; final selectedTextStyle = widget.selectedTextStyle ?? textStyle; final cells = []; if (widget.showSelect) { isSelected = _selectedIdxs.contains(index); decoration = selectedDecoration; cells.add( buildCheckCell( value: _selectedIdxs.contains(index), onChanged: (value) { final checked = value == true; setState(() { if (checked) { if (!_selectedIdxs.contains(index)) { _selectedIdxs.add(index); } } else { if (_selectedIdxs.contains(index)) { _selectedIdxs.remove(index); } } }); widget.onRowSelected?.call(checked, index, _selectedIdxs); }, ), ); } cells.addAll( widget.columns.map( (column) { final child = column.render != null ? column.render!.call(rowData, index) : Container( padding: _cellPadding, alignment: headerAlignSwitch(column.textAlign), child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ Tooltip( message: column.textFormatter!.call(rowData, index), child: Text( _breakWord( column.textFormatter!.call(rowData, index)), textAlign: column.textAlign, maxLines: 1, overflow: TextOverflow.ellipsis, style: isSelected ? selectedTextStyle.copyWith( fontSize: column.textFontSize, ) : textStyle.copyWith( fontSize: column.textFontSize, ), ), ) ], ), // child: Text( // column.textFormatter!.call(rowData, index), // textAlign: column.textAlign, // style: isSelected // ? widget.selectedTextStyle // : widget.rowTextStyle, // ), ); final cell = buildCellBox(column, child); return Expanded(flex: column.actualFlex, child: cell); }, ), ); return Material( key: ValueKey("${index}_${rowData.hashCode}"), child: Ink( color: _currectSelected == index ? Theme.of(context).secondaryHeaderColor : index % 2 == 0 ? Theme.of(context).scaffoldBackgroundColor : decoration.color ?? Colors.white, child: InkWell( mouseCursor: SystemMouseCursors.basic, onTap: () { setState(() {}); _currectSelected = index; widget.onRowTap?.call(index); }, hoverColor: Theme.of(context).secondaryHeaderColor, child: Container( decoration: decoration, child: Row( mainAxisSize: MainAxisSize.min, children: cells, ), ), ), ), ); } Widget buildCellBox(TableColumn column, Widget child) { if (!column.hasWidthLimit) return child; return Container( alignment: Alignment.center, width: column.width, constraints: BoxConstraints( maxWidth: column.maxWidth ?? double.infinity, minWidth: column.minWidth ?? 0.0, ), child: child, ); } Widget buildCheckCell({ required bool value, required ValueChanged onChanged, }) { return Container( width: widget.checkCellWidth, alignment: Alignment.center, child: Checkbox( activeColor: Theme.of(context).primaryColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), ), side: const BorderSide( color: Colors.grey, width: 1, ), value: value, onChanged: (val) { onChanged(val == true); }, ), ); } } /// 插入零宽空格 String _breakWord(String word) { if (word.isEmpty) return word; String breakWord = ' '; for (var element in word.runes) { breakWord += String.fromCharCode(element); breakWord += '\u200B'; } return breakWord; }