From 9ac7f878fd92f38c759e43c597d782751c0a8742 Mon Sep 17 00:00:00 2001 From: ValentinVignal Date: Wed, 6 May 2026 17:27:44 +0800 Subject: [PATCH 1/7] Activate leak testing --- packages/two_dimensional_scrollables/pubspec.yaml | 1 + .../test/flutter_test_config.dart | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 packages/two_dimensional_scrollables/test/flutter_test_config.dart diff --git a/packages/two_dimensional_scrollables/pubspec.yaml b/packages/two_dimensional_scrollables/pubspec.yaml index 3942be484044..a6f87807c7d8 100644 --- a/packages/two_dimensional_scrollables/pubspec.yaml +++ b/packages/two_dimensional_scrollables/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + leak_tracker_flutter_testing: any topics: - scrollable diff --git a/packages/two_dimensional_scrollables/test/flutter_test_config.dart b/packages/two_dimensional_scrollables/test/flutter_test_config.dart new file mode 100644 index 000000000000..d1eb77755d23 --- /dev/null +++ b/packages/two_dimensional_scrollables/test/flutter_test_config.dart @@ -0,0 +1,13 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + LeakTesting.enable(); + LeakTracking.warnForUnsupportedPlatforms = false; + await testMain(); +} From cda20f6d23bdd3bf673f62a1104e323249ca85de Mon Sep 17 00:00:00 2001 From: ValentinVignal Date: Wed, 6 May 2026 17:29:02 +0800 Subject: [PATCH 2/7] fix memory leaks --- .../lib/src/table_view/table.dart | 375 ++++++++++++++---- .../lib/src/tree_view/tree.dart | 132 +++++- 2 files changed, 414 insertions(+), 93 deletions(-) diff --git a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart index dc46613574f0..693a1127640d 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart @@ -129,34 +129,34 @@ class TableView extends TwoDimensionalScrollView { /// Creates a [TableView] of widgets that are created on demand. /// - /// This constructor is appropriate for table views with a large - /// number of cells because the [cellbuilder] is called only for those - /// cells that are actually visible. + /// This is appropriate for table views with a large number of cells because + /// the [cellBuilder] is called only for those cells that are actually + /// visible. /// - /// This constructor generates a [TableCellBuilderDelegate] for building - /// children on demand using the required [cellBuilder], - /// [columnBuilder], and [rowBuilder]. + /// This generates a [TableCellBuilderDelegate] for building children on + /// demand using the required [cellBuilder], [columnBuilder], and + /// [rowBuilder]. /// /// For infinite rows and columns, omit providing [columnCount] or [rowCount]. - /// Returning null from the [columnBuilder] or [rowBuilder] will terminate - /// the row or column at that index, representing the end of the table in that + /// Returning null from the [columnBuilder] or [rowBuilder] will terminate the + /// row or column at that index, representing the end of the table in that /// axis. In this scenario, until the potential end of the table in either /// dimension is reached by returning null, the /// [ScrollPosition.maxScrollExtent] will reflect [double.infinity]. This is /// because as the table is built lazily, it will not know the end has been /// reached until the [ScrollPosition] arrives there. This is similar to /// returning null from [ListView.builder] to signify the end of the list. - TableView.builder({ - super.key, - super.primary, - super.mainAxis, - super.horizontalDetails, - super.verticalDetails, - super.cacheExtent, - super.diagonalDragBehavior = DiagonalDragBehavior.none, - super.dragStartBehavior, - super.keyboardDismissBehavior, - super.clipBehavior, + static Widget builder({ + Key? key, + bool? primary, + Axis mainAxis = Axis.vertical, + ScrollableDetails horizontalDetails = const ScrollableDetails.horizontal(), + ScrollableDetails verticalDetails = const ScrollableDetails.vertical(), + double? cacheExtent, + DiagonalDragBehavior diagonalDragBehavior = DiagonalDragBehavior.none, + DragStartBehavior dragStartBehavior = DragStartBehavior.start, + ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior, + Clip clipBehavior = Clip.hardEdge, int pinnedRowCount = 0, int pinnedColumnCount = 0, int trailingPinnedRowCount = 0, @@ -166,56 +166,53 @@ class TableView extends TwoDimensionalScrollView { required TableSpanBuilder columnBuilder, required TableSpanBuilder rowBuilder, required TableViewCellBuilder cellBuilder, - this.alignment = Alignment.topLeft, - }) : assert(pinnedRowCount >= 0), - assert(trailingPinnedRowCount >= 0), - assert(rowCount == null || rowCount >= 0), - assert( - rowCount == null || - rowCount >= pinnedRowCount + trailingPinnedRowCount, - ), - assert(columnCount == null || columnCount >= 0), - assert(pinnedColumnCount >= 0), - assert(trailingPinnedColumnCount >= 0), - assert( - columnCount == null || - columnCount >= pinnedColumnCount + trailingPinnedColumnCount, - ), - super( - delegate: TableCellBuilderDelegate( - columnCount: columnCount, - rowCount: rowCount, - pinnedColumnCount: pinnedColumnCount, - pinnedRowCount: pinnedRowCount, - trailingPinnedColumnCount: trailingPinnedColumnCount, - trailingPinnedRowCount: trailingPinnedRowCount, - cellBuilder: cellBuilder, - columnBuilder: columnBuilder, - rowBuilder: rowBuilder, - ), - ); + AlignmentGeometry alignment = Alignment.topLeft, + }) { + return _TableViewBuilder( + key: key, + primary: primary, + mainAxis: mainAxis, + horizontalDetails: horizontalDetails, + verticalDetails: verticalDetails, + cacheExtent: cacheExtent, + diagonalDragBehavior: diagonalDragBehavior, + dragStartBehavior: dragStartBehavior, + keyboardDismissBehavior: keyboardDismissBehavior, + clipBehavior: clipBehavior, + pinnedRowCount: pinnedRowCount, + pinnedColumnCount: pinnedColumnCount, + trailingPinnedRowCount: trailingPinnedRowCount, + trailingPinnedColumnCount: trailingPinnedColumnCount, + columnCount: columnCount, + rowCount: rowCount, + columnBuilder: columnBuilder, + rowBuilder: rowBuilder, + cellBuilder: cellBuilder, + alignment: alignment, + ); + } /// Creates a [TableView] from an explicit two dimensional array of children. /// - /// This constructor is appropriate for list views with a small number of - /// children because constructing the [List] requires doing work for every - /// child that could possibly be displayed in the list view instead of just - /// those children that are actually visible. + /// This is appropriate for list views with a small number of children because + /// constructing the [List] requires doing work for every child that could + /// possibly be displayed in the list view instead of just those children that + /// are actually visible. /// /// The [children] are accessed for each [TableVicinity.column] and /// [TableVicinity.row] of the [TwoDimensionalViewport] as /// `children[vicinity.column][vicinity.row]`. - TableView.list({ - super.key, - super.primary, - super.mainAxis, - super.horizontalDetails, - super.verticalDetails, - super.cacheExtent, - super.diagonalDragBehavior = DiagonalDragBehavior.none, - super.dragStartBehavior, - super.keyboardDismissBehavior, - super.clipBehavior, + static Widget list({ + Key? key, + bool? primary, + Axis mainAxis = Axis.vertical, + ScrollableDetails horizontalDetails = const ScrollableDetails.horizontal(), + ScrollableDetails verticalDetails = const ScrollableDetails.vertical(), + double? cacheExtent, + DiagonalDragBehavior diagonalDragBehavior = DiagonalDragBehavior.none, + DragStartBehavior dragStartBehavior = DragStartBehavior.start, + ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior, + Clip clipBehavior = Clip.hardEdge, int pinnedRowCount = 0, int pinnedColumnCount = 0, int trailingPinnedRowCount = 0, @@ -223,22 +220,29 @@ class TableView extends TwoDimensionalScrollView { required TableSpanBuilder columnBuilder, required TableSpanBuilder rowBuilder, List> cells = const >[], - this.alignment = Alignment.topLeft, - }) : assert(pinnedRowCount >= 0), - assert(pinnedColumnCount >= 0), - assert(trailingPinnedRowCount >= 0), - assert(trailingPinnedColumnCount >= 0), - super( - delegate: TableCellListDelegate( - pinnedColumnCount: pinnedColumnCount, - pinnedRowCount: pinnedRowCount, - trailingPinnedColumnCount: trailingPinnedColumnCount, - trailingPinnedRowCount: trailingPinnedRowCount, - cells: cells, - columnBuilder: columnBuilder, - rowBuilder: rowBuilder, - ), - ); + AlignmentGeometry alignment = Alignment.topLeft, + }) { + return _TableViewList( + key: key, + primary: primary, + mainAxis: mainAxis, + horizontalDetails: horizontalDetails, + verticalDetails: verticalDetails, + cacheExtent: cacheExtent, + diagonalDragBehavior: diagonalDragBehavior, + dragStartBehavior: dragStartBehavior, + keyboardDismissBehavior: keyboardDismissBehavior, + clipBehavior: clipBehavior, + pinnedRowCount: pinnedRowCount, + pinnedColumnCount: pinnedColumnCount, + trailingPinnedRowCount: trailingPinnedRowCount, + trailingPinnedColumnCount: trailingPinnedColumnCount, + columnBuilder: columnBuilder, + rowBuilder: rowBuilder, + cells: cells, + alignment: alignment, + ); + } /// The alignment of the table within the viewport when there is extra space. /// @@ -265,6 +269,221 @@ class TableView extends TwoDimensionalScrollView { } } +class _TableViewBuilder extends StatefulWidget { + const _TableViewBuilder({ + super.key, + this.primary, + this.mainAxis = Axis.vertical, + this.horizontalDetails = const ScrollableDetails.horizontal(), + this.verticalDetails = const ScrollableDetails.vertical(), + this.cacheExtent, + this.diagonalDragBehavior = DiagonalDragBehavior.none, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior, + this.clipBehavior = Clip.hardEdge, + this.pinnedRowCount = 0, + this.pinnedColumnCount = 0, + this.trailingPinnedRowCount = 0, + this.trailingPinnedColumnCount = 0, + this.columnCount, + this.rowCount, + required this.columnBuilder, + required this.rowBuilder, + required this.cellBuilder, + this.alignment = Alignment.topLeft, + }) : assert(pinnedRowCount >= 0), + assert(trailingPinnedRowCount >= 0), + assert(rowCount == null || rowCount >= 0), + assert( + rowCount == null || + rowCount >= pinnedRowCount + trailingPinnedRowCount, + ), + assert(columnCount == null || columnCount >= 0), + assert(pinnedColumnCount >= 0), + assert(trailingPinnedColumnCount >= 0), + assert( + columnCount == null || + columnCount >= pinnedColumnCount + trailingPinnedColumnCount, + ); + + final bool? primary; + final Axis mainAxis; + final ScrollableDetails horizontalDetails; + final ScrollableDetails verticalDetails; + final double? cacheExtent; + final DiagonalDragBehavior diagonalDragBehavior; + final DragStartBehavior dragStartBehavior; + final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; + final Clip clipBehavior; + final int pinnedRowCount; + final int pinnedColumnCount; + final int trailingPinnedRowCount; + final int trailingPinnedColumnCount; + final int? columnCount; + final int? rowCount; + final TableSpanBuilder columnBuilder; + final TableSpanBuilder rowBuilder; + final TableViewCellBuilder cellBuilder; + final AlignmentGeometry alignment; + + @override + State<_TableViewBuilder> createState() => __TableViewBuilderState(); +} + +class __TableViewBuilderState extends State<_TableViewBuilder> { + late TableCellBuilderDelegate _delegate; + + @override + void initState() { + super.initState(); + _delegate = TableCellBuilderDelegate( + columnCount: widget.columnCount, + rowCount: widget.rowCount, + pinnedColumnCount: widget.pinnedColumnCount, + pinnedRowCount: widget.pinnedRowCount, + trailingPinnedColumnCount: widget.trailingPinnedColumnCount, + trailingPinnedRowCount: widget.trailingPinnedRowCount, + cellBuilder: widget.cellBuilder, + columnBuilder: widget.columnBuilder, + rowBuilder: widget.rowBuilder, + ); + } + + @override + void didUpdateWidget(_TableViewBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.columnCount != oldWidget.columnCount || + widget.rowCount != oldWidget.rowCount || + widget.pinnedColumnCount != oldWidget.pinnedColumnCount || + widget.pinnedRowCount != oldWidget.pinnedRowCount || + widget.trailingPinnedColumnCount != + oldWidget.trailingPinnedColumnCount || + widget.trailingPinnedRowCount != oldWidget.trailingPinnedRowCount || + widget.cellBuilder != oldWidget.cellBuilder || + widget.columnBuilder != oldWidget.columnBuilder || + widget.rowBuilder != oldWidget.rowBuilder) { + _delegate.dispose(); + _delegate = TableCellBuilderDelegate( + columnCount: widget.columnCount, + rowCount: widget.rowCount, + pinnedColumnCount: widget.pinnedColumnCount, + pinnedRowCount: widget.pinnedRowCount, + trailingPinnedColumnCount: widget.trailingPinnedColumnCount, + trailingPinnedRowCount: widget.trailingPinnedRowCount, + cellBuilder: widget.cellBuilder, + columnBuilder: widget.columnBuilder, + rowBuilder: widget.rowBuilder, + ); + } + } + + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TableView( + primary: widget.primary, + mainAxis: widget.mainAxis, + horizontalDetails: widget.horizontalDetails, + verticalDetails: widget.verticalDetails, + cacheExtent: widget.cacheExtent, + diagonalDragBehavior: widget.diagonalDragBehavior, + dragStartBehavior: widget.dragStartBehavior, + delegate: _delegate, + alignment: widget.alignment, + ); + } +} + +class _TableViewList extends StatefulWidget { + const _TableViewList({ + super.key, + this.primary, + this.mainAxis = Axis.vertical, + this.horizontalDetails = const ScrollableDetails.horizontal(), + this.verticalDetails = const ScrollableDetails.vertical(), + this.cacheExtent, + this.diagonalDragBehavior = DiagonalDragBehavior.none, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior, + this.clipBehavior = Clip.hardEdge, + this.pinnedRowCount = 0, + this.pinnedColumnCount = 0, + this.trailingPinnedRowCount = 0, + this.trailingPinnedColumnCount = 0, + required this.columnBuilder, + required this.rowBuilder, + this.cells = const >[], + this.alignment = Alignment.topLeft, + }) : assert(pinnedRowCount >= 0), + assert(pinnedColumnCount >= 0), + assert(trailingPinnedRowCount >= 0), + assert(trailingPinnedColumnCount >= 0); + + final bool? primary; + final Axis mainAxis; + final ScrollableDetails horizontalDetails; + final ScrollableDetails verticalDetails; + final double? cacheExtent; + final DiagonalDragBehavior diagonalDragBehavior; + final DragStartBehavior dragStartBehavior; + final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; + final Clip clipBehavior; + final int pinnedRowCount; + final int pinnedColumnCount; + final int trailingPinnedRowCount; + final int trailingPinnedColumnCount; + final TableSpanBuilder columnBuilder; + final TableSpanBuilder rowBuilder; + final AlignmentGeometry alignment; + final List> cells; + + @override + State<_TableViewList> createState() => _TableViewListState(); +} + +class _TableViewListState extends State<_TableViewList> { + late TableCellListDelegate _delegate; + + @override + void initState() { + super.initState(); + _delegate = TableCellListDelegate( + pinnedColumnCount: widget.pinnedColumnCount, + pinnedRowCount: widget.pinnedRowCount, + trailingPinnedColumnCount: widget.trailingPinnedColumnCount, + trailingPinnedRowCount: widget.trailingPinnedRowCount, + cells: widget.cells, + columnBuilder: widget.columnBuilder, + rowBuilder: widget.rowBuilder, + ); + } + + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TableView( + primary: widget.primary, + mainAxis: widget.mainAxis, + horizontalDetails: widget.horizontalDetails, + verticalDetails: widget.verticalDetails, + cacheExtent: widget.cacheExtent, + diagonalDragBehavior: widget.diagonalDragBehavior, + dragStartBehavior: widget.dragStartBehavior, + delegate: _delegate, + ); + } +} + /// A widget through which a portion of a Table of [Widget] children are viewed, /// typically in combination with a [TableView]. class TableViewport extends TwoDimensionalViewport { diff --git a/packages/two_dimensional_scrollables/lib/src/tree_view/tree.dart b/packages/two_dimensional_scrollables/lib/src/tree_view/tree.dart index 6819c9424018..19805132e74b 100644 --- a/packages/two_dimensional_scrollables/lib/src/tree_view/tree.dart +++ b/packages/two_dimensional_scrollables/lib/src/tree_view/tree.dart @@ -925,6 +925,7 @@ class _TreeViewState extends State> case AnimationStatus.dismissed: case AnimationStatus.completed: _currentAnimationForParent[node]!.controller.dispose(); + _currentAnimationForParent[node]!.animation.dispose(); _currentAnimationForParent.remove(node); _updateActiveAnimations(); // If the node is collapsing, we need to unpack the active @@ -958,6 +959,7 @@ class _TreeViewState extends State> widget.toggleAnimationStyle?.curve ?? TreeView.defaultAnimationCurve, ); + _currentAnimationForParent[node]?.animation.dispose(); _currentAnimationForParent[node] = ( controller: controller, animation: newAnimation, @@ -978,8 +980,119 @@ class _TreeViewState extends State> } } -class _TreeView extends TwoDimensionalScrollView { - _TreeView({ +class _TreeView extends StatefulWidget { + const _TreeView({ + this.primary, + this.mainAxis = Axis.vertical, + this.horizontalDetails = const ScrollableDetails.horizontal(), + this.verticalDetails = const ScrollableDetails.vertical(), + this.cacheExtent, + this.diagonalDragBehavior = DiagonalDragBehavior.none, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior, + this.clipBehavior = Clip.hardEdge, + required this.nodeBuilder, + required this.rowBuilder, + required this.activeAnimations, + required this.rowDepths, + required this.indentation, + required this.alignment, + required this.rowCount, + this.addAutomaticKeepAlives = true, + }); + + final bool? primary; + + final Axis mainAxis; + + final ScrollableDetails horizontalDetails; + + final ScrollableDetails verticalDetails; + + final double? cacheExtent; + + final DiagonalDragBehavior diagonalDragBehavior; + + final DragStartBehavior dragStartBehavior; + + final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; + + final Clip clipBehavior; + + final TwoDimensionalIndexedWidgetBuilder nodeBuilder; + final TreeVicinityToRowBuilder rowBuilder; + final Map activeAnimations; + final Map rowDepths; + final double indentation; + final AlignmentGeometry alignment; + final int rowCount; + final bool addAutomaticKeepAlives; + + @override + State<_TreeView> createState() => __TreeViewState(); +} + +class __TreeViewState extends State<_TreeView> { + late TreeRowBuilderDelegate _delegate; + + @override + void initState() { + super.initState(); + _delegate = TreeRowBuilderDelegate( + nodeBuilder: widget.nodeBuilder, + rowBuilder: widget.rowBuilder, + rowCount: widget.rowCount, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + ); + } + + @override + void didUpdateWidget(_TreeView oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.nodeBuilder != widget.nodeBuilder || + oldWidget.rowBuilder != widget.rowBuilder || + oldWidget.rowCount != widget.rowCount || + oldWidget.addAutomaticKeepAlives != widget.addAutomaticKeepAlives) { + _delegate.dispose(); + _delegate = TreeRowBuilderDelegate( + nodeBuilder: widget.nodeBuilder, + rowBuilder: widget.rowBuilder, + rowCount: widget.rowCount, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + ); + } + } + + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _TreeViewWidget( + primary: widget.primary, + mainAxis: widget.mainAxis, + horizontalDetails: widget.horizontalDetails, + verticalDetails: widget.verticalDetails, + cacheExtent: widget.cacheExtent, + diagonalDragBehavior: widget.diagonalDragBehavior, + dragStartBehavior: widget.dragStartBehavior, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + clipBehavior: widget.clipBehavior, + delegate: _delegate, + activeAnimations: widget.activeAnimations, + rowDepths: widget.rowDepths, + indentation: widget.indentation, + alignment: widget.alignment, + ); + } +} + +class _TreeViewWidget extends TwoDimensionalScrollView { + _TreeViewWidget({ super.primary, super.mainAxis, super.horizontalDetails, @@ -989,24 +1102,13 @@ class _TreeView extends TwoDimensionalScrollView { super.dragStartBehavior, super.keyboardDismissBehavior, super.clipBehavior, - required TwoDimensionalIndexedWidgetBuilder nodeBuilder, - required TreeVicinityToRowBuilder rowBuilder, + required super.delegate, required this.activeAnimations, required this.rowDepths, required this.indentation, required this.alignment, - required int rowCount, - bool addAutomaticKeepAlives = true, }) : assert(verticalDetails.direction == AxisDirection.down), - assert(horizontalDetails.direction == AxisDirection.right), - super( - delegate: TreeRowBuilderDelegate( - nodeBuilder: nodeBuilder, - rowBuilder: rowBuilder, - rowCount: rowCount, - addAutomaticKeepAlives: addAutomaticKeepAlives, - ), - ); + assert(horizontalDetails.direction == AxisDirection.right); final Map activeAnimations; final Map rowDepths; From 46192979010a9b31ba44b13412df04b2891a4c1e Mon Sep 17 00:00:00 2001 From: ValentinVignal Date: Wed, 6 May 2026 17:29:07 +0800 Subject: [PATCH 3/7] Update tests --- .../test/table_view/table_cell_test.dart | 401 +++++++++--------- .../test/table_view/table_span_test.dart | 24 +- .../test/table_view/table_test.dart | 301 ++++++++----- 3 files changed, 403 insertions(+), 323 deletions(-) diff --git a/packages/two_dimensional_scrollables/test/table_view/table_cell_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_cell_test.dart index 8313dc5c48b0..e360bbd6341e 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_cell_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_cell_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; const TableSpan span = TableSpan(extent: FixedTableSpanExtent(100)); @@ -172,203 +173,215 @@ void main() { expect(cell, isNull); }); - testWidgets('Merge start cannot exceed current index', ( - WidgetTester tester, - ) async { - // Merge span start is greater than given index, ex: column 10 has merge - // start at 20. - final exceptions = []; - final FlutterExceptionHandler? oldHandler = FlutterError.onError; - FlutterError.onError = (FlutterErrorDetails details) { - exceptions.add(details.exception); - }; - // Row - // +---------+ - // | X err | - // | | - // +---------+ - // | merge | - // | | - // + + - // | | - // | | - // +---------+ - // This cell should only be built for (0, 1) and (0, 2), not (0,0). - var cell = const TableViewCell( - rowMergeStart: 1, - rowMergeSpan: 2, - child: SizedBox.shrink(), - ); - await tester.pumpWidget( - TableView.builder( - cellBuilder: (_, __) => cell, - columnBuilder: (_) => span, - rowBuilder: (_) => span, - columnCount: 1, - rowCount: 3, - ), - ); - FlutterError.onError = oldHandler; - expect(exceptions.length, 2); - expect( - exceptions.first.toString(), - contains('spanMergeStart <= currentSpan'), - ); - - await tester.pumpWidget(Container()); - exceptions.clear(); - FlutterError.onError = (FlutterErrorDetails details) { - exceptions.add(details.exception); - }; - // Column - // +---------+---------+---------+ - // | X err | merged | - // | | | - // +---------+---------+---------+ - // This cell should only be returned for (1, 0) and (2, 0), not (0,0). - cell = const TableViewCell( - columnMergeStart: 1, - columnMergeSpan: 2, - child: SizedBox.shrink(), - ); - await tester.pumpWidget( - TableView.builder( - cellBuilder: (_, __) => cell, - columnBuilder: (_) => span, - rowBuilder: (_) => span, - columnCount: 3, - rowCount: 1, - ), - ); - FlutterError.onError = oldHandler; - expect(exceptions.length, 2); - expect( - exceptions.first.toString(), - contains('spanMergeStart <= currentSpan'), - ); - }); - - testWidgets('Merge cannot exceed table contents', ( - WidgetTester tester, - ) async { - // Merge exceeds table content, ex: at column 10, cell spans 4 columns, - // but table only has 12 columns. - final exceptions = []; - final FlutterExceptionHandler? oldHandler = FlutterError.onError; - FlutterError.onError = (FlutterErrorDetails details) { - exceptions.add(details.exception); - }; - // Row - var cell = const TableViewCell( - rowMergeStart: 0, - rowMergeSpan: 10, // Exceeds the number of rows - child: SizedBox.shrink(), - ); - await tester.pumpWidget( - TableView.builder( - cellBuilder: (_, __) => cell, - columnBuilder: (_) => span, - rowBuilder: (_) => span, - columnCount: 1, - rowCount: 3, - ), - ); - FlutterError.onError = oldHandler; - expect(exceptions.length, 2); - expect( - exceptions.first.toString(), - contains('spanMergeEnd < spanCount'), - ); + testWidgets( + 'Merge start cannot exceed current index', + // The build throws an assertion error which prevents the table from + // properly disposing the elements. + experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(), + (WidgetTester tester) async { + // Merge span start is greater than given index, ex: column 10 has merge + // start at 20. + final exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Row + // +---------+ + // | X err | + // | | + // +---------+ + // | merge | + // | | + // + + + // | | + // | | + // +---------+ + // This cell should only be built for (0, 1) and (0, 2), not (0,0). + var cell = const TableViewCell( + rowMergeStart: 1, + rowMergeSpan: 2, + child: SizedBox.shrink(), + ); + await tester.pumpWidget( + TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 1, + rowCount: 3, + ), + ); + FlutterError.onError = oldHandler; + expect(exceptions, hasLength(2)); + expect( + exceptions.first.toString(), + contains('spanMergeStart <= currentSpan'), + ); - await tester.pumpWidget(Container()); - exceptions.clear(); - FlutterError.onError = (FlutterErrorDetails details) { - exceptions.add(details.exception); - }; - // Column - cell = const TableViewCell( - columnMergeStart: 0, - columnMergeSpan: 10, // Exceeds the number of columns - child: SizedBox.shrink(), - ); - await tester.pumpWidget( - TableView.builder( - cellBuilder: (_, __) => cell, - columnBuilder: (_) => span, - rowBuilder: (_) => span, - columnCount: 3, - rowCount: 1, - ), - ); - FlutterError.onError = oldHandler; - expect(exceptions.length, 2); - expect( - exceptions.first.toString(), - contains('spanMergeEnd < spanCount'), - ); - }); + await tester.pumpWidget(const SizedBox()); + exceptions.clear(); + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Column + // +---------+---------+---------+ + // | X err | merged | + // | | | + // +---------+---------+---------+ + // This cell should only be returned for (1, 0) and (2, 0), not (0,0). + cell = const TableViewCell( + columnMergeStart: 1, + columnMergeSpan: 2, + child: SizedBox.shrink(), + ); + await tester.pumpWidget( + TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 3, + rowCount: 1, + ), + ); + FlutterError.onError = oldHandler; + expect(exceptions, hasLength(2)); + expect( + exceptions.first.toString(), + contains('spanMergeStart <= currentSpan'), + ); + }, + ); + + testWidgets( + 'Merge cannot exceed table contents', + // The build throws an assertion error which prevents the table from + // properly disposing the elements. + experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(), + (WidgetTester tester) async { + // Merge exceeds table content, ex: at column 10, cell spans 4 columns, + // but table only has 12 columns. + final exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Row + var cell = const TableViewCell( + rowMergeStart: 0, + rowMergeSpan: 10, // Exceeds the number of rows + child: SizedBox.shrink(), + ); + await tester.pumpWidget( + TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 1, + rowCount: 3, + ), + ); + FlutterError.onError = oldHandler; + expect(exceptions, hasLength(2)); + expect( + exceptions.first.toString(), + contains('spanMergeEnd < spanCount'), + ); - testWidgets('Merge cannot contain pinned and unpinned cells', ( - WidgetTester tester, - ) async { - // Merge spans pinned and unpinned cells, ex: column 0 is pinned, 0-2 - // expected merge. - final exceptions = []; - final FlutterExceptionHandler? oldHandler = FlutterError.onError; - FlutterError.onError = (FlutterErrorDetails details) { - exceptions.add(details.exception); - }; - // Row - var cell = const TableViewCell( - rowMergeStart: 0, - rowMergeSpan: 3, - child: SizedBox.shrink(), - ); - await tester.pumpWidget( - TableView.builder( - cellBuilder: (_, __) => cell, - columnBuilder: (_) => span, - rowBuilder: (_) => span, - columnCount: 1, - rowCount: 3, - pinnedRowCount: 1, - ), - ); - FlutterError.onError = oldHandler; - expect(exceptions.length, 2); - expect( - exceptions.first.toString(), - contains('spanMergeEnd < pinnedSpanCount'), - ); + await tester.pumpWidget(const SizedBox()); + exceptions.clear(); + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Column + cell = const TableViewCell( + columnMergeStart: 0, + columnMergeSpan: 10, // Exceeds the number of columns + child: SizedBox.shrink(), + ); + await tester.pumpWidget( + TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 3, + rowCount: 1, + ), + ); + FlutterError.onError = oldHandler; + expect(exceptions, hasLength(2)); + expect( + exceptions.first.toString(), + contains('spanMergeEnd < spanCount'), + ); + }, + ); + + testWidgets( + 'Merge cannot contain pinned and unpinned cells', + // The build throws an assertion error which prevents the table from + // properly disposing the elements. + experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(), + (WidgetTester tester) async { + // Merge spans pinned and unpinned cells, ex: column 0 is pinned, 0-2 + // expected merge. + final exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Row + var cell = const TableViewCell( + rowMergeStart: 0, + rowMergeSpan: 3, + child: SizedBox.shrink(), + ); + await tester.pumpWidget( + TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 1, + rowCount: 3, + pinnedRowCount: 1, + ), + ); + FlutterError.onError = oldHandler; + expect(exceptions, hasLength(2)); + expect( + exceptions.first.toString(), + contains('spanMergeEnd < pinnedSpanCount'), + ); - await tester.pumpWidget(Container()); - exceptions.clear(); - FlutterError.onError = (FlutterErrorDetails details) { - exceptions.add(details.exception); - }; - // Column - cell = const TableViewCell( - columnMergeStart: 0, - columnMergeSpan: 3, - child: SizedBox.shrink(), - ); - await tester.pumpWidget( - TableView.builder( - cellBuilder: (_, __) => cell, - columnBuilder: (_) => span, - rowBuilder: (_) => span, - columnCount: 3, - rowCount: 1, - pinnedColumnCount: 1, - ), - ); - FlutterError.onError = oldHandler; - expect(exceptions.length, 2); - expect( - exceptions.first.toString(), - contains('spanMergeEnd < pinnedSpanCount'), - ); - }); + await tester.pumpWidget(const SizedBox()); + exceptions.clear(); + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Column + cell = const TableViewCell( + columnMergeStart: 0, + columnMergeSpan: 3, + child: SizedBox.shrink(), + ); + await tester.pumpWidget( + TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 3, + rowCount: 1, + pinnedColumnCount: 1, + ), + ); + FlutterError.onError = oldHandler; + expect(exceptions, hasLength(2)); + expect( + exceptions.first.toString(), + contains('spanMergeEnd < pinnedSpanCount'), + ); + }, + ); }); group('layout', () { diff --git a/packages/two_dimensional_scrollables/test/table_view/table_span_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_span_test.dart index ad3a9b8dca44..2ccdc02e0b92 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_span_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_span_test.dart @@ -212,7 +212,7 @@ void main() { testWidgets('Vertical main axis, vertical reversed', ( WidgetTester tester, ) async { - final table = TableView.builder( + final Widget table = TableView.builder( verticalDetails: ScrollableDetails.vertical( controller: verticalController, reverse: true, @@ -318,7 +318,7 @@ void main() { testWidgets('Vertical main axis, horizontal reversed', ( WidgetTester tester, ) async { - final table = TableView.builder( + final Widget table = TableView.builder( verticalDetails: ScrollableDetails.vertical( controller: verticalController, ), @@ -424,7 +424,7 @@ void main() { testWidgets('Vertical main axis, both reversed', ( WidgetTester tester, ) async { - final table = TableView.builder( + final Widget table = TableView.builder( verticalDetails: ScrollableDetails.vertical( controller: verticalController, reverse: true, @@ -531,7 +531,7 @@ void main() { testWidgets('Horizontal main axis, vertical reversed', ( WidgetTester tester, ) async { - final table = TableView.builder( + final Widget table = TableView.builder( mainAxis: Axis.horizontal, verticalDetails: ScrollableDetails.vertical( controller: verticalController, @@ -638,7 +638,7 @@ void main() { testWidgets('Horizontal main axis, horizontal reversed', ( WidgetTester tester, ) async { - final table = TableView.builder( + final Widget table = TableView.builder( mainAxis: Axis.horizontal, verticalDetails: ScrollableDetails.vertical( controller: verticalController, @@ -745,7 +745,7 @@ void main() { testWidgets('Horizontal main axis, both reversed', ( WidgetTester tester, ) async { - final table = TableView.builder( + final Widget table = TableView.builder( mainAxis: Axis.horizontal, verticalDetails: ScrollableDetails.vertical( controller: verticalController, @@ -854,7 +854,7 @@ void main() { 'paints borders correctly when cross axis is reversed (TableView)', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/177117 - final tableView = TableView.builder( + final Widget tableView = TableView.builder( horizontalDetails: const ScrollableDetails.horizontal(reverse: true), rowCount: 1, columnCount: 1, @@ -906,7 +906,7 @@ void main() { 'paints borders correctly when vertical scrolling is reversed (TableView)', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/177117 - final tableView = TableView.builder( + final Widget tableView = TableView.builder( verticalDetails: const ScrollableDetails.vertical(reverse: true), rowCount: 1, columnCount: 1, @@ -958,7 +958,7 @@ void main() { 'TableView row decoration rect is correct when vertical axis is reversed and padding is used', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/177117 - final tableView = TableView.builder( + final Widget tableView = TableView.builder( verticalDetails: const ScrollableDetails.vertical(reverse: true), rowCount: 1, columnCount: 1, @@ -1037,7 +1037,7 @@ void main() { const TableVicinity(row: 3, column: 2): (2, 2), }; - TableView buildScenario1({ + Widget buildScenario1({ bool reverseVertical = false, bool reverseHorizontal = false, }) { @@ -1099,7 +1099,7 @@ void main() { const TableVicinity(row: 2, column: 3): (2, 2), }; - TableView buildScenario2({ + Widget buildScenario2({ bool reverseVertical = false, bool reverseHorizontal = false, }) { @@ -1176,7 +1176,7 @@ void main() { const TableVicinity(row: 2, column: 3): (2, 2), }; - TableView buildScenario3({ + Widget buildScenario3({ Axis mainAxis = Axis.vertical, bool reverseVertical = false, bool reverseHorizontal = false, diff --git a/packages/two_dimensional_scrollables/test/table_view/table_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_test.dart index a89029c843b4..bfa87102587c 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_test.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; const TableSpan span = TableSpan(extent: FixedTableSpanExtent(100)); @@ -41,14 +42,21 @@ TableSpan getMouseTrackingSpan( void main() { group('TableView.builder', () { - test('creates correct delegate', () { - final tableView = TableView.builder( + testWidgets('creates correct delegate', (WidgetTester tester) async { + final Widget widget = TableView.builder( columnCount: 3, rowCount: 2, rowBuilder: (_) => span, columnBuilder: (_) => span, cellBuilder: (_, __) => cell, ); + + await tester.pumpWidget(widget); + + final TableView tableView = tester.widget( + find.byType(TableView), + ); + final delegate = tableView.delegate as TableCellBuilderDelegate; expect(delegate.pinnedRowCount, 0); expect(delegate.pinnedRowCount, 0); @@ -60,7 +68,7 @@ void main() { }); test('asserts correct counts', () { - TableView? tableView; + Widget? tableView; expect( () { tableView = TableView.builder( @@ -191,7 +199,7 @@ void main() { horizontalController.dispose(); }); - TableView getTableView({ + Widget getTableView({ int? columnCount, int? rowCount, TableSpanBuilder? columnBuilder, @@ -2030,106 +2038,118 @@ void main() { ); }); - testWidgets('merged column that exceeds metrics will assert', ( - WidgetTester tester, - ) async { - final exceptions = []; - final FlutterExceptionHandler? oldHandler = FlutterError.onError; - FlutterError.onError = (FlutterErrorDetails details) { - exceptions.add(details.exception); - }; - const ({int start, int span}) columnConfig = (start: 1, span: 10); - final mergedColumns = List.generate(10, (int index) => index + 1); - await tester.pumpWidget( - MaterialApp( - home: getTableView( - columnBuilder: (int index) { - // There will only be 8 columns, but the merge is set up for 10. - if (index == 8) { - return null; - } - return largeSpan; - }, - cellBuilder: (_, TableVicinity vicinity) { - // Merged column - if (mergedColumns.contains(vicinity.column) && - vicinity.row == 0) { + testWidgets( + 'merged column that exceeds metrics will assert', + // The build throws an assertion error which prevents the table from + // properly disposing the elements. + experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(), + (WidgetTester tester) async { + final exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + const ({int start, int span}) columnConfig = (start: 1, span: 10); + final mergedColumns = List.generate( + 10, + (int index) => index + 1, + ); + await tester.pumpWidget( + MaterialApp( + home: getTableView( + columnBuilder: (int index) { + // There will only be 8 columns, but the merge is set up for 10. + if (index == 8) { + return null; + } + return largeSpan; + }, + cellBuilder: (_, TableVicinity vicinity) { + // Merged column + if (mergedColumns.contains(vicinity.column) && + vicinity.row == 0) { + return TableViewCell( + columnMergeStart: columnConfig.start, + columnMergeSpan: columnConfig.span, + child: const Text('R0:C1'), + ); + } return TableViewCell( - columnMergeStart: columnConfig.start, - columnMergeSpan: columnConfig.span, - child: const Text('R0:C1'), + child: Text('R${vicinity.row}:C${vicinity.column}'), ); - } - return TableViewCell( - child: Text('R${vicinity.row}:C${vicinity.column}'), - ); - }, + }, + ), ), - ), - ); - await tester.pumpWidget(Container()); - FlutterError.onError = oldHandler; - expect(exceptions.length, 3); - expect( - exceptions.first.toString(), - contains( - 'The merged cell containing (row: 0, column: 1) is ' - 'missing TableSpan information necessary for layout. The ' - 'columnBuilder returned null, signifying the end, at column 8 but ' - 'the merged cell is configured to end with column 10.', - ), - ); - }); + ); + await tester.pumpWidget(const SizedBox()); + FlutterError.onError = oldHandler; + expect(exceptions, hasLength(3)); + expect( + exceptions.first.toString(), + contains( + 'The merged cell containing (row: 0, column: 1) is ' + 'missing TableSpan information necessary for layout. The ' + 'columnBuilder returned null, signifying the end, at column 8 but ' + 'the merged cell is configured to end with column 10.', + ), + ); + }, + ); - testWidgets('merged row that exceeds metrics will assert', ( - WidgetTester tester, - ) async { - final exceptions = []; - final FlutterExceptionHandler? oldHandler = FlutterError.onError; - FlutterError.onError = (FlutterErrorDetails details) { - exceptions.add(details.exception); - }; - const ({int start, int span}) rowConfig = (start: 0, span: 10); - final mergedRows = List.generate(10, (int index) => index); - await tester.pumpWidget( - MaterialApp( - home: getTableView( - rowBuilder: (int index) { - // There will only be 8 rows, but the merge is set up for 9. - if (index == 8) { - return null; - } - return largeSpan; - }, - cellBuilder: (_, TableVicinity vicinity) { - // Merged column - if (mergedRows.contains(vicinity.row) && vicinity.column == 0) { + testWidgets( + 'merged row that exceeds metrics will assert', + // The build throws an assertion error which prevents the table from + // properly disposing the elements. + experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(), + (WidgetTester tester) async { + final exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + const ({int start, int span}) rowConfig = (start: 0, span: 10); + final mergedRows = List.generate(10, (int index) => index); + await tester.pumpWidget( + MaterialApp( + home: getTableView( + rowBuilder: (int index) { + // There will only be 8 rows, but the merge is set up for 9. + if (index == 8) { + return null; + } + return largeSpan; + }, + cellBuilder: (_, TableVicinity vicinity) { + // Merged column + if (mergedRows.contains(vicinity.row) && + vicinity.column == 0) { + return TableViewCell( + rowMergeStart: rowConfig.start, + rowMergeSpan: rowConfig.span, + child: const Text('R0:C0'), + ); + } return TableViewCell( - rowMergeStart: rowConfig.start, - rowMergeSpan: rowConfig.span, - child: const Text('R0:C0'), + child: Text('R${vicinity.row}:C${vicinity.column}'), ); - } - return TableViewCell( - child: Text('R${vicinity.row}:C${vicinity.column}'), - ); - }, + }, + ), ), - ), - ); - await tester.pumpWidget(Container()); - FlutterError.onError = oldHandler; - expect(exceptions.length, 3); - expect( - exceptions.first.toString(), - contains( - 'The merged cell containing (row: 0, column: 0) is ' - 'missing TableSpan information necessary for layout. The ' - 'rowBuilder returned null, signifying the end, at row 8 but ' - 'the merged cell is configured to end with row 9.', - ), - ); - }); + ); + await tester.pumpWidget(const SizedBox()); + FlutterError.onError = oldHandler; + expect(exceptions, hasLength(3)); + expect( + exceptions.first.toString(), + contains( + 'The merged cell containing (row: 0, column: 0) is ' + 'missing TableSpan information necessary for layout. The ' + 'rowBuilder returned null, signifying the end, at row 8 but ' + 'the merged cell is configured to end with row 9.', + ), + ); + }, + ); testWidgets('Binary search correctly finds first/last non-pinned cells', ( WidgetTester tester, @@ -2163,8 +2183,8 @@ void main() { }); group('TableView.list', () { - test('creates correct delegate', () { - final tableView = TableView.list( + testWidgets('creates correct delegate', (WidgetTester tester) async { + final Widget widget = TableView.list( rowBuilder: (_) => span, columnBuilder: (_) => span, cells: const >[ @@ -2172,6 +2192,13 @@ void main() { [cell, cell, cell], ], ); + + await tester.pumpWidget(widget); + + final TableView tableView = tester.widget( + find.byType(TableView), + ); + final delegate = tableView.delegate as TableCellListDelegate; expect(delegate.pinnedRowCount, 0); expect(delegate.pinnedRowCount, 0); @@ -2183,7 +2210,7 @@ void main() { }); test('asserts correct counts', () { - TableView? tableView; + Widget? tableView; expect( () { tableView = TableView.list( @@ -2232,7 +2259,7 @@ void main() { ) async { final childKeys = {}; const span = TableSpan(extent: FixedTableSpanExtent(200)); - final tableView = TableView.builder( + final Widget tableView = TableView.builder( rowCount: 5, columnCount: 5, columnBuilder: (_) => span, @@ -2297,7 +2324,7 @@ void main() { extent: FixedTableSpanExtent(200), padding: TableSpanPadding(leading: 30.0, trailing: 40.0), ); - var tableView = TableView.builder( + Widget tableView = TableView.builder( rowCount: 2, columnCount: 2, columnBuilder: (_) => columnSpan, @@ -2397,7 +2424,7 @@ void main() { testWidgets('TableSpan gesture hit testing', (WidgetTester tester) async { var tapCounter = 0; // Rows - var tableView = TableView.builder( + Widget tableView = TableView.builder( rowCount: 50, columnCount: 50, columnBuilder: (_) => span, @@ -2573,7 +2600,11 @@ void main() { final rowExtent = TestTableSpanExtent(); final verticalController = ScrollController(); final horizontalController = ScrollController(); - final tableView = TableView.builder( + addTearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); + final Widget tableView = TableView.builder( rowCount: 10, columnCount: 10, columnBuilder: (_) => TableSpan(extent: columnExtent), @@ -2625,7 +2656,7 @@ void main() { ) async { // Huge padding, first span layout // Column-wise - var tableView = TableView.builder( + Widget tableView = TableView.builder( rowCount: 50, columnCount: 50, columnBuilder: (_) => const TableSpan( @@ -2696,7 +2727,7 @@ void main() { ) async { // Check with gradually accrued paddings // Column-wise - var tableView = TableView.builder( + Widget tableView = TableView.builder( rowCount: 50, columnCount: 50, columnBuilder: (_) => @@ -2818,7 +2849,11 @@ void main() { testWidgets('regular layout - no pinning', (WidgetTester tester) async { final verticalController = ScrollController(); final horizontalController = ScrollController(); - final tableView = TableView.builder( + addTearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); + final Widget tableView = TableView.builder( rowCount: 50, columnCount: 50, columnBuilder: (_) => span, @@ -2897,7 +2932,11 @@ void main() { // Just pinned rows final verticalController = ScrollController(); final horizontalController = ScrollController(); - var tableView = TableView.builder( + addTearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); + Widget tableView = TableView.builder( rowCount: 50, pinnedRowCount: 1, columnCount: 50, @@ -3139,7 +3178,11 @@ void main() { testWidgets('only paints visible cells', (WidgetTester tester) async { final verticalController = ScrollController(); final horizontalController = ScrollController(); - final tableView = TableView.builder( + addTearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); + final Widget tableView = TableView.builder( rowCount: 50, columnCount: 50, columnBuilder: (_) => span, @@ -3206,7 +3249,7 @@ void main() { testWidgets('paints decorations in correct order', ( WidgetTester tester, ) async { - var tableView = TableView.builder( + Widget tableView = TableView.builder( rowCount: 2, columnCount: 2, columnBuilder: (int index) => TableSpan( @@ -3483,7 +3526,7 @@ void main() { WidgetTester tester, ) async { // Both reversed - Regression test for https://github.com/flutter/flutter/issues/135386 - var tableView = TableView.builder( + Widget tableView = TableView.builder( verticalDetails: const ScrollableDetails.vertical(reverse: true), horizontalDetails: const ScrollableDetails.horizontal(reverse: true), rowCount: 2, @@ -3578,7 +3621,7 @@ void main() { testWidgets('mouse handling', (WidgetTester tester) async { var enterCounter = 0; var exitCounter = 0; - final tableView = TableView.builder( + final Widget tableView = TableView.builder( rowCount: 50, columnCount: 50, columnBuilder: (_) => span, @@ -3729,7 +3772,11 @@ void main() { testWidgets('Normal axes', (WidgetTester tester) async { final verticalController = ScrollController(); final horizontalController = ScrollController(); - final tableView = TableView.builder( + addTearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); + final Widget tableView = TableView.builder( verticalDetails: ScrollableDetails.vertical( controller: verticalController, ), @@ -3839,7 +3886,11 @@ void main() { testWidgets('Vertical reversed', (WidgetTester tester) async { final verticalController = ScrollController(); final horizontalController = ScrollController(); - final tableView = TableView.builder( + addTearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); + final Widget tableView = TableView.builder( verticalDetails: ScrollableDetails.vertical( reverse: true, controller: verticalController, @@ -3950,7 +4001,11 @@ void main() { testWidgets('Horizontal reversed', (WidgetTester tester) async { final verticalController = ScrollController(); final horizontalController = ScrollController(); - final tableView = TableView.builder( + addTearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); + final Widget tableView = TableView.builder( verticalDetails: ScrollableDetails.vertical( controller: verticalController, ), @@ -4061,7 +4116,11 @@ void main() { testWidgets('Both reversed', (WidgetTester tester) async { final verticalController = ScrollController(); final horizontalController = ScrollController(); - final tableView = TableView.builder( + addTearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); + final Widget tableView = TableView.builder( verticalDetails: ScrollableDetails.vertical( reverse: true, controller: verticalController, @@ -4177,13 +4236,17 @@ void main() { (WidgetTester tester) async { final verticalController = ScrollController(); final horizontalController = ScrollController(); + addTearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); final mergedCell = { const TableVicinity(row: 2, column: 2), const TableVicinity(row: 3, column: 2), const TableVicinity(row: 2, column: 3), const TableVicinity(row: 3, column: 3), }; - final tableView = TableView.builder( + final Widget tableView = TableView.builder( columnCount: 10, rowCount: 10, columnBuilder: (_) => @@ -4472,6 +4535,10 @@ void main() { ) async { final horizontalController = ScrollController(); final verticalController = ScrollController(); + addTearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); Widget getTableView({ int? columnCount = 10, From 21dde2f04749f0a71d09d96e2a8d18797d6a545b Mon Sep 17 00:00:00 2001 From: ValentinVignal Date: Wed, 6 May 2026 17:31:11 +0800 Subject: [PATCH 4/7] Update version number --- packages/two_dimensional_scrollables/CHANGELOG.md | 4 ++++ packages/two_dimensional_scrollables/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/two_dimensional_scrollables/CHANGELOG.md b/packages/two_dimensional_scrollables/CHANGELOG.md index 813b9bbc00ed..19acc1c86676 100644 --- a/packages/two_dimensional_scrollables/CHANGELOG.md +++ b/packages/two_dimensional_scrollables/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.2 + +* Fixes memory leaks. + ## 0.5.1 * Fixes an infinite loop of onExit/onEnter events when setState is called within onEnter in a TableSpan. diff --git a/packages/two_dimensional_scrollables/pubspec.yaml b/packages/two_dimensional_scrollables/pubspec.yaml index a6f87807c7d8..913d0039bf7e 100644 --- a/packages/two_dimensional_scrollables/pubspec.yaml +++ b/packages/two_dimensional_scrollables/pubspec.yaml @@ -1,6 +1,6 @@ name: two_dimensional_scrollables description: Widgets that scroll using the two dimensional scrolling foundation. -version: 0.5.1 +version: 0.5.2 repository: https://github.com/flutter/packages/tree/main/packages/two_dimensional_scrollables issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+two_dimensional_scrollables%22+ From 66d82ccde1c1a1aa9716856f4bd392c3ae9ba08e Mon Sep 17 00:00:00 2001 From: ValentinVignal Date: Wed, 6 May 2026 18:04:52 +0800 Subject: [PATCH 5/7] Add memory leak test in example --- .../example/pubspec.yaml | 5 +++-- .../example/test/flutter_test_config.dart | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 packages/two_dimensional_scrollables/example/test/flutter_test_config.dart diff --git a/packages/two_dimensional_scrollables/example/pubspec.yaml b/packages/two_dimensional_scrollables/example/pubspec.yaml index dc9626494bf9..9e5e51fdc2e6 100644 --- a/packages/two_dimensional_scrollables/example/pubspec.yaml +++ b/packages/two_dimensional_scrollables/example/pubspec.yaml @@ -1,6 +1,6 @@ name: two_dimensional_examples -description: 'A sample application that uses TableView and TreeView' -publish_to: 'none' +description: "A sample application that uses TableView and TreeView" +publish_to: "none" # The following defines the version and build number for your application. version: 2.0.0 @@ -18,6 +18,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + leak_tracker_flutter_testing: any # The following section is specific to Flutter packages. flutter: diff --git a/packages/two_dimensional_scrollables/example/test/flutter_test_config.dart b/packages/two_dimensional_scrollables/example/test/flutter_test_config.dart new file mode 100644 index 000000000000..d1eb77755d23 --- /dev/null +++ b/packages/two_dimensional_scrollables/example/test/flutter_test_config.dart @@ -0,0 +1,13 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + LeakTesting.enable(); + LeakTracking.warnForUnsupportedPlatforms = false; + await testMain(); +} From 3af68b5ab19ba585bd5545aadb84a08738e40fe9 Mon Sep 17 00:00:00 2001 From: ValentinVignal Date: Wed, 6 May 2026 18:05:19 +0800 Subject: [PATCH 6/7] Gemini comments --- .../lib/src/table_view/table.dart | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart index 693a1127640d..3dedbb3ee6cf 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart @@ -393,6 +393,8 @@ class __TableViewBuilderState extends State<_TableViewBuilder> { cacheExtent: widget.cacheExtent, diagonalDragBehavior: widget.diagonalDragBehavior, dragStartBehavior: widget.dragStartBehavior, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + clipBehavior: widget.clipBehavior, delegate: _delegate, alignment: widget.alignment, ); @@ -463,6 +465,30 @@ class _TableViewListState extends State<_TableViewList> { ); } + @override + void didUpdateWidget(_TableViewList oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.pinnedColumnCount != widget.pinnedColumnCount || + oldWidget.pinnedRowCount != widget.pinnedRowCount || + oldWidget.trailingPinnedColumnCount != + widget.trailingPinnedColumnCount || + oldWidget.trailingPinnedRowCount != widget.trailingPinnedRowCount || + oldWidget.cells != widget.cells || + oldWidget.columnBuilder != widget.columnBuilder || + oldWidget.rowBuilder != widget.rowBuilder) { + _delegate.dispose(); + _delegate = TableCellListDelegate( + pinnedColumnCount: widget.pinnedColumnCount, + pinnedRowCount: widget.pinnedRowCount, + trailingPinnedColumnCount: widget.trailingPinnedColumnCount, + trailingPinnedRowCount: widget.trailingPinnedRowCount, + cells: widget.cells, + columnBuilder: widget.columnBuilder, + rowBuilder: widget.rowBuilder, + ); + } + } + @override void dispose() { _delegate.dispose(); @@ -479,7 +505,10 @@ class _TableViewListState extends State<_TableViewList> { cacheExtent: widget.cacheExtent, diagonalDragBehavior: widget.diagonalDragBehavior, dragStartBehavior: widget.dragStartBehavior, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + clipBehavior: widget.clipBehavior, delegate: _delegate, + alignment: widget.alignment, ); } } From 67412080b67c9fc402f6a8515d279ce6e6ee6f8d Mon Sep 17 00:00:00 2001 From: ValentinVignal Date: Wed, 6 May 2026 18:10:50 +0800 Subject: [PATCH 7/7] Fix tests --- .../example/lib/table_view/simple_table.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/two_dimensional_scrollables/example/lib/table_view/simple_table.dart b/packages/two_dimensional_scrollables/example/lib/table_view/simple_table.dart index 855d35a17fe0..b07e9b1f55ee 100644 --- a/packages/two_dimensional_scrollables/example/lib/table_view/simple_table.dart +++ b/packages/two_dimensional_scrollables/example/lib/table_view/simple_table.dart @@ -51,6 +51,7 @@ class _TableExampleState extends State { ), ) : TableView.builder( + key: ValueKey(_selectionMode), verticalDetails: ScrollableDetails.vertical( controller: _verticalController, ),