diff --git a/lib/src/builtins/flutter_html_table.dart b/lib/src/builtins/flutter_html_table.dart index c1c1f92809..f12224e370 100644 --- a/lib/src/builtins/flutter_html_table.dart +++ b/lib/src/builtins/flutter_html_table.dart @@ -6,6 +6,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_layout_grid/flutter_layout_grid.dart'; +import '../tables/fixed_headers/fixed_headers_table_widget.dart'; +import '../tables/table_helper.dart'; + /// [TableHtmlExtension] adds support for the element to the flutter_html library. /// , , , , , and are also /// supported. @@ -14,7 +17,14 @@ import 'package:flutter_layout_grid/flutter_layout_grid.dart'; /// /// class TableHtmlExtension extends HtmlExtension { - const TableHtmlExtension(); + /// It use shrinkWrap option to CssBoxWidgets. But in build method it fill available width. + /// Use only if something wrong with the view. + /// Uses shrinkWrap option in many widgets can be very expensive, because it means that you have to measure everything. + final bool shrinkAndFill; + bool hasFixedHeaders = false; + final tableHelper = TableHelper(); + + TableHtmlExtension({this.shrinkAndFill = false}); @override Set get supportedTags => { @@ -32,6 +42,8 @@ class TableHtmlExtension extends HtmlExtension { @override StyledElement prepare(ExtensionContext context, List children) { if (context.elementName == "table") { + hasFixedHeaders = context.attributes.containsKey("data-fixed-header"); + final cellDescendants = _getCellDescendants(children); return TableElement( @@ -105,6 +117,7 @@ class TableHtmlExtension extends HtmlExtension { if (context.elementName == "table") { return WidgetSpan( child: CssBoxWidget( + shrinkWrap: shrinkAndFill, style: context.styledElement!.style, child: LayoutBuilder( builder: (ctx, constraints) { @@ -114,12 +127,25 @@ class TableHtmlExtension extends HtmlExtension { } else { width = MediaQuery.sizeOf(ctx).width - 32; } - return _layoutCells( - context.styledElement as TableElement, - context.builtChildrenMap!, - context, - width, - ); + if (hasFixedHeaders) { + return FixedHeadersTableWidget( + table: context.styledElement as TableElement, + parsedCells: context.builtChildrenMap!, + context: context, + width: width, + shrinkWrap: shrinkAndFill, + tableHelper: tableHelper, + ); + } else { + return _layoutCells( + context.styledElement as TableElement, + context.builtChildrenMap!, + context, + width, + shrinkAndFill, + tableHelper, + ); + } }, ), ), @@ -151,15 +177,16 @@ List _getCellDescendants(List children) { return descendants; } -Widget _layoutCells(TableElement table, Map parsedCells, ExtensionContext context, double width) { - final minWidths = _getColWidths(table.tableStructure); +Widget _layoutCells( + TableElement table, Map parsedCells, ExtensionContext context, double width, bool shrinkAndFill, TableHelper tableHelper) { + final minWidths = tableHelper.getColWidths(table.tableStructure); double requiredWidth = 0; for (final minWidth in minWidths) { requiredWidth += minWidth; } List cellWidths; - if (requiredWidth < width) { + if (shrinkAndFill || requiredWidth < width) { final extra = (width - requiredWidth) / minWidths.length; cellWidths = List.generate(minWidths.length, (index) => minWidths[index] + extra); } else { @@ -219,12 +246,13 @@ Widget _layoutCells(TableElement table, Map parsedCel rowStart: rowi, rowSpan: min(child.rowspan, rows.length - rowi), child: CssBoxWidget( + shrinkWrap: shrinkAndFill, style: child.style.merge(row.style), child: Builder(builder: (context) { final alignment = child.style.direction ?? Directionality.of(context); return SizedBox.expand( child: Container( - alignment: _getCellAlignment(child, alignment), + alignment: tableHelper.getCellAlignment(child, alignment), child: CssBoxWidget.withInlineSpanChildren( children: [parsedCells[child] ?? const TextSpan(text: "error")], style: Style(), @@ -266,129 +294,6 @@ Widget _layoutCells(TableElement table, Map parsedCel )); } -List _getColWidths(List children) { - final widths = []; - for (final child in children) { - List partialWidths = []; - if (child is TableRowLayoutElement) { - partialWidths = _getColWidthsFromRow(child); - } else { - partialWidths = _getColWidths(child.children); - } - if (partialWidths.isEmpty) continue; - for (int i = 0; i < partialWidths.length; ++i) { - double partial = partialWidths[i]; - if (widths.length <= i) { - widths.add(partial); - } else if (widths[i] < partial) { - widths[i] = partial; - } - } - } - return widths; -} - -List _getColWidthsFromRow(TableRowLayoutElement row) { - List widths = []; - for (final cell in row.children) { - if (cell is TableCellElement) { - WidthInfo info = WidthInfo(); - for (final child in cell.children) { - _getCellInfo(child, info); - } - double minWidth = info.requiredWidth + 32; - widths.add(minWidth); - } - } - return widths; -} - -void _getCellInfo(StyledElement element, WidthInfo info) { - if (element is TextContentElement) { - final regex = RegExp(r'\w+|\s+|[^\w\s]'); - final wordRegex = RegExp(r'\w+'); - final text = element.text; - if (text == null || text.isEmpty) return; - final words = regex.allMatches(text).map((m) => m.group(0)!).toList(); - for (final word in words) { - double wordWidth = TextPainter.computeWidth( - text: TextSpan( - text: word, - style: TextStyle( - fontSize: element.style.fontSize?.value ?? 16, - fontFamily: element.style.fontFamily, - fontWeight: element.style.fontWeight, - fontStyle: element.style.fontStyle, - )), - textDirection: TextDirection.ltr, - ); - if (info.join && wordRegex.hasMatch(word)) { - info.width += wordWidth; - } else { - info.width = wordWidth; - } - if (info.width > info.requiredWidth) { - info.requiredWidth = info.width; - } - info.join = wordRegex.hasMatch(word); - } - } else { - for (final child in element.children) { - _getCellInfo(child, info); - } - } -} - -class WidthInfo { - double width = 0; - double requiredWidth = 0; - bool join = false; -} - -Alignment _getCellAlignment(TableCellElement cell, TextDirection alignment) { - Alignment verticalAlignment; - - switch (cell.style.verticalAlign) { - case VerticalAlign.baseline: - case VerticalAlign.sub: - case VerticalAlign.sup: - case VerticalAlign.top: - verticalAlignment = Alignment.topCenter; - break; - case VerticalAlign.middle: - verticalAlignment = Alignment.center; - break; - case VerticalAlign.bottom: - verticalAlignment = Alignment.bottomCenter; - break; - } - - switch (cell.style.textAlign) { - case TextAlign.left: - return verticalAlignment + Alignment.centerLeft; - case TextAlign.right: - return verticalAlignment + Alignment.centerRight; - case TextAlign.center: - return verticalAlignment + Alignment.center; - case null: - case TextAlign.start: - case TextAlign.justify: - switch (alignment) { - case TextDirection.rtl: - return verticalAlignment + Alignment.centerRight; - case TextDirection.ltr: - return verticalAlignment + Alignment.centerLeft; - } - case TextAlign.end: - switch (alignment) { - case TextDirection.rtl: - return verticalAlignment + Alignment.centerLeft; - case TextDirection.ltr: - return verticalAlignment + Alignment.centerRight; - } - } -} - class TableCellElement extends StyledElement { int colspan = 1; int rowspan = 1; diff --git a/lib/src/builtins/styled_element_builtin.dart b/lib/src/builtins/styled_element_builtin.dart index f035eb2140..73aca0c8bb 100644 --- a/lib/src/builtins/styled_element_builtin.dart +++ b/lib/src/builtins/styled_element_builtin.dart @@ -390,18 +390,9 @@ class StyledElementBuiltIn extends HtmlExtension { case "strong": continue bold; case "sub": - if (_hasOnlyDigits(children)) { - // We can use FontFeature.subscripts since this just looks nice - styledElement.style = Style( - fontFeatureSettings: [const FontFeature.subscripts()], - ); - } else { - // For the rest we should just reduce the height of the text - // Using WidgetSpan with translation should not be used because line breaks can occur just before subscript text - styledElement.style = Style( - fontSize: FontSize(50, Unit.percent), - ); - } + styledElement.style = Style( + fontSize: FontSize(50, Unit.percent), + ); break; case "summary": styledElement.style = Style( @@ -429,20 +420,6 @@ class StyledElementBuiltIn extends HtmlExtension { return styledElement; } - bool _hasOnlyDigits(List children) { - if (children.isEmpty) return true; - for (var child in children) { - if (child is TextContentElement) { - final text = child.text; - if (text == null || text.isEmpty) continue; - for (int codeUnit in text.codeUnits) { - if ((codeUnit ^ 0x30) > 9) return false; - } - } - } - return true; - } - @override InlineSpan build(ExtensionContext context) { if (context.styledElement!.style.display == Display.listItem || diff --git a/lib/src/css_box_widget.dart b/lib/src/css_box_widget.dart index 337e70c0a4..3b1869c291 100644 --- a/lib/src/css_box_widget.dart +++ b/lib/src/css_box_widget.dart @@ -76,16 +76,23 @@ class CssBoxWidget extends StatelessWidget { Container( decoration: BoxDecoration( border: style.border, - color: style.backgroundColor, //Colors the padding and content boxes + borderRadius: style.borderRadius, + ), + child: Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: style.borderRadius, + color: style.backgroundColor, //Colors the padding and content boxes + ), + width: _shouldExpandToFillBlock() ? double.infinity : null, + padding: padding, + child: top + ? child + : MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), + child: child, + ), ), - width: _shouldExpandToFillBlock() ? double.infinity : null, - padding: padding, - child: top - ? child - : MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), - child: child, - ), ), if (markerBox != null) ClickableRichText(text: markerBox), ], diff --git a/lib/src/css_parser.dart b/lib/src/css_parser.dart index c0fcf02f8b..00e43d1a4c 100644 --- a/lib/src/css_parser.dart +++ b/lib/src/css_parser.dart @@ -61,6 +61,29 @@ Style declarationsToStyle(Map> declarations) { style.border = ExpressionMapping.expressionToBorder( borderWidths, borderStyles, borderColors); break; + case 'border-radius': + List? borderRadiuses = + value.whereType().toList(); + + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderRadiuses.removeWhere((element) => + element == null || + (element is! css.LengthTerm && + element is! css.PercentageTerm && + element is! css.EmTerm && + element is! css.RemTerm && + element is! css.NumberTerm)); + + css.LiteralTerm? borderRadius = + borderRadiuses.firstWhereOrNull((element) => element != null); + + BorderRadius newBorderRadius = BorderRadius.all(Radius.circular( + ExpressionMapping.expressionToBorderRadius( + borderRadius)), + + ); + style.borderRadius = newBorderRadius; + break; case 'border-left': List? borderWidths = value.whereType().toList(); @@ -875,6 +898,23 @@ class ExpressionMapping { return BorderStyle.none; } + static double expressionToBorderRadius(css.Expression? value) { + if (value is css.NumberTerm) { + return double.tryParse(value.text) ?? 1.0; + } else if (value is css.PercentageTerm) { + return (double.tryParse(value.text) ?? 400) / 100; + } else if (value is css.EmTerm) { + return double.tryParse(value.text) ?? 1.0; + } else if (value is css.RemTerm) { + return double.tryParse(value.text) ?? 1.0; + } else if (value is css.LengthTerm) { + return double.tryParse( + value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')) ?? + 1.0; + } + return 4.0; + } + static Color? expressionToColor(css.Expression? value) { if (value != null) { if (value is css.HexColorTerm) { diff --git a/lib/src/style.dart b/lib/src/style.dart index 747b1d9016..d19696fd22 100644 --- a/lib/src/style.dart +++ b/lib/src/style.dart @@ -214,6 +214,7 @@ class Style { String? after; Border? border; Alignment? alignment; + BorderRadius? borderRadius; /// MaxLine /// @@ -267,6 +268,7 @@ class Style { this.after, this.border, this.alignment, + this.borderRadius, this.maxLines, this.textOverflow, this.textTransform = TextTransform.none, @@ -299,7 +301,6 @@ class Style { TextStyle generateTextStyle() { return TextStyle( - backgroundColor: backgroundColor, color: color, decoration: textDecoration, decorationColor: textDecorationColor, @@ -359,6 +360,7 @@ class Style { before: other.before, after: other.after, border: border?.merge(other.border) ?? other.border, + borderRadius: other.borderRadius, alignment: other.alignment, maxLines: other.maxLines, textOverflow: other.textOverflow, @@ -378,9 +380,6 @@ class Style { : lineHeight; return child.copyWith( - backgroundColor: child.backgroundColor != Colors.transparent - ? child.backgroundColor - : backgroundColor, color: child.color ?? color, direction: child.direction ?? direction, display: display == Display.none ? display : child.display, @@ -452,6 +451,7 @@ class Style { TextOverflow? textOverflow, TextTransform? textTransform, bool? beforeAfterNull, + BorderRadius? borderRadius, }) { return Style( backgroundColor: backgroundColor ?? this.backgroundColor, @@ -489,6 +489,7 @@ class Style { before: beforeAfterNull == true ? null : before ?? this.before, after: beforeAfterNull == true ? null : after ?? this.after, border: border ?? this.border, + borderRadius: borderRadius ?? this.borderRadius, alignment: alignment ?? this.alignment, maxLines: maxLines ?? this.maxLines, textOverflow: textOverflow ?? this.textOverflow, diff --git a/lib/src/tables/fixed_headers/adapters/layout_info_to_sliver_adapter.dart b/lib/src/tables/fixed_headers/adapters/layout_info_to_sliver_adapter.dart new file mode 100644 index 0000000000..16d835fb34 --- /dev/null +++ b/lib/src/tables/fixed_headers/adapters/layout_info_to_sliver_adapter.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/src/tables/fixed_headers/model/builder_info.dart'; +import 'package:flutter_layout_grid/flutter_layout_grid.dart'; + +import '../scroll_group_synchronizer.dart'; + +class LayoutInfoToSliverAdapter { + final List rowSizes; + final ScrollGroupSynchronizer _verticalScrollGroupSynchronizer; + final ScrollGroupSynchronizer _horizontalScrollGroupSynchronizer; + + LayoutInfoToSliverAdapter(this.rowSizes, this._verticalScrollGroupSynchronizer, this._horizontalScrollGroupSynchronizer); + + SliverToBoxAdapter transform(LayoutBuilderInfo layoutBuilderInfo) { + final List headerChildren = []; + final List bodyChildren = []; + + for (var e in layoutBuilderInfo.headersLayoutBuilderInfo) { + if (e?.gridPlacements != null) { + headerChildren.addAll(e!.gridPlacements); + } + } + + final headerLayoutGrid = LayoutGrid( + columnSizes: layoutBuilderInfo.headerColumnSizes, + rowSizes: rowSizes, + children: headerChildren, + ); + LayoutGrid? bodyLayoutGrid; + if (layoutBuilderInfo.bodyColumnSizes.isNotEmpty) { + for (var e in layoutBuilderInfo.bodiesLayoutBuilderInfo) { + if (e?.gridPlacements != null) { + for (var gridPlacementList in e!.gridPlacements) { + bodyChildren.addAll(gridPlacementList); + } + } + } + bodyLayoutGrid = LayoutGrid( + columnSizes: layoutBuilderInfo.bodyColumnSizes, + rowSizes: rowSizes, + children: bodyChildren, + ); + } + + final bodyVerticalController = ScrollController(); + final bodyHorizontalController = ScrollController(); + _verticalScrollGroupSynchronizer.addController(bodyVerticalController); + _horizontalScrollGroupSynchronizer.addController(bodyHorizontalController); + + if (layoutBuilderInfo.bodyColumnSizes.isNotEmpty) { + final stickyColumnVerticalController = ScrollController(); + final stickyColumnHorizontalController = ScrollController(); + _verticalScrollGroupSynchronizer.addController(stickyColumnVerticalController); + _horizontalScrollGroupSynchronizer.addController(stickyColumnHorizontalController); + + final sliver = SliverToBoxAdapter( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + controller: stickyColumnVerticalController, + scrollDirection: Axis.vertical, + physics: const ClampingScrollPhysics(), + child: headerLayoutGrid, + ), + Expanded( + child: SingleChildScrollView( + controller: bodyVerticalController, + scrollDirection: Axis.vertical, + physics: const ClampingScrollPhysics(), + child: SingleChildScrollView( + controller: bodyHorizontalController, scrollDirection: Axis.horizontal, physics: const ClampingScrollPhysics(), child: bodyLayoutGrid), + ), + ), + ], + ), + ); + return sliver; + } else { + /// For full spannable headers + final sliver = SliverToBoxAdapter( + child: SingleChildScrollView( + controller: bodyHorizontalController, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SingleChildScrollView( + controller: bodyVerticalController, scrollDirection: Axis.vertical, physics: const ClampingScrollPhysics(), child: headerLayoutGrid), + ]), + ), + ); + return sliver; + } + } +} \ No newline at end of file diff --git a/lib/src/tables/fixed_headers/fixed_headers_table_widget.dart b/lib/src/tables/fixed_headers/fixed_headers_table_widget.dart new file mode 100644 index 0000000000..d2438b9bab --- /dev/null +++ b/lib/src/tables/fixed_headers/fixed_headers_table_widget.dart @@ -0,0 +1,564 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/src/tables/fixed_headers/model/builder_info.dart'; +import 'package:flutter_html/src/tables/fixed_headers/scroll_group_synchronizer.dart'; +import 'package:flutter_layout_grid/flutter_layout_grid.dart'; + +import '../../../../flutter_html.dart'; +import '../table_helper.dart'; +import 'adapters/layout_info_to_sliver_adapter.dart'; + +class FixedHeadersTableWidget extends StatefulWidget { + final TableElement table; + final Map parsedCells; + final ExtensionContext context; + final double width; + final bool shrinkWrap; + final TableHelper _tableHelper; + + const FixedHeadersTableWidget( + {super.key, + required this.table, + required this.parsedCells, + required this.context, + required this.width, + required this.shrinkWrap, + required TableHelper tableHelper}) + : _tableHelper = tableHelper; + + @override + State createState() => _FixedHeadersTableWidgetState(); +} + +class _FixedHeadersTableWidgetState extends State { + late double width; + + late ScrollGroupSynchronizer _verticalScrollGroupSynchronizer; + late ScrollGroupSynchronizer _horizontalScrollGroupSynchronizer; + final _horizontalRowScrollController = ScrollController(); + + final GlobalKey _headerContentKey = GlobalKey(); + late List intrinsicHeightRowsKeys; + List _calculatedFullRowIntrinsicHeightList = []; + double? _calculatedHeaderHeight; + bool _isMeasured = false; + + @override + void initState() { + width = widget.width; + _horizontalScrollGroupSynchronizer = ScrollGroupSynchronizer([_horizontalRowScrollController]); + _verticalScrollGroupSynchronizer = ScrollGroupSynchronizer(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _measureWidgets(); + }); + super.initState(); + } + + void _measureWidgets() { + if (!mounted) return; + final headerContext = _headerContentKey.currentContext; + double? newHeaderHeight; + bool isContextNull = false; + if (headerContext != null) { + newHeaderHeight = headerContext.size?.height; + } else { + isContextNull = true; + } + List newCalculatedIntrinsicHeights = _calculatedFullRowIntrinsicHeightList; + bool isCalculatedHeightTableChanged = false; + for (var i = 0; i < intrinsicHeightRowsKeys.length; ++i) { + final hasIndex = i < _calculatedFullRowIntrinsicHeightList.length - 1; + final height = hasIndex ? _calculatedFullRowIntrinsicHeightList[i] : null; + final context = intrinsicHeightRowsKeys[i].currentContext; + if (context != null) { + final newHeight = context.size?.height; + if (newHeight != null && newHeight != height) { + isCalculatedHeightTableChanged = true; + if (hasIndex) { + newCalculatedIntrinsicHeights[i] = newHeight; + } else { + newCalculatedIntrinsicHeights.add(newHeight); + } + } + } else { + isContextNull = true; + } + } + + if (isContextNull) { + if (mounted) { + setState(() { + _isMeasured = false; + }); + } + return; + } + + if (isCalculatedHeightTableChanged || (newHeaderHeight != null && newHeaderHeight != _calculatedHeaderHeight)) { + if (mounted) { + setState(() { + if ((newHeaderHeight != null && newHeaderHeight != _calculatedHeaderHeight)) { + _calculatedHeaderHeight = newHeaderHeight; + } + if (isCalculatedHeightTableChanged) { + _calculatedFullRowIntrinsicHeightList = newCalculatedIntrinsicHeights; + } + _isMeasured = true; + }); + } + } + } + + @override + void didUpdateWidget(covariant FixedHeadersTableWidget oldWidget) { + setState(() { + if (oldWidget.width != widget.width) { + width = widget.width; + } + _isMeasured = false; + _calculatedFullRowIntrinsicHeightList = []; + _calculatedHeaderHeight = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_isMeasured) { + _measureWidgets(); + } + }); + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _horizontalScrollGroupSynchronizer.dispose(); + _verticalScrollGroupSynchronizer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final minWidths = widget._tableHelper.getColWidths(widget.table.tableStructure); + double requiredWidth = 0; + for (final minWidth in minWidths) { + requiredWidth += minWidth; + } + + List cellWidths; + if (!widget.shrinkWrap && requiredWidth < width) { + final extra = (width - requiredWidth) / minWidths.length; + cellWidths = List.generate(minWidths.length, (index) => minWidths[index] + extra); + } else { + cellWidths = minWidths; + width = requiredWidth + 32; + } + + final rows = []; + for (var child in widget.table.tableStructure) { + if (child is TableSectionLayoutElement) { + rows.addAll(child.children.whereType()); + } else if (child is TableRowLayoutElement) { + rows.add(child); + } + } + + // All table rows have a height intrinsic to their (spanned) contents + final rowSizes = List.generate( + rows.length, + (_) => const IntrinsicContentTrackSize(), + ); + + // Calculate column bounds + int columnMax = 0; + List rowSpanOffsets = []; + for (final row in rows) { + final cols = row.children.whereType().fold(0, (int value, child) => value + child.colspan) + + rowSpanOffsets.fold(0, (int offset, child) => child); + columnMax = max(cols, columnMax); + rowSpanOffsets = [ + ...rowSpanOffsets.map((value) => value - 1).where((value) => value > 0), + ...row.children.whereType().map((cell) => cell.rowspan - 1), + ]; + // Ignore width set in CSS, there is only one proper layout... + row.children.whereType().forEach((cell) => cell.style.width = null); + } + + // Place the cells in the rows/columns + final bodyCells = []; + final columnRowOffset = List.generate(columnMax, (_) => 0); + final columnColspanOffset = List.generate(columnMax, (_) => 0); + int rowi = 0; + late LayoutGrid cornerLayoutGrid; + late List headersRowGridPlacement = []; + + late List headersColumnGridPlacement = []; + + /// Collect build info to rebuild widget with different height + List headersColumnBuildInfoElements = []; + List bodyCellsBuildInfoElements = []; + late GridPlacementBuildInfo cornerBuildInfoElement; + List headerRowBuildInfoElements = []; + + for (var row in rows) { + int columni = 0; + int lastHeaderColspan = 1; + for (var child in row.children) { + if (columni > columnMax - 1) { + break; + } + if (child is TableCellElement) { + while (columnRowOffset[columni] > 0) { + columnRowOffset[columni] = columnRowOffset[columni] - 1; + columni += columnColspanOffset[columni].clamp(1, columnMax - columni - 1); + } + if (columni == 0 && rowi == 0) { + final cornerGridPlacement = buildGridPlacement(columni, child, columnMax, rowi, rows, row); + cornerBuildInfoElement = GridPlacementBuildInfo(columni, child, columnMax, rowi, rows, row); + cornerLayoutGrid = LayoutGrid( + columnSizes: [FixedTrackSize(cellWidths[columni])], + rowSizes: [rowSizes[rowi]], + children: [cornerGridPlacement], + ); + } else if (columni == 0 && rowi > 0) { + final gridBuildInfo = GridPlacementBuildInfo(columni, child, columnMax, rowi - 1, rows, row); + final gridPlacement = buildGridPlacement(columni, child, columnMax, rowi - 1, rows, row); + headersColumnBuildInfoElements.add(gridBuildInfo); + headersColumnGridPlacement.add(gridPlacement); + lastHeaderColspan = child.colspan; + } else if (columni > 0 && rowi == 0) { + //before headerRow we use cornerGrid so we have to use (columni - 1) + final gridBuildInfo = GridPlacementBuildInfo(columni - 1, child, columnMax, rowi, rows, row); + headerRowBuildInfoElements.add(gridBuildInfo); + headersRowGridPlacement.add(buildGridPlacement(columni - 1, child, columnMax, rowi, rows, row)); + } else { + //body is separate view so we use (columni - 1) + bodyCellsBuildInfoElements.add(GridPlacementBuildInfo(columni - lastHeaderColspan, child, columnMax, rowi - 1, rows, row)); + bodyCells.add(buildGridPlacement(columni - lastHeaderColspan, child, columnMax, rowi - 1, rows, row)); + } + columnRowOffset[columni] = child.rowspan - 1; + columnColspanOffset[columni] = child.colspan; + columni += child.colspan; + } + } + while (columni < columnRowOffset.length) { + columnRowOffset[columni] = columnRowOffset[columni] - 1; + columni++; + } + rowi++; + } + + // Create column tracks (insofar there were no colgroups that already defined them) + List finalColumnSizes = List.generate(cellWidths.length, (index) => FixedTrackSize(cellWidths[index])); + + if (finalColumnSizes.isEmpty || rowSizes.isEmpty) { + // No actual cells to show + return const SizedBox(); + } + + // If not measured yet, render whole view to calculate height + if (!_isMeasured) { + // Prepare data for render offstage (invisible view) to calculate its height. + List intrinsicHeightRowsList = _prepareIntrinsicHeightRowsList(finalColumnSizes, rowSizes, bodyCells, headersColumnGridPlacement); + + // Header row is the first row of table. It will be sticky. + final headerRowLayoutGrid = + LayoutGrid(gridFit: GridFit.passthrough, columnSizes: finalColumnSizes.sublist(1), rowSizes: rowSizes, children: headersRowGridPlacement); + + final headerRowContent = IntrinsicHeight( + key: _headerContentKey, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: (finalColumnSizes[0] as FixedTrackSize).sizeInPx, child: cornerLayoutGrid), + Expanded( + child: SingleChildScrollView( + controller: _horizontalRowScrollController, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: headerRowLayoutGrid, + ), + ), + ], + ), + ); + + return Stack( + children: [ + Offstage( + child: SingleChildScrollView( + child: Column( + children: [headerRowContent, ...intrinsicHeightRowsList], + ), + )), + ], + ); + } + + final calculatedHeaderRowContent = _recreateHeaderRowContent(cornerBuildInfoElement, headerRowBuildInfoElements, finalColumnSizes, cellWidths, rowSizes); + + // Initialize values before long for loop + List headerLayoutGrids = []; + List bodyLayoutGrids = []; + List layoutBuilderInfoList = []; + + for (int i = 0; i < _calculatedFullRowIntrinsicHeightList.length; ++i) { + /// Get info about header column sizes for later comparison is header column sized was changed + final headerColumnI = headersColumnBuildInfoElements.where((e) => e.rowi == i).firstOrNull; + int? headerColumnSpan = getColumnSpan(headerColumnI?.child, columnMax, headerColumnI?.columni); + List headerColumnSizes = headerColumnSpan == null ? [] : finalColumnSizes.sublist(0, headerColumnSpan); + + final bodyCellsI = bodyCellsBuildInfoElements.where((e) => e.rowi == i).toList(growable: false); + List bodyColumnSizes = []; + final headerNotNullSpan = headerColumnSpan ?? 0; + if (bodyCellsI.isNotEmpty) { + final headerColumnSize = bodyCellsI.fold(0, (previousValue, element) { + final columnSpan = getColumnSpan(element.child, columnMax, element.columni) ?? 0; + return previousValue + columnSpan; + }); + bodyColumnSizes = finalColumnSizes.sublist(headerNotNullSpan, headerColumnSize + headerNotNullSpan); + } + + /// Check is currentLayout is the same as previous + bool isDifferentLayout = true; + if ((layoutBuilderInfoList.isEmpty) || + !hasSameColumnSize(layoutBuilderInfoList.last.headerColumnSizes, headerColumnSizes, layoutBuilderInfoList.last.bodyColumnSizes, bodyColumnSizes)) { + isDifferentLayout = true; + } else { + isDifferentLayout = false; + } + + /// Build info about header and add it to appropriate category + HeaderLayoutBuilderInfo? matchingHeaderLayoutBuilderInfo; + if (headerColumnI != null) { + //if it is differentLayout we start from index 0 + //else we get lastHeaderIndex + final rowi = isDifferentLayout ? 0 : (layoutBuilderInfoList.last.lastHeaderIdx ?? 0) + 1; + final headerGridPlacement = buildGridPlacement( + headerColumnI.columni, headerColumnI.child, headerColumnI.columnMax, rowi, headerColumnI.rows, headerColumnI.row, + height: _calculatedFullRowIntrinsicHeightList[i]); + headerColumnSizes = finalColumnSizes.sublist(0, headerColumnSpan); + matchingHeaderLayoutBuilderInfo = headerLayoutGrids.where((e) => e.lastIdx == (i - 1) && e.columnSizes == headerColumnSizes).firstOrNull; + if (matchingHeaderLayoutBuilderInfo != null) { + matchingHeaderLayoutBuilderInfo.add(headerGridPlacement, lastIdx: i); + } else { + matchingHeaderLayoutBuilderInfo = HeaderLayoutBuilderInfo(headerColumnSizes)..add(headerGridPlacement, lastIdx: i); + headerLayoutGrids.add(matchingHeaderLayoutBuilderInfo); + } + } + + BodyLayoutBuilderInfo? matchingBodyLayoutBuilderInfo; + final List cellsFixedSizeRowElements = []; + + /// Build info about cells and add it to appropriate category + for (var e in bodyCellsI) { + //if it is differentLayout we start from index 0 + //else we get last body index + final rowi = isDifferentLayout ? 0 : (layoutBuilderInfoList.last.lastBodyIdx ?? 0) + 1; + final cellGridPlacement = buildGridPlacement(e.columni, e.child, e.columnMax, rowi, e.rows, e.row, height: _calculatedFullRowIntrinsicHeightList[i]); + cellsFixedSizeRowElements.add(cellGridPlacement); + } + + matchingBodyLayoutBuilderInfo = bodyLayoutGrids.where((e) => e.lastIdx == (i - 1) && e.columnSizes == bodyColumnSizes).firstOrNull; + if (matchingBodyLayoutBuilderInfo != null) { + matchingBodyLayoutBuilderInfo.add(cellsFixedSizeRowElements, lastIdx: i); + } else { + matchingBodyLayoutBuilderInfo = BodyLayoutBuilderInfo(bodyColumnSizes)..add(cellsFixedSizeRowElements, lastIdx: i); + bodyLayoutGrids.add(matchingBodyLayoutBuilderInfo); + } + + /// Add to layoutBuilderInfoList + if (isDifferentLayout) { + final layoutBuilderInfo = LayoutBuilderInfo(headerColumnSizes, bodyColumnSizes); + layoutBuilderInfo.add(matchingHeaderLayoutBuilderInfo, matchingBodyLayoutBuilderInfo); + layoutBuilderInfoList.add(layoutBuilderInfo); + } else { + layoutBuilderInfoList.last.add(matchingHeaderLayoutBuilderInfo, matchingBodyLayoutBuilderInfo); + } + } + + final layoutToSliverAdapter = LayoutInfoToSliverAdapter(rowSizes, _verticalScrollGroupSynchronizer, _horizontalScrollGroupSynchronizer); + final sliverToBoxAdapterList = layoutBuilderInfoList.map((e) => layoutToSliverAdapter.transform(e)).toList(growable: false); + + return SizedBox( + height: MediaQuery.of(context).size.height - 150, + child: CustomScrollView( + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _MySliverPersistentRowHeaderDelegate( + minHeight: _calculatedHeaderHeight!, maxHeight: _calculatedHeaderHeight!, child: calculatedHeaderRowContent), + ), + ...sliverToBoxAdapterList + ], + ), + ); + } + + /// Prepare list of IntrinsicHeight widgets. + /// This widget list can be used to compute the height of all complete rows. + /// The height of each row is determined by the tallest column within that row. + List _prepareIntrinsicHeightRowsList( + List finalColumnSizes, + List rowSizes, + List bodyCells, + List headersColumnGridPlacement, + ) { + final cellsLayoutGrid = LayoutGrid(columnSizes: finalColumnSizes.sublist(1), rowSizes: rowSizes, children: bodyCells); + final headerColumnLayoutGrid = LayoutGrid(columnSizes: finalColumnSizes.sublist(0, 1), rowSizes: rowSizes, children: headersColumnGridPlacement); + + List intrinsicHeightRowsList = []; + intrinsicHeightRowsKeys = List.generate(headerColumnLayoutGrid.children.length, (_) => GlobalKey()); + + final cellsLayoutGridChildren = (cellsLayoutGrid.children as List); + int j = 0; + for (int i = 0; i < headerColumnLayoutGrid.children.length; ++i) { + final cellsRowElements = []; + final headerElement = headerColumnLayoutGrid.children[i] as GridPlacement; + for (; j < cellsLayoutGridChildren.length && cellsLayoutGridChildren[j].rowStart == headerElement.rowStart; ++j) { + final currentCellsGridPlacement = cellsLayoutGridChildren[j]; + cellsRowElements.add(currentCellsGridPlacement); + } + final cellRowLayoutGrid = + LayoutGrid(gridFit: GridFit.passthrough, columnSizes: finalColumnSizes.sublist(1), rowSizes: rowSizes, children: cellsRowElements); + + final headerElementLayoutGrid = LayoutGrid( + columnSizes: finalColumnSizes.sublist(0, headerElement.columnSpan), + rowSizes: rowSizes, + children: [headerElement], + ); + final currentHeaderColumnSizes = finalColumnSizes.whereIndexed((idx, e) => idx < headerElement.columnSpan); + final headerColumnSize = currentHeaderColumnSizes.fold(0.0, (previousValue, element) { + return previousValue + (element as FixedTrackSize).sizeInPx; + }); + final fullRow = IntrinsicHeight( + key: intrinsicHeightRowsKeys[i], + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: headerColumnSize, child: headerElementLayoutGrid), + Expanded( + child: cellRowLayoutGrid, + ), + ], + ), + ); + intrinsicHeightRowsList.add(fullRow); + } + return intrinsicHeightRowsList; + } + + int? getColumnSpan(TableCellElement? child, int columnMax, int? columni) { + if (child == null || columni == null) { + return null; + } else { + return min(child.colspan, columnMax - columni); + } + } + + GridPlacement buildGridPlacement(int columni, TableCellElement child, int columnMax, int rowi, List rows, TableRowLayoutElement row, + {double? height}) { + return GridPlacement( + columnStart: columni, + columnSpan: min(child.colspan, columnMax - columni), + rowStart: rowi, + rowSpan: min(child.rowspan, rows.length - rowi), + child: CssBoxWidget( + shrinkWrap: widget.shrinkWrap, + style: child.style.merge(row.style), + child: Builder(builder: (context) { + final alignment = child.style.direction ?? Directionality.of(context); + if (height == null) { + return SizedBox.expand( + child: Container( + alignment: widget._tableHelper.getCellAlignment(child, alignment), + child: CssBoxWidget.withInlineSpanChildren( + children: [widget.parsedCells[child] ?? const TextSpan(text: "error")], + style: Style(), + ), + ), + ); + } else { + return Container( + height: height, + alignment: widget._tableHelper.getCellAlignment(child, alignment), + child: CssBoxWidget.withInlineSpanChildren( + children: [widget.parsedCells[child] ?? const TextSpan(text: "error")], + style: Style(), + ), + ); + } + }), + ), + ); + } + + bool hasSameColumnSize( + List headerColumnSizes, List headerColumnSizes2, List bodyColumnSizes, List bodyColumnSizes2) { + const eq = ListEquality(); + return eq.equals(headerColumnSizes, headerColumnSizes2) && eq.equals(bodyColumnSizes, bodyColumnSizes2); + } + + IntrinsicHeight _recreateHeaderRowContent(GridPlacementBuildInfo cornerBuildInfoElement, List headerRowBuildInfoElements, + List columnSizes, List cellWidths, List rowSizes) { + final cornerLayoutGrid = LayoutGrid( + columnSizes: [FixedTrackSize(cellWidths[cornerBuildInfoElement.columni])], + rowSizes: [FixedTrackSize(_calculatedHeaderHeight ?? 0.0)], + children: [ + buildGridPlacement(cornerBuildInfoElement.columni, cornerBuildInfoElement.child, cornerBuildInfoElement.columnMax, cornerBuildInfoElement.rowi, + cornerBuildInfoElement.rows, cornerBuildInfoElement.row,height: _calculatedHeaderHeight) + ], + ); + final headerRowLayoutGrid = LayoutGrid( + gridFit: GridFit.passthrough, + columnSizes: columnSizes.sublist(1), + rowSizes: rowSizes.map((e) => FixedTrackSize(_calculatedHeaderHeight ?? 0.0)).toList(growable: false), + children: headerRowBuildInfoElements + .map((e) => buildGridPlacement(e.columni, e.child, e.columnMax, e.rowi, e.rows, e.row, height: _calculatedHeaderHeight)) + .toList(growable: false)); + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: (columnSizes[0] as FixedTrackSize).sizeInPx, child: cornerLayoutGrid), + Expanded( + child: SingleChildScrollView( + controller: _horizontalRowScrollController, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: headerRowLayoutGrid, + ), + ), + ], + ), + ); + } +} + +class _MySliverPersistentRowHeaderDelegate extends SliverPersistentHeaderDelegate { + _MySliverPersistentRowHeaderDelegate({ + required this.minHeight, + required this.maxHeight, + required this.child, + }); + + final double minHeight; + final double maxHeight; + final Widget child; + + @override + double get minExtent => minHeight; + + @override + double get maxExtent => maxHeight; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + return SizedBox.expand(child: child); + } + + @override + bool shouldRebuild(_MySliverPersistentRowHeaderDelegate oldDelegate) { + return maxHeight != oldDelegate.maxHeight || minHeight != oldDelegate.minHeight || child != oldDelegate.child; + } +} diff --git a/lib/src/tables/fixed_headers/model/builder_info.dart b/lib/src/tables/fixed_headers/model/builder_info.dart new file mode 100644 index 0000000000..f24a62e14f --- /dev/null +++ b/lib/src/tables/fixed_headers/model/builder_info.dart @@ -0,0 +1,62 @@ +import 'dart:math'; + +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_layout_grid/flutter_layout_grid.dart'; + +/// Properties that be needed to build GridPlacement +class GridPlacementBuildInfo { + final int columni; + final TableCellElement child; + final int columnMax; + final int rowi; + final List rows; + final TableRowLayoutElement row; + + GridPlacementBuildInfo(this.columni, this.child, this.columnMax, this.rowi, this.rows, this.row); +} + +class HeaderLayoutBuilderInfo { + int? lastIdx; + final List columnSizes; + final List gridPlacements = []; + + HeaderLayoutBuilderInfo(this.columnSizes); + + void add(GridPlacement gridPlacement, {required int lastIdx}) { + gridPlacements.add(gridPlacement); + this.lastIdx = lastIdx; + } +} + +class BodyLayoutBuilderInfo { + int? lastIdx; + final List columnSizes; + final List> gridPlacements = []; + + BodyLayoutBuilderInfo(this.columnSizes); + + void add(List gridPlacement, {required int lastIdx}) { + gridPlacements.add(gridPlacement); + this.lastIdx = lastIdx; + } +} + +class LayoutBuilderInfo { + int? lastIdx; + int? lastBodyIdx; + int? lastHeaderIdx; + final List headerColumnSizes; + final List bodyColumnSizes; + final List headersLayoutBuilderInfo = []; + final List bodiesLayoutBuilderInfo = []; + + LayoutBuilderInfo(this.headerColumnSizes, this.bodyColumnSizes); + + void add(HeaderLayoutBuilderInfo? headerLayoutBuilderInfo, BodyLayoutBuilderInfo? bodyLayoutBuilderInfo) { + headersLayoutBuilderInfo.add(headerLayoutBuilderInfo); + bodiesLayoutBuilderInfo.add(bodyLayoutBuilderInfo); + lastHeaderIdx = headerLayoutBuilderInfo?.gridPlacements.lastOrNull?.rowStart; + lastBodyIdx = bodyLayoutBuilderInfo?.gridPlacements.lastOrNull?.lastOrNull?.rowStart; + lastIdx = max(lastHeaderIdx ?? 0, lastBodyIdx ?? 0); + } +} \ No newline at end of file diff --git a/lib/src/tables/fixed_headers/scroll_group_synchronizer.dart b/lib/src/tables/fixed_headers/scroll_group_synchronizer.dart new file mode 100644 index 0000000000..4ec35a141c --- /dev/null +++ b/lib/src/tables/fixed_headers/scroll_group_synchronizer.dart @@ -0,0 +1,83 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; + +class ScrollGroupSynchronizer { + final List _controllers = []; + final Map _listeners = {}; + bool _isSyncing = false; // Flag to avoid unnecassary updates + + ScrollGroupSynchronizer([List? controllersToSync]) { + if (controllersToSync != null) { + for (var controller in controllersToSync) { + addController(controller); + } + } + } + + + UnmodifiableListView get controllers => UnmodifiableListView(_controllers); + + void addController(ScrollController controller) { + if (!_controllers.contains(controller)) { + _controllers.add(controller); + listener() => _handleScroll(controller); + _listeners[controller] = listener; + controller.addListener(listener); + } + } + + /// Usuwa [ScrollController] z grupy synchronizacji. + /// Usuwa również powiązany listener. + void removeController(ScrollController controller) { + if (_controllers.contains(controller)) { + final listener = _listeners.remove(controller); + if (listener != null) { + controller.removeListener(listener); + } + _controllers.remove(controller); + } + } + + void _handleScroll(ScrollController sourceController) { + if (_isSyncing || !sourceController.hasClients) return; + + _isSyncing = true; + + final sourceOffset = sourceController.offset; + + for (var targetController in _controllers) { + if (targetController == sourceController) continue; + + if (targetController.hasClients && targetController.offset != sourceOffset) { + final targetListener = _listeners[targetController]; + if (targetListener != null) { + // Temporary delete listener to avoid loop, during jumpTo + targetController.removeListener(targetListener); + } + + targetController.jumpTo(sourceOffset); + + if (targetListener != null) { + // Add listener after set position + targetController.addListener(targetListener); + } + } + } + + _isSyncing = false; + } + + + void dispose() { + for (var controller in _controllers) { + final listener = _listeners[controller]; + if (listener != null) { + controller.removeListener(listener); + } + controller.dispose(); + } + _listeners.clear(); + _controllers.clear(); + } +} \ No newline at end of file diff --git a/lib/src/tables/table_helper.dart b/lib/src/tables/table_helper.dart new file mode 100644 index 0000000000..5b7fb7ecdc --- /dev/null +++ b/lib/src/tables/table_helper.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +import '../../flutter_html.dart'; + +class TableHelper { + Alignment getCellAlignment(TableCellElement cell, TextDirection alignment) { + Alignment verticalAlignment; + + switch (cell.style.verticalAlign) { + case VerticalAlign.baseline: + case VerticalAlign.sub: + case VerticalAlign.sup: + case VerticalAlign.top: + verticalAlignment = Alignment.topCenter; + break; + case VerticalAlign.middle: + verticalAlignment = Alignment.center; + break; + case VerticalAlign.bottom: + verticalAlignment = Alignment.bottomCenter; + break; + } + + switch (cell.style.textAlign) { + case TextAlign.left: + return verticalAlignment + Alignment.centerLeft; + case TextAlign.right: + return verticalAlignment + Alignment.centerRight; + case TextAlign.center: + return verticalAlignment + Alignment.center; + case null: + case TextAlign.start: + case TextAlign.justify: + switch (alignment) { + case TextDirection.rtl: + return verticalAlignment + Alignment.centerRight; + case TextDirection.ltr: + return verticalAlignment + Alignment.centerLeft; + } + case TextAlign.end: + switch (alignment) { + case TextDirection.rtl: + return verticalAlignment + Alignment.centerLeft; + case TextDirection.ltr: + return verticalAlignment + Alignment.centerRight; + } + } + } + + + List getColWidths(List children) { + final widths = []; + for (final child in children) { + List partialWidths = []; + if (child is TableRowLayoutElement) { + partialWidths = _getColWidthsFromRow(child); + } else { + partialWidths = getColWidths(child.children); + } + if (partialWidths.isEmpty) continue; + for (int i = 0; i < partialWidths.length; ++i) { + double partial = partialWidths[i]; + if (widths.length <= i) { + widths.add(partial); + } else if (widths[i] < partial) { + widths[i] = partial; + } + } + } + return widths; + } + + + List _getColWidthsFromRow(TableRowLayoutElement row) { + List widths = []; + for (final cell in row.children) { + if (cell is TableCellElement) { + WidthInfo info = WidthInfo(); + for (final child in cell.children) { + _getCellInfo(child, info); + } + double minWidth = info.requiredWidth + 32; + widths.add(minWidth); + } + } + return widths; + } + + void _getCellInfo(StyledElement element, WidthInfo info) { + if (element is TextContentElement) { + final regex = RegExp(r'\w+|\s+|[^\w\s]'); + final wordRegex = RegExp(r'\w+'); + final text = element.text; + if (text == null || text.isEmpty) return; + final words = regex.allMatches(text).map((m) => m.group(0)!).toList(); + for (final word in words) { + double wordWidth = TextPainter.computeWidth( + text: TextSpan( + text: word, + style: TextStyle( + fontSize: element.style.fontSize?.value ?? 16, + fontFamily: element.style.fontFamily, + fontWeight: element.style.fontWeight, + fontStyle: element.style.fontStyle, + )), + textDirection: TextDirection.ltr, + ); + if (info.join && wordRegex.hasMatch(word)) { + info.width += wordWidth; + } else { + info.width = wordWidth; + } + if (info.width > info.requiredWidth) { + info.requiredWidth = info.width; + } + info.join = wordRegex.hasMatch(word); + } + } else { + for (final child in element.children) { + _getCellInfo(child, info); + } + } + } +} + +class WidthInfo { + double width = 0; + double requiredWidth = 0; + bool join = false; +}
, ,