From 0c80ed6d1238efabe47618e97f2a19bb0aba2818 Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Thu, 10 Aug 2023 00:46:05 -0500 Subject: [PATCH] Keep alive support for 2D scrolling (#131641) Fixes https://github.com/flutter/flutter/issues/126297 This adds support for keep alive to the 2D scrolling foundation. The TwoDimensionalChildBuilderDelegate and TwoDimensionalChildListDelegate will both add automatic keep alives to their children, matching the convention from SliverChildDelegates. The TwoDimensionalViewportParentData now incorporates keep alive and which is managed by the RenderTwoDimensionalViewport. --- .../lib/src/widgets/scroll_delegate.dart | 22 +- .../src/widgets/two_dimensional_viewport.dart | 130 ++++++- .../test/widgets/two_dimensional_utils.dart | 56 ++- .../two_dimensional_viewport_test.dart | 336 +++++++++++++++++- 4 files changed, 514 insertions(+), 30 deletions(-) diff --git a/packages/flutter/lib/src/widgets/scroll_delegate.dart b/packages/flutter/lib/src/widgets/scroll_delegate.dart index 765bf31d8a..b81b9b069e 100644 --- a/packages/flutter/lib/src/widgets/scroll_delegate.dart +++ b/packages/flutter/lib/src/widgets/scroll_delegate.dart @@ -395,8 +395,8 @@ class SliverChildBuilderDelegate extends SliverChildDelegate { /// {@template flutter.widgets.SliverChildBuilderDelegate.addAutomaticKeepAlives} /// Whether to wrap each child in an [AutomaticKeepAlive]. /// - /// Typically, children in lazy list are wrapped in [AutomaticKeepAlive] - /// widgets so that children can use [KeepAliveNotification]s to preserve + /// Typically, lazily laid out children are wrapped in [AutomaticKeepAlive] + /// widgets so that the children can use [KeepAliveNotification]s to preserve /// their state when they would otherwise be garbage collected off-screen. /// /// This feature (and [addRepaintBoundaries]) must be disabled if the children @@ -863,7 +863,6 @@ Widget _createErrorWidget(Object exception, StackTrace stackTrace) { return ErrorWidget.builder(details); } -// TODO(Piinks): Come back and add keep alive support, https://github.com/flutter/flutter/issues/126297 /// A delegate that supplies children for scrolling in two dimensions. /// /// A [TwoDimensionalScrollView] lazily constructs its box children to avoid @@ -929,10 +928,11 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { /// Creates a delegate that supplies children for a [TwoDimensionalScrollView] /// using the given builder callback. TwoDimensionalChildBuilderDelegate({ - this.addRepaintBoundaries = true, required this.builder, int? maxXIndex, int? maxYIndex, + this.addRepaintBoundaries = true, + this.addAutomaticKeepAlives = true, }) : assert(maxYIndex == null || maxYIndex >= 0), assert(maxXIndex == null || maxXIndex >= 0), _maxYIndex = maxYIndex, @@ -1028,6 +1028,9 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { /// {@macro flutter.widgets.SliverChildBuilderDelegate.addRepaintBoundaries} final bool addRepaintBoundaries; + /// {@macro flutter.widgets.SliverChildBuilderDelegate.addAutomaticKeepAlives} + final bool addAutomaticKeepAlives; + @override Widget? build(BuildContext context, ChildVicinity vicinity) { // If we have exceeded explicit upper bounds, return null. @@ -1050,6 +1053,9 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { if (addRepaintBoundaries) { child = RepaintBoundary(child: child); } + if (addAutomaticKeepAlives) { + child = AutomaticKeepAlive(child: _SelectionKeepAlive(child: child)); + } return child; } @@ -1095,6 +1101,7 @@ class TwoDimensionalChildListDelegate extends TwoDimensionalChildDelegate { /// null. TwoDimensionalChildListDelegate({ this.addRepaintBoundaries = true, + this.addAutomaticKeepAlives = true, required this.children, }); @@ -1114,6 +1121,9 @@ class TwoDimensionalChildListDelegate extends TwoDimensionalChildDelegate { /// {@macro flutter.widgets.SliverChildBuilderDelegate.addRepaintBoundaries} final bool addRepaintBoundaries; + /// {@macro flutter.widgets.SliverChildBuilderDelegate.addAutomaticKeepAlives} + final bool addAutomaticKeepAlives; + @override Widget? build(BuildContext context, ChildVicinity vicinity) { // If we have exceeded explicit upper bounds, return null. @@ -1128,7 +1138,9 @@ class TwoDimensionalChildListDelegate extends TwoDimensionalChildDelegate { if (addRepaintBoundaries) { child = RepaintBoundary(child: child); } - + if (addAutomaticKeepAlives) { + child = AutomaticKeepAlive(child: _SelectionKeepAlive(child: child)); + } return child; } diff --git a/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart b/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart index 82befbce08..282ef44490 100644 --- a/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart +++ b/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart @@ -391,7 +391,7 @@ class _TwoDimensionalViewportElement extends RenderObjectElement /// RenderTwoDimensionalViewport override the paint method, the [paintOffset] /// should be used to position the child in the viewport in order to account for /// a reversed [AxisDirection] in one or both dimensions. -class TwoDimensionalViewportParentData extends ParentData { +class TwoDimensionalViewportParentData extends ParentData with KeepAliveParentDataMixin { /// The offset at which to paint the child in the parent's coordinate system. /// /// This [Offset] represents the top left corner of the child of the @@ -472,14 +472,18 @@ class TwoDimensionalViewportParentData extends ParentData { /// position the children instead of [layoutOffset]. Offset? paintOffset; + @override + bool get keptAlive => keepAlive && !isVisible; + @override String toString() { return 'vicinity=$vicinity; ' 'layoutOffset=$layoutOffset; ' 'paintOffset=$paintOffset; ' '${_paintExtent == null - ? 'not visible ' - : '${!isVisible ? 'not ' : ''}visible - paintExtent=$_paintExtent'}'; + ? 'not visible; ' + : '${!isVisible ? 'not ' : ''}visible - paintExtent=$_paintExtent; '}' + '${keepAlive ? "keepAlive; " : ""}'; } } @@ -493,9 +497,7 @@ class TwoDimensionalViewportParentData extends ParentData { /// /// Subclasses should not override [performLayout], as it handles housekeeping /// on either side of the call to [layoutChildSequence]. -// TODO(Piinks): Two follow up changes: -// - Keep alive https://github.com/flutter/flutter/issues/126297 -// - ensureVisible https://github.com/flutter/flutter/issues/126299 +// TODO(Piinks): ensureVisible https://github.com/flutter/flutter/issues/126299 abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderAbstractViewport { /// Initializes fields for subclasses. /// @@ -527,7 +529,12 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA _delegate = delegate, _mainAxis = mainAxis, _cacheExtent = cacheExtent ?? RenderAbstractViewport.defaultCacheExtent, - _clipBehavior = clipBehavior; + _clipBehavior = clipBehavior { + assert(() { + _debugDanglingKeepAlives = []; + return true; + }()); + } /// Which part of the content inside the viewport should be visible in the /// horizontal axis. @@ -674,6 +681,16 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA } final TwoDimensionalChildManager _childManager; + final Map _children = {}; + /// Children that have been laid out (or re-used) during the course of + /// performLayout, used to update the keep alive bucket at the end of + /// performLayout. + final Map _activeChildrenForLayoutPass = {}; + /// The nodes being kept alive despite not being visible. + final Map _keepAliveBucket = {}; + + late List _debugDanglingKeepAlives; + bool _hasVisualOverflow = false; final LayerHandle _clipRectLayer = LayerHandle(); @@ -683,7 +700,6 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA @override bool get sizedByParent => true; - final Map _children = {}; // Keeps track of the upper and lower bounds of ChildVicinity indices when // subclasses call buildOrObtainChildFor during layoutChildSequence. These // values are used to sort children in accordance with the mainAxis for @@ -788,6 +804,9 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA for (final RenderBox child in _children.values) { child.attach(owner); } + for (final RenderBox child in _keepAliveBucket.values) { + child.attach(owner); + } } @override @@ -799,6 +818,9 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA for (final RenderBox child in _children.values) { child.detach(); } + for (final RenderBox child in _keepAliveBucket.values) { + child.detach(); + } } @override @@ -806,6 +828,7 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA for (final RenderBox child in _children.values) { child.redepthChildren(); } + _keepAliveBucket.values.forEach(redepthChild); } @override @@ -815,6 +838,7 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA visitor(child); child = parentDataOf(child)._nextSibling; } + _keepAliveBucket.values.forEach(visitor); } @override @@ -824,11 +848,14 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA RenderBox? child = _firstChild; while (child != null) { final TwoDimensionalViewportParentData childParentData = parentDataOf(child); + // TODO(Piinks): When ensure visible is supported, remove this isVisible + // condition. if (childParentData.isVisible) { visitor(child); } child = childParentData._nextSibling; } + // Do not visit children in [_keepAliveBucket]. } @override @@ -959,6 +986,7 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA void performLayout() { _firstChild = null; _lastChild = null; + _activeChildrenForLayoutPass.clear(); _childManager._startLayout(); // Subclass lays out children. @@ -967,15 +995,35 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA assert(_debugCheckContentDimensions()); _didResize = false; _needsDelegateRebuild = false; + _cacheKeepAlives(); invokeLayoutCallback((BoxConstraints _) { _childManager._endLayout(); assert(_debugOrphans?.isEmpty ?? true); + assert(_debugDanglingKeepAlives.isEmpty); + // Ensure we are not keeping anything alive that should not be any longer. + assert(_keepAliveBucket.values.where((RenderBox child) { + return !parentDataOf(child).keepAlive; + }).isEmpty); // Organize children in paint order and complete parent data after // un-used children are disposed of by the childManager. _reifyChildren(); }); } + void _cacheKeepAlives() { + final List remainingChildren = _children.values.toSet().difference( + _activeChildrenForLayoutPass.values.toSet() + ).toList(); + for (final RenderBox child in remainingChildren) { + final TwoDimensionalViewportParentData childParentData = parentDataOf(child); + if (childParentData.keepAlive) { + _keepAliveBucket[childParentData.vicinity] = child; + // Let the child manager know we intend to keep this. + _childManager._reuseChild(childParentData.vicinity); + } + } + } + // Ensures all children have a layoutOffset, sets paintExtent & paintOffset, // and arranges children in paint order. void _reifyChildren() { @@ -1082,12 +1130,20 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA return true; } - /// Returns the child for a given [ChildVicinity]. + /// Returns the child for a given [ChildVicinity], should be called during + /// [layoutChildSequence] in order to instantiate or retrieve children. /// /// This method will build the child if it has not been already, or will reuse - /// it if it already exists. + /// it if it already exists, whether it was part of the previous frame or kept + /// alive. + /// + /// Children for the given [ChildVicinity] will be inserted into the active + /// children list, and so should be visible, or contained within the + /// [cacheExtent]. RenderBox? buildOrObtainChildFor(ChildVicinity vicinity) { assert(vicinity != ChildVicinity.invalid); + // This should only be called during layout. + assert(debugDoingThisLayout); if (_leadingXIndex == null || _trailingXIndex == null || _leadingXIndex == null || _trailingYIndex == null) { // First child of this layout pass. Set leading and trailing trackers. _leadingXIndex = vicinity.xIndex; @@ -1107,11 +1163,12 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA _leadingYIndex = math.min(vicinity.yIndex, _leadingYIndex!); _trailingYIndex = math.max(vicinity.yIndex, _trailingYIndex!); } - if (_needsDelegateRebuild || !_children.containsKey(vicinity)) { + if (_needsDelegateRebuild || (!_children.containsKey(vicinity) && !_keepAliveBucket.containsKey(vicinity))) { invokeLayoutCallback((BoxConstraints _) { _childManager._buildChild(vicinity); }); } else { + _keepAliveBucket.remove(vicinity); _childManager._reuseChild(vicinity); } if (!_children.containsKey(vicinity)) { @@ -1122,6 +1179,7 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA assert(_children.containsKey(vicinity)); final RenderBox child = _children[vicinity]!; + _activeChildrenForLayoutPass[vicinity] = child; parentDataOf(child).vicinity = vicinity; return child; } @@ -1304,23 +1362,59 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA void _insertChild(RenderBox child, ChildVicinity slot) { assert(_debugTrackOrphans(newOrphan: _children[slot])); + assert(!_keepAliveBucket.containsValue(child)); _children[slot] = child; adoptChild(child); } void _moveChild(RenderBox child, {required ChildVicinity from, required ChildVicinity to}) { - if (_children[from] == child) { - _children.remove(from); + final TwoDimensionalViewportParentData childParentData = parentDataOf(child); + if (!childParentData.keptAlive) { + if (_children[from] == child) { + _children.remove(from); + } + assert(_debugTrackOrphans(newOrphan: _children[to], noLongerOrphan: child)); + _children[to] = child; + return; } - assert(_debugTrackOrphans(newOrphan: _children[to], noLongerOrphan: child)); - _children[to] = child; + // If the child in the bucket is not current child, that means someone has + // already moved and replaced current child, and we cannot remove this + // child. + if (_keepAliveBucket[childParentData.vicinity] == child) { + _keepAliveBucket.remove(childParentData.vicinity); + } + assert(() { + _debugDanglingKeepAlives.remove(child); + return true; + }()); + // If there is an existing child in the new slot, that mean that child + // will be moved to other index. In other cases, the existing child should + // have been removed by _removeChild. Thus, it is ok to overwrite it. + assert(() { + if (_keepAliveBucket.containsKey(childParentData.vicinity)) { + _debugDanglingKeepAlives.add(_keepAliveBucket[childParentData.vicinity]!); + } + return true; + }()); + _keepAliveBucket[childParentData.vicinity] = child; } void _removeChild(RenderBox child, ChildVicinity slot) { - if (_children[slot] == child) { - _children.remove(slot); + final TwoDimensionalViewportParentData childParentData = parentDataOf(child); + if (!childParentData.keptAlive) { + if (_children[slot] == child) { + _children.remove(slot); + } + assert(_debugTrackOrphans(noLongerOrphan: child)); + dropChild(child); + return; } - assert(_debugTrackOrphans(noLongerOrphan: child)); + assert(_keepAliveBucket[childParentData.vicinity] == child); + assert(() { + _debugDanglingKeepAlives.remove(child); + return true; + }()); + _keepAliveBucket.remove(childParentData.vicinity); dropChild(child); } diff --git a/packages/flutter/test/widgets/two_dimensional_utils.dart b/packages/flutter/test/widgets/two_dimensional_utils.dart index 5eb3bf2602..017897bcae 100644 --- a/packages/flutter/test/widgets/two_dimensional_utils.dart +++ b/packages/flutter/test/widgets/two_dimensional_utils.dart @@ -393,18 +393,18 @@ class RenderSimpleListTableViewport extends RenderTwoDimensionalViewport { final TwoDimensionalChildListDelegate listDelegate = delegate as TwoDimensionalChildListDelegate; final int rowCount; final int columnCount; - rowCount = listDelegate.children.length - 1; - columnCount = listDelegate.children[0].length - 1; + rowCount = listDelegate.children.length; + columnCount = listDelegate.children[0].length; final int leadingColumn = math.max((horizontalPixels / 200).floor(), 0); final int leadingRow = math.max((verticalPixels / 200).floor(), 0); final int trailingColumn = math.min( ((horizontalPixels + viewportDimension.width) / 200).ceil(), - columnCount, + columnCount - 1, ); final int trailingRow = math.min( ((verticalPixels + viewportDimension.height) / 200).ceil(), - rowCount, + rowCount - 1, ); double xLayoutOffset = (leadingColumn * 200) - horizontalOffset.pixels; @@ -420,7 +420,51 @@ class RenderSimpleListTableViewport extends RenderTwoDimensionalViewport { } xLayoutOffset += 200; } - verticalOffset.applyContentDimensions(0, 200 * 100 - viewportDimension.height); - horizontalOffset.applyContentDimensions(0, 200 * 100 - viewportDimension.width); + + verticalOffset.applyContentDimensions( + 0.0, + math.max(200 * rowCount - viewportDimension.height, 0.0), + ); + horizontalOffset.applyContentDimensions( + 0, + math.max(200 * columnCount - viewportDimension.width, 0.0), + ); + } +} + +class KeepAliveCheckBox extends StatefulWidget { + const KeepAliveCheckBox({ super.key }); + + @override + KeepAliveCheckBoxState createState() => KeepAliveCheckBoxState(); +} + +class KeepAliveCheckBoxState extends State with AutomaticKeepAliveClientMixin { + bool checkValue = false; + + @override + bool get wantKeepAlive => _wantKeepAlive; + bool _wantKeepAlive = false; + set wantKeepAlive(bool value) { + if (_wantKeepAlive != value) { + _wantKeepAlive = value; + updateKeepAlive(); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Checkbox( + value: checkValue, + onChanged: (bool? value) { + if (checkValue != value) { + setState(() { + checkValue = value!; + wantKeepAlive = value; + }); + } + }, + ); } } diff --git a/packages/flutter/test/widgets/two_dimensional_viewport_test.dart b/packages/flutter/test/widgets/two_dimensional_viewport_test.dart index a445d3412c..6cb6d025dd 100644 --- a/packages/flutter/test/widgets/two_dimensional_viewport_test.dart +++ b/packages/flutter/test/widgets/two_dimensional_viewport_test.dart @@ -212,6 +212,172 @@ void main() { testWidgets('shouldRebuild', (WidgetTester tester) async { expect(builderDelegate.shouldRebuild(builderDelegate), isTrue); }, variant: TargetPlatformVariant.all()); + + testWidgets('builder delegate supports automatic keep alive - default true', (WidgetTester tester) async { + const ChildVicinity firstCell = ChildVicinity(xIndex: 0, yIndex: 0); + final ScrollController verticalController = ScrollController(); + final UniqueKey checkBoxKey = UniqueKey(); + final TwoDimensionalChildBuilderDelegate builderDelegate = TwoDimensionalChildBuilderDelegate( + maxXIndex: 5, + maxYIndex: 5, + builder: (BuildContext context, ChildVicinity vicinity) { + return SizedBox.square( + dimension: 200, + child: Center(child: vicinity == firstCell + ? KeepAliveCheckBox(key: checkBoxKey) + : Text('R${vicinity.xIndex}:C${vicinity.yIndex}') + ), + ); + } + ); + + await tester.pumpWidget(simpleBuilderTest( + delegate: builderDelegate, + verticalDetails: ScrollableDetails.vertical(controller: verticalController), + )); + await tester.pumpAndSettle(); + + expect(verticalController.hasClients, isTrue); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isFalse, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isFalse, + ); + // Scroll away, disposing of the checkbox. + verticalController.jumpTo(verticalController.position.maxScrollExtent); + await tester.pump(); + expect(verticalController.position.pixels, 600.0); + expect(find.byKey(checkBoxKey), findsNothing); + + // Bring back into view, still unchecked, not kept alive. + verticalController.jumpTo(0.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + await tester.tap(find.byKey(checkBoxKey)); + await tester.pumpAndSettle(); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isTrue, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isTrue, + ); + + // Scroll away again, checkbox should be kept alive now. + verticalController.jumpTo(verticalController.position.maxScrollExtent); + await tester.pump(); + expect(verticalController.position.pixels, 600.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isTrue, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isTrue, + ); + + // Bring back into view, still checked, after being kept alive. + verticalController.jumpTo(0.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isTrue, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isTrue, + ); + }); + + testWidgets('builder delegate will not add automatic keep alives', (WidgetTester tester) async { + const ChildVicinity firstCell = ChildVicinity(xIndex: 0, yIndex: 0); + final ScrollController verticalController = ScrollController(); + final UniqueKey checkBoxKey = UniqueKey(); + final TwoDimensionalChildBuilderDelegate builderDelegate = TwoDimensionalChildBuilderDelegate( + maxXIndex: 5, + maxYIndex: 5, + addAutomaticKeepAlives: false, // No keeping alive this time + builder: (BuildContext context, ChildVicinity vicinity) { + return SizedBox.square( + dimension: 200, + child: Center(child: vicinity == firstCell + ? KeepAliveCheckBox(key: checkBoxKey) + : Text('R${vicinity.xIndex}:C${vicinity.yIndex}') + ), + ); + } + ); + + await tester.pumpWidget(simpleBuilderTest( + delegate: builderDelegate, + verticalDetails: ScrollableDetails.vertical(controller: verticalController), + )); + await tester.pumpAndSettle(); + + expect(verticalController.hasClients, isTrue); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isFalse, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isFalse, + ); + // Scroll away, disposing of the checkbox. + verticalController.jumpTo(verticalController.position.maxScrollExtent); + await tester.pump(); + expect(verticalController.position.pixels, 600.0); + expect(find.byKey(checkBoxKey), findsNothing); + + // Bring back into view, still unchecked, not kept alive. + verticalController.jumpTo(0.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + await tester.tap(find.byKey(checkBoxKey)); + await tester.pumpAndSettle(); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isTrue, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isTrue, + ); + + // Scroll away again, checkbox should not be kept alive since the + // delegate did not add automatic keep alive. + verticalController.jumpTo(verticalController.position.maxScrollExtent); + await tester.pump(); + expect(verticalController.position.pixels, 600.0); + expect(find.byKey(checkBoxKey), findsNothing); + + // Bring back into view, not checked, having not been kept alive. + verticalController.jumpTo(0.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isFalse, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isFalse, + ); + }); }); group('TwoDimensionalChildListDelegate', () { @@ -338,6 +504,174 @@ void main() { expect(delegate.shouldRebuild(oldDelegate), isTrue); }, variant: TargetPlatformVariant.all()); }); + + testWidgets('list delegate supports automatic keep alive - default true', (WidgetTester tester) async { + final UniqueKey checkBoxKey = UniqueKey(); + final Widget originCell = SizedBox.square( + dimension: 200, + child: Center(child: KeepAliveCheckBox(key: checkBoxKey) + ), + ); + const Widget otherCell = SizedBox.square(dimension: 200); + final ScrollController verticalController = ScrollController(); + final TwoDimensionalChildListDelegate listDelegate = TwoDimensionalChildListDelegate( + children: >[ + [originCell, otherCell, otherCell, otherCell, otherCell], + [otherCell, otherCell, otherCell, otherCell, otherCell], + [otherCell, otherCell, otherCell, otherCell, otherCell], + [otherCell, otherCell, otherCell, otherCell, otherCell], + [otherCell, otherCell, otherCell, otherCell, otherCell], + ], + ); + + await tester.pumpWidget(simpleListTest( + delegate: listDelegate, + verticalDetails: ScrollableDetails.vertical(controller: verticalController), + )); + await tester.pumpAndSettle(); + + expect(verticalController.hasClients, isTrue); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isFalse, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isFalse, + ); + // Scroll away, disposing of the checkbox. + verticalController.jumpTo(verticalController.position.maxScrollExtent); + await tester.pump(); + expect(verticalController.position.pixels, 400.0); + expect(find.byKey(checkBoxKey), findsNothing); + + // Bring back into view, still unchecked, not kept alive. + verticalController.jumpTo(0.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + await tester.tap(find.byKey(checkBoxKey)); + await tester.pumpAndSettle(); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isTrue, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isTrue, + ); + + // Scroll away again, checkbox should be kept alive now. + verticalController.jumpTo(verticalController.position.maxScrollExtent); + await tester.pump(); + expect(verticalController.position.pixels, 400.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isTrue, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isTrue, + ); + + // Bring back into view, still checked, after being kept alive. + verticalController.jumpTo(0.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isTrue, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isTrue, + ); + }); + + testWidgets('list delegate will not add automatic keep alives', (WidgetTester tester) async { + final UniqueKey checkBoxKey = UniqueKey(); + final Widget originCell = SizedBox.square( + dimension: 200, + child: Center(child: KeepAliveCheckBox(key: checkBoxKey) + ), + ); + const Widget otherCell = SizedBox.square(dimension: 200); + final ScrollController verticalController = ScrollController(); + final TwoDimensionalChildListDelegate listDelegate = TwoDimensionalChildListDelegate( + addAutomaticKeepAlives: false, + children: >[ + [originCell, otherCell, otherCell, otherCell, otherCell], + [otherCell, otherCell, otherCell, otherCell, otherCell], + [otherCell, otherCell, otherCell, otherCell, otherCell], + [otherCell, otherCell, otherCell, otherCell, otherCell], + [otherCell, otherCell, otherCell, otherCell, otherCell], + ], + ); + + await tester.pumpWidget(simpleListTest( + delegate: listDelegate, + verticalDetails: ScrollableDetails.vertical(controller: verticalController), + )); + await tester.pumpAndSettle(); + + expect(verticalController.hasClients, isTrue); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isFalse, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isFalse, + ); + // Scroll away, disposing of the checkbox. + verticalController.jumpTo(verticalController.position.maxScrollExtent); + await tester.pump(); + expect(verticalController.position.pixels, 400.0); + expect(find.byKey(checkBoxKey), findsNothing); + + // Bring back into view, still unchecked, not kept alive. + verticalController.jumpTo(0.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + await tester.tap(find.byKey(checkBoxKey)); + await tester.pumpAndSettle(); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isTrue, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isTrue, + ); + + // Scroll away again, checkbox should not be kept alive since the + // delegate did not add automatic keep alive. + verticalController.jumpTo(verticalController.position.maxScrollExtent); + await tester.pump(); + expect(verticalController.position.pixels, 400.0); + expect(find.byKey(checkBoxKey), findsNothing); + + // Bring back into view, not checked, having not been kept alive. + verticalController.jumpTo(0.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(find.byKey(checkBoxKey), findsOneWidget); + expect( + tester.state(find.byKey(checkBoxKey)).checkValue, + isFalse, + ); + expect( + tester.state(find.byKey(checkBoxKey)).wantKeepAlive, + isFalse, + ); + }); }); group('TwoDimensionalScrollable', () { @@ -1025,7 +1359,7 @@ void main() { expect( parentData.toString(), 'vicinity=(xIndex: 10, yIndex: 10); layoutOffset=Offset(20.0, 20.0); ' - 'paintOffset=Offset(20.0, 20.0); not visible ', + 'paintOffset=Offset(20.0, 20.0); not visible; ', ); });