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; ', ); });