diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index b481c2fce5..c4b5e1f227 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -1125,6 +1125,7 @@ class _TabBarViewState extends State { TabController _controller; PageController _pageController; List _children; + List _childrenWithKey; int _currentIndex; int _warpUnderwayCount = 0; @@ -1156,7 +1157,7 @@ class _TabBarViewState extends State { @override void initState() { super.initState(); - _children = widget.children; + _updateChildren(); } @override @@ -1173,7 +1174,7 @@ class _TabBarViewState extends State { if (widget.controller != oldWidget.controller) _updateTabController(); if (widget.children != oldWidget.children && _warpUnderwayCount == 0) - _children = widget.children; + _updateChildren(); } @override @@ -1184,6 +1185,11 @@ class _TabBarViewState extends State { super.dispose(); } + void _updateChildren() { + _children = widget.children; + _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children); + } + void _handleTabControllerAnimationTick() { if (_warpUnderwayCount > 0 || !_controller.indexIsChanging) return; // This widget is driving the controller's animation. @@ -1206,28 +1212,30 @@ class _TabBarViewState extends State { return _pageController.animateToPage(_currentIndex, duration: kTabScrollDuration, curve: Curves.ease); assert((_currentIndex - previousIndex).abs() > 1); - int initialPage; + final int initialPage = _currentIndex > previousIndex + ? _currentIndex - 1 + : _currentIndex + 1; + final List originalChildren = _childrenWithKey; setState(() { _warpUnderwayCount += 1; - _children = List.from(widget.children, growable: false); - if (_currentIndex > previousIndex) { - _children[_currentIndex - 1] = _children[previousIndex]; - initialPage = _currentIndex - 1; - } else { - _children[_currentIndex + 1] = _children[previousIndex]; - initialPage = _currentIndex + 1; - } - }); + _childrenWithKey = List.from(_childrenWithKey, growable: false); + final Widget temp = _childrenWithKey[initialPage]; + _childrenWithKey[initialPage] = _childrenWithKey[previousIndex]; + _childrenWithKey[previousIndex] = temp; + }); _pageController.jumpToPage(initialPage); await _pageController.animateToPage(_currentIndex, duration: kTabScrollDuration, curve: Curves.ease); if (!mounted) return Future.value(); - setState(() { _warpUnderwayCount -= 1; - _children = widget.children; + if (widget.children != _children) { + _updateChildren(); + } else { + _childrenWithKey = originalChildren; + } }); } @@ -1272,7 +1280,7 @@ class _TabBarViewState extends State { dragStartBehavior: widget.dragStartBehavior, controller: _pageController, physics: widget.physics == null ? _kTabBarViewPhysics : _kTabBarViewPhysics.applyTo(widget.physics), - children: _children, + children: _childrenWithKey, ), ); } diff --git a/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart b/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart index e950078d89..0280a6e373 100644 --- a/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart +++ b/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart @@ -85,7 +85,8 @@ abstract class RenderSliverBoxChildManager { /// list). int get childCount; - /// Called during [RenderSliverMultiBoxAdaptor.adoptChild]. + /// Called during [RenderSliverMultiBoxAdaptor.adoptChild] or + /// [RenderSliverMultiBoxAdaptor.move]. /// /// Subclasses must ensure that the [SliverMultiBoxAdaptorParentData.index] /// field of the child's [RenderObject.parentData] accurately reflects the @@ -193,7 +194,12 @@ abstract class RenderSliverMultiBoxAdaptor extends RenderSliver RenderSliverMultiBoxAdaptor({ @required RenderSliverBoxChildManager childManager, }) : assert(childManager != null), - _childManager = childManager; + _childManager = childManager { + assert(() { + _debugDanglingKeepAlives = []; + return true; + }()); + } @override void setupParentData(RenderObject child) { @@ -214,6 +220,27 @@ abstract class RenderSliverMultiBoxAdaptor extends RenderSliver /// The nodes being kept alive despite not being visible. final Map _keepAliveBucket = {}; + List _debugDanglingKeepAlives; + + /// Indicates whether integrity check is enabled. + /// + /// Setting this property to true will immediately perform an integrity check. + /// + /// The integrity check consists of: + /// + /// 1. Verify that the children index in childList is in ascending order. + /// 2. Verify that there is no dangling keepalive child as the result of [move]. + bool get debugChildIntegrityEnabled => _debugChildIntegrityEnabled; + bool _debugChildIntegrityEnabled = true; + set debugChildIntegrityEnabled(bool enabled) { + assert(enabled != null); + assert(() { + _debugChildIntegrityEnabled = enabled; + return _debugVerifyChildOrder() && + (!_debugChildIntegrityEnabled || _debugDanglingKeepAlives.isEmpty); + }()); + } + @override void adoptChild(RenderObject child) { super.adoptChild(child); @@ -224,21 +251,70 @@ abstract class RenderSliverMultiBoxAdaptor extends RenderSliver bool _debugAssertChildListLocked() => childManager.debugAssertChildListLocked(); + /// Verify that the child list index is in strictly increasing order. + /// + /// This has no effect in release builds. + bool _debugVerifyChildOrder(){ + if (_debugChildIntegrityEnabled) { + RenderBox child = firstChild; + int index; + while (child != null) { + index = indexOf(child); + child = childAfter(child); + assert(child == null || indexOf(child) > index); + } + } + return true; + } + @override void insert(RenderBox child, { RenderBox after }) { assert(!_keepAliveBucket.containsValue(child)); super.insert(child, after: after); assert(firstChild != null); - assert(() { - int index = indexOf(firstChild); - RenderBox child = childAfter(firstChild); - while (child != null) { - assert(indexOf(child) > index); - index = indexOf(child); - child = childAfter(child); + assert(_debugVerifyChildOrder()); + } + + @override + void move(RenderBox child, { RenderBox after }) { + // There are two scenarios: + // + // 1. The child is not keptAlive. + // The child is in the childList maintained by ContainerRenderObjectMixin. + // We can call super.move and update parentData with the new slot. + // + // 2. The child is keptAlive. + // In this case, the child is no longer in the childList but might be stored in + // [_keepAliveBucket]. We need to update the location of the child in the bucket. + final SliverMultiBoxAdaptorParentData childParentData = child.parentData; + if (!childParentData.keptAlive) { + super.move(child, after: after); + childManager.didAdoptChild(child); // updates the slot in the parentData + // Its slot may change even if super.move does not change the position. + // In this case, we still want to mark as needs layout. + markNeedsLayout(); + } else { + // 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.index] == child) { + _keepAliveBucket.remove(childParentData.index); } - return true; - }()); + assert(() { + _debugDanglingKeepAlives.remove(child); + return true; + }()); + // Update the slot and reinsert back to _keepAliveBucket in the new slot. + childManager.didAdoptChild(child); + // 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 updateChild. Thus, it is ok to overwrite it. + assert(() { + if (_keepAliveBucket.containsKey(childParentData.index)) + _debugDanglingKeepAlives.add(_keepAliveBucket[childParentData.index]); + return true; + }()); + _keepAliveBucket[childParentData.index] = child; + } } @override @@ -249,6 +325,10 @@ abstract class RenderSliverMultiBoxAdaptor extends RenderSliver return; } assert(_keepAliveBucket[childParentData.index] == child); + assert(() { + _debugDanglingKeepAlives.remove(child); + return true; + }()); _keepAliveBucket.remove(childParentData.index); dropChild(child); } diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 257ad9116e..9ae2d5a64b 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -149,6 +149,13 @@ abstract class GlobalKey> extends Key { assert(() { assert(parent != null); if (_debugReservations.containsKey(this) && _debugReservations[this] != parent) { + // Reserving a new parent while the old parent is not attached is ok. + // This can happen when a renderObject detaches and re-attaches to rendering + // tree multiple times. + if (_debugReservations[this].renderObject?.attached == false) { + _debugReservations[this] = parent; + return true; + } // It's possible for an element to get built multiple times in one // frame, in which case it'll reserve the same child's key multiple // times. We catch multiple children of one widget having the same key diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart index 19f1b8b9c9..aa6f7e5f9a 100644 --- a/packages/flutter/lib/src/widgets/sliver.dart +++ b/packages/flutter/lib/src/widgets/sliver.dart @@ -174,6 +174,13 @@ abstract class SliverChildDelegate { /// away. bool shouldRebuild(covariant SliverChildDelegate oldDelegate); + /// Find index of child element with associated key. + /// + /// This will be called during [performRebuild] in [SliverMultiBoxAdaptorElement] + /// to check if a child has moved to a different position. It should return the + /// index of the child element with associated key, null if not found. + int findIndexByKey(Key key) => null; + @override String toString() { final List description = []; @@ -195,6 +202,12 @@ abstract class SliverChildDelegate { } } +class _SaltedValueKey extends ValueKey{ + const _SaltedValueKey(Key key): assert(key != null), super(key); +} + +typedef ChildIndexGetter = int Function(Key key); + /// A delegate that supplies children for slivers using a builder callback. /// /// Many slivers lazily construct their box children to avoid creating more @@ -306,8 +319,14 @@ class SliverChildBuilderDelegate extends SliverChildDelegate { /// The [builder], [addAutomaticKeepAlives], [addRepaintBoundaries], /// [addSemanticIndexes], and [semanticIndexCallback] arguments must not be /// null. + /// + /// If the order in which [builder] returns children ever changes, consider + /// providing a [findChildIndex]. This allows the delegate to find the new index + /// for a child that was previously located at a different index to attach the + /// existing state to the [Widget] at its new location. const SliverChildBuilderDelegate( this.builder, { + this.findChildIndexCallback, this.childCount, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, @@ -388,6 +407,31 @@ class SliverChildBuilderDelegate extends SliverChildDelegate { /// Defaults to providing an index for each widget. final SemanticIndexCallback semanticIndexCallback; + /// Called to find the new index of a child based on its key in case of reordering. + /// + /// If not provided, a child widget may not map to its existing [RenderObject] + /// when the order in which children are returned from [builder] changes. + /// This may result in state-loss. + /// + /// This callback should take an input [Key], and It should return the + /// index of the child element with associated key, null if not found. + final ChildIndexGetter findChildIndexCallback; + + @override + int findIndexByKey(Key key) { + if (findChildIndexCallback == null) + return null; + assert(key != null); + Key childKey; + if (key is _SaltedValueKey) { + final _SaltedValueKey saltedValueKey = key; + childKey = saltedValueKey.value; + } else { + childKey = key; + } + return findChildIndexCallback(childKey); + } + @override Widget build(BuildContext context, int index) { assert(builder != null); @@ -401,8 +445,9 @@ class SliverChildBuilderDelegate extends SliverChildDelegate { } if (child == null) return null; + final Key key = child.key != null ? _SaltedValueKey(child.key) : null; if (addRepaintBoundaries) - child = RepaintBoundary.wrap(child, index); + child = RepaintBoundary(child: child); if (addSemanticIndexes) { final int semanticIndex = semanticIndexCallback(child, index); if (semanticIndex != null) @@ -410,7 +455,7 @@ class SliverChildBuilderDelegate extends SliverChildDelegate { } if (addAutomaticKeepAlives) child = AutomaticKeepAlive(child: child); - return child; + return KeyedSubtree(child: child, key: key); } @override @@ -478,7 +523,10 @@ class SliverChildListDelegate extends SliverChildDelegate { /// The [children], [addAutomaticKeepAlives], [addRepaintBoundaries], /// [addSemanticIndexes], and [semanticIndexCallback] arguments must not be /// null. - const SliverChildListDelegate( + /// + /// If the order of children` never changes, consider using the constant + /// [SliverChildListDelegate.fixed] constructor. + SliverChildListDelegate( this.children, { this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, @@ -489,7 +537,31 @@ class SliverChildListDelegate extends SliverChildDelegate { assert(addAutomaticKeepAlives != null), assert(addRepaintBoundaries != null), assert(addSemanticIndexes != null), - assert(semanticIndexCallback != null); + assert(semanticIndexCallback != null), + _keyToIndex = {null: 0}; + + /// Creates a constant version of the delegate that supplies children for + /// slivers using the given list. + /// + /// If the order of the children will change, consider using the regular + /// [SliverChildListDelegate] constructor. + /// + /// The [children], [addAutomaticKeepAlives], [addRepaintBoundaries], + /// [addSemanticIndexes], and [semanticIndexCallback] arguments must not be + /// null. + const SliverChildListDelegate.fixed( + this.children, { + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + this.semanticIndexCallback = _kDefaultSemanticIndexCallback, + this.semanticIndexOffset = 0, + }) : assert(children != null), + assert(addAutomaticKeepAlives != null), + assert(addRepaintBoundaries != null), + assert(addSemanticIndexes != null), + assert(semanticIndexCallback != null), + _keyToIndex = null; /// Whether to wrap each child in an [AutomaticKeepAlive]. /// @@ -544,15 +616,63 @@ class SliverChildListDelegate extends SliverChildDelegate { /// The widgets to display. final List children; + /// A map to cache key to index lookup for children. + /// + /// _keyToIndex[null] is used as current index during the lazy loading process + /// in [_findChildIndex]. _keyToIndex should never be used for looking up null key. + final Map _keyToIndex; + + bool get _isConstantInstance => _keyToIndex == null; + + int _findChildIndex(Key key) { + if (_isConstantInstance) { + return null; + } + // Lazily fill the [_keyToIndex]. + if (!_keyToIndex.containsKey(key)) { + int index = _keyToIndex[null]; + while (index < children.length) { + final Widget child = children[index]; + if (child.key != null) { + _keyToIndex[child.key] = index; + } + if (child.key == key) { + // Record current index for next function call. + _keyToIndex[null] = index + 1; + return index; + } + index += 1; + } + _keyToIndex[null] = index; + } else { + return _keyToIndex[key]; + } + return null; + } + + @override + int findIndexByKey(Key key) { + assert(key != null); + Key childKey; + if (key is _SaltedValueKey) { + final _SaltedValueKey saltedValueKey = key; + childKey = saltedValueKey.value; + } else { + childKey = key; + } + return _findChildIndex(childKey); + } + @override Widget build(BuildContext context, int index) { assert(children != null); if (index < 0 || index >= children.length) return null; Widget child = children[index]; + final Key key = child.key != null? _SaltedValueKey(child.key) : null; assert(child != null); if (addRepaintBoundaries) - child = RepaintBoundary.wrap(child, index); + child = RepaintBoundary(child: child); if (addSemanticIndexes) { final int semanticIndex = semanticIndexCallback(child, index); if (semanticIndex != null) @@ -560,7 +680,7 @@ class SliverChildListDelegate extends SliverChildDelegate { } if (addAutomaticKeepAlives) child = AutomaticKeepAlive(child: child); - return child; + return KeyedSubtree(child: child, key: key); } @override @@ -979,9 +1099,15 @@ class SliverMultiBoxAdaptorElement extends RenderObjectElement implements Render _currentBeforeChild = null; assert(_currentlyUpdatingChildIndex == null); try { + final SplayTreeMap newChildren = SplayTreeMap(); + void processElement(int index) { _currentlyUpdatingChildIndex = index; - final Element newChild = updateChild(_childElements[index], _build(index), index); + if (_childElements[index] != null && _childElements[index] != newChildren[index]) { + // This index has an old child that isn't used anywhere and should be deactivated. + _childElements[index] = updateChild(_childElements[index], null, index); + } + final Element newChild = updateChild(newChildren[index], _build(index), index); if (newChild != null) { _childElements[index] = newChild; final SliverMultiBoxAdaptorParentData parentData = newChild.renderObject.parentData; @@ -991,14 +1117,32 @@ class SliverMultiBoxAdaptorElement extends RenderObjectElement implements Render _childElements.remove(index); } } - // processElement may modify the Map - need to do a .toList() here. - _childElements.keys.toList().forEach(processElement); + + for (int index in _childElements.keys.toList()) { + final Key key = _childElements[index].widget.key; + final int newIndex = key == null ? null : widget.delegate.findIndexByKey(key); + if (newIndex != null && newIndex != index) { + newChildren[newIndex] = _childElements[index]; + // We need to make sure the original index gets processed. + newChildren.putIfAbsent(index, () => null); + // We do not want the remapped child to get deactivated during processElement. + _childElements.remove(index); + } else { + newChildren.putIfAbsent(index, () => _childElements[index]); + } + } + + renderObject.debugChildIntegrityEnabled = false; // Moving children will temporary violate the integrity. + newChildren.keys.forEach(processElement); if (_didUnderflow) { final int lastKey = _childElements.lastKey() ?? -1; - processElement(lastKey + 1); + final int rightBoundary = lastKey + 1; + newChildren[rightBoundary] = _childElements[rightBoundary]; + processElement(rightBoundary); } } finally { _currentlyUpdatingChildIndex = null; + renderObject.debugChildIntegrityEnabled = true; } } @@ -1038,7 +1182,6 @@ class SliverMultiBoxAdaptorElement extends RenderObjectElement implements Render if (oldParentData != newParentData && oldParentData != null && newParentData != null) { newParentData.layoutOffset = oldParentData.layoutOffset; } - return newChild; } @@ -1163,9 +1306,9 @@ class SliverMultiBoxAdaptorElement extends RenderObjectElement implements Render @override void moveChildRenderObject(covariant RenderObject child, int slot) { - // TODO(ianh): At some point we should be better about noticing when a - // particular LocalKey changes slot, and handle moving the nodes around. - assert(false); + assert(slot != null); + assert(_currentlyUpdatingChildIndex == slot); + renderObject.move(child, after: _currentBeforeChild); } @override diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index e6c8f6cf1f..5f95e1cb92 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -49,6 +49,7 @@ class StateMarkerState extends State { } class AlwaysKeepAliveWidget extends StatefulWidget { + const AlwaysKeepAliveWidget({ Key key}) : super(key: key); static String text = 'AlwaysKeepAlive'; @override AlwaysKeepAliveState createState() => AlwaysKeepAliveState(); @@ -1987,6 +1988,56 @@ void main() { }); + testWidgets('Skipping tabs with global key does not crash', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/24660 + final List tabs = [ + 'Tab1', + 'Tab2', + 'Tab3', + 'Tab4', + ]; + final TabController controller = TabController( + vsync: const TestVSync(), + length: tabs.length, + ); + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 300.0, + height: 200.0, + child: Scaffold( + appBar: AppBar( + title: const Text('tabs'), + bottom: TabBar( + controller: controller, + tabs: tabs.map((String tab) => Tab(text: tab)).toList(), + ), + ), + body: TabBarView( + controller: controller, + children: [ + Text('1', key: GlobalKey()), + Text('2', key: GlobalKey()), + Text('3', key: GlobalKey()), + Text('4', key: GlobalKey()), + ], + ), + ), + ), + ), + ), + ); + expect(find.text('1'), findsOneWidget); + expect(find.text('4'), findsNothing); + await tester.tap(find.text('Tab4')); + await tester.pumpAndSettle(); + expect(controller.index, 3); + expect(find.text('4'), findsOneWidget); + expect(find.text('1'), findsNothing); + }); + testWidgets('Skipping tabs with a KeepAlive child works', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/11895 final List tabs = [ @@ -2018,7 +2069,7 @@ void main() { body: TabBarView( controller: controller, children: [ - AlwaysKeepAliveWidget(), + AlwaysKeepAliveWidget(key: UniqueKey()), const Text('2'), const Text('3'), const Text('4'), diff --git a/packages/flutter/test/widgets/box_sliver_mismatch_test.dart b/packages/flutter/test/widgets/box_sliver_mismatch_test.dart index fe93c90ea9..6828d01d9a 100644 --- a/packages/flutter/test/widgets/box_sliver_mismatch_test.dart +++ b/packages/flutter/test/widgets/box_sliver_mismatch_test.dart @@ -9,10 +9,10 @@ import 'package:flutter/widgets.dart'; void main() { testWidgets('Sliver in a box', (WidgetTester tester) async { await tester.pumpWidget( - const DecoratedBox( - decoration: BoxDecoration(), + DecoratedBox( + decoration: const BoxDecoration(), child: SliverList( - delegate: SliverChildListDelegate([]), + delegate: SliverChildListDelegate(const []), ), ), ); @@ -21,9 +21,9 @@ void main() { await tester.pumpWidget( Row( - children: const [ + children: [ SliverList( - delegate: SliverChildListDelegate([]), + delegate: SliverChildListDelegate(const []), ), ], ), diff --git a/packages/flutter/test/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index 69dddbae0e..6a22359b9f 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -451,6 +451,41 @@ void main() { expect(count, 2); }); + testWidgets('GlobalKey - dettach and re-attach child to different parents', (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Container( + height: 100, + child: CustomScrollView( + controller: ScrollController(), + slivers: [ + SliverList( + delegate: SliverChildListDelegate([ + Text('child', key: GlobalKey()), + ]), + ) + ], + ), + ), + ), + )); + final SliverMultiBoxAdaptorElement element = tester.element(find.byType(SliverList)); + Element childElement; + // Removing and recreating child with same Global Key should not trigger + // duplicate key error. + element.visitChildren((Element e) { + childElement = e; + }); + element.removeChild(childElement.renderObject); + element.createChild(0, after: null); + element.visitChildren((Element e) { + childElement = e; + }); + element.removeChild(childElement.renderObject); + element.createChild(0, after: null); + }); + testWidgets('Defunct setState throws exception', (WidgetTester tester) async { StateSetter setState; diff --git a/packages/flutter/test/widgets/slivers_appbar_floating_pinned_test.dart b/packages/flutter/test/widgets/slivers_appbar_floating_pinned_test.dart index d0f246823b..f6ac603c32 100644 --- a/packages/flutter/test/widgets/slivers_appbar_floating_pinned_test.dart +++ b/packages/flutter/test/widgets/slivers_appbar_floating_pinned_test.dart @@ -22,12 +22,12 @@ void main() { data: const MediaQueryData(), child: CustomScrollView( controller: controller, - slivers: const [ - SliverAppBar(floating: true, pinned: true, expandedHeight: 200.0, title: Text('A')), - SliverAppBar(primary: false, pinned: true, title: Text('B')), + slivers: [ + const SliverAppBar(floating: true, pinned: true, expandedHeight: 200.0, title: Text('A')), + const SliverAppBar(primary: false, pinned: true, title: Text('B')), SliverList( delegate: SliverChildListDelegate( - [ + const [ Text('C'), Text('D'), SizedBox(height: 500.0), diff --git a/packages/flutter/test/widgets/slivers_appbar_floating_test.dart b/packages/flutter/test/widgets/slivers_appbar_floating_test.dart index 03603bdadb..548b43e705 100644 --- a/packages/flutter/test/widgets/slivers_appbar_floating_test.dart +++ b/packages/flutter/test/widgets/slivers_appbar_floating_test.dart @@ -203,9 +203,9 @@ void main() { physics: const BouncingScrollPhysics(), slivers: [ SliverPersistentHeader(delegate: TestDelegate(), floating: true), - const SliverList( + SliverList( delegate: SliverChildListDelegate([ - SizedBox( + const SizedBox( height: 300.0, child: Text('X'), ), diff --git a/packages/flutter/test/widgets/slivers_appbar_pinned_test.dart b/packages/flutter/test/widgets/slivers_appbar_pinned_test.dart index 4e355017a8..ad42d12797 100644 --- a/packages/flutter/test/widgets/slivers_appbar_pinned_test.dart +++ b/packages/flutter/test/widgets/slivers_appbar_pinned_test.dart @@ -258,9 +258,9 @@ void main() { physics: const BouncingScrollPhysics(), slivers: [ SliverPersistentHeader(delegate: TestDelegate(), pinned: true), - const SliverList( + SliverList( delegate: SliverChildListDelegate([ - SizedBox( + const SizedBox( height: 300.0, child: Text('X'), ), diff --git a/packages/flutter/test/widgets/slivers_appbar_scrolling_test.dart b/packages/flutter/test/widgets/slivers_appbar_scrolling_test.dart index 4a766523a9..56a88f91ee 100644 --- a/packages/flutter/test/widgets/slivers_appbar_scrolling_test.dart +++ b/packages/flutter/test/widgets/slivers_appbar_scrolling_test.dart @@ -82,8 +82,40 @@ void main() { physics: const BouncingScrollPhysics(), slivers: [ SliverPersistentHeader(delegate: TestDelegate()), - const SliverList( + SliverList( delegate: SliverChildListDelegate([ + const SizedBox( + height: 300.0, + child: Text('X'), + ), + ]), + ), + ], + ), + ), + ); + + expect(tester.getTopLeft(find.byType(Container)), Offset.zero); + expect(tester.getTopLeft(find.text('X')), const Offset(0.0, 200.0)); + + final ScrollPosition position = tester.state(find.byType(Scrollable)).position; + position.jumpTo(-50.0); + await tester.pump(); + + expect(tester.getTopLeft(find.byType(Container)), Offset.zero); + expect(tester.getTopLeft(find.text('X')), const Offset(0.0, 250.0)); + }); + + testWidgets('Sliver appbars const child delegate - scrolling - overscroll gap is below header', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverPersistentHeader(delegate: TestDelegate()), + const SliverList( + delegate: SliverChildListDelegate.fixed([ SizedBox( height: 300.0, child: Text('X'), diff --git a/packages/flutter/test/widgets/slivers_block_test.dart b/packages/flutter/test/widgets/slivers_block_test.dart index 147b25ba98..3869dc7e9a 100644 --- a/packages/flutter/test/widgets/slivers_block_test.dart +++ b/packages/flutter/test/widgets/slivers_block_test.dart @@ -9,6 +9,28 @@ import 'package:flutter/widgets.dart'; import '../rendering/mock_canvas.dart'; Future test(WidgetTester tester, double offset) { + return tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Viewport( + offset: ViewportOffset.fixed(offset), + slivers: [ + SliverList( + delegate: SliverChildListDelegate(const [ + SizedBox(height: 400.0, child: Text('a')), + SizedBox(height: 400.0, child: Text('b')), + SizedBox(height: 400.0, child: Text('c')), + SizedBox(height: 400.0, child: Text('d')), + SizedBox(height: 400.0, child: Text('e')), + ]), + ), + ], + ), + ), + ); +} + +Future testWithConstChildDelegate(WidgetTester tester, double offset) { return tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, @@ -16,7 +38,7 @@ Future test(WidgetTester tester, double offset) { offset: ViewportOffset.fixed(offset), slivers: const [ SliverList( - delegate: SliverChildListDelegate([ + delegate: SliverChildListDelegate.fixed([ SizedBox(height: 400.0, child: Text('a')), SizedBox(height: 400.0, child: Text('b')), SizedBox(height: 400.0, child: Text('c')), @@ -76,6 +98,39 @@ void main() { ], 'ab'); }); + testWidgets('Viewport+SliverBlock basic test with constant SliverChildListDelegate', (WidgetTester tester) async { + await testWithConstChildDelegate(tester, 0.0); + expect(tester.renderObject(find.byType(Viewport)).size, equals(const Size(800.0, 600.0))); + verify(tester, [ + const Offset(0.0, 0.0), + const Offset(0.0, 400.0), + ], 'ab'); + + await testWithConstChildDelegate(tester, 200.0); + verify(tester, [ + const Offset(0.0, -200.0), + const Offset(0.0, 200.0), + ], 'ab'); + + await testWithConstChildDelegate(tester, 600.0); + verify(tester, [ + const Offset(0.0, -200.0), + const Offset(0.0, 200.0), + ], 'bc'); + + await testWithConstChildDelegate(tester, 900.0); + verify(tester, [ + const Offset(0.0, -100.0), + const Offset(0.0, 300.0), + ], 'cd'); + + await testWithConstChildDelegate(tester, 200.0); + verify(tester, [ + const Offset(0.0, -200.0), + const Offset(0.0, 200.0), + ], 'ab'); + }); + testWidgets('Viewport with GlobalKey reparenting', (WidgetTester tester) async { final Key key1 = GlobalKey(); final ViewportOffset offset = ViewportOffset.zero(); @@ -150,9 +205,9 @@ void main() { textDirection: TextDirection.ltr, child: Viewport( offset: offset, - slivers: const [ + slivers: [ SliverList( - delegate: SliverChildListDelegate([ + delegate: SliverChildListDelegate(const [ SizedBox(height: 251.0, child: Text('a')), SizedBox(height: 252.0, child: Text('b')), ]), @@ -261,9 +316,9 @@ void main() { textDirection: TextDirection.ltr, child: Viewport( offset: ViewportOffset.zero(), - slivers: const [ + slivers: [ SliverList( - delegate: SliverChildListDelegate([ + delegate: SliverChildListDelegate(const [ SizedBox(height: 400.0, child: Text('a')), ]), ), @@ -279,9 +334,9 @@ void main() { textDirection: TextDirection.ltr, child: Viewport( offset: ViewportOffset.fixed(100.0), - slivers: const [ + slivers: [ SliverList( - delegate: SliverChildListDelegate([ + delegate: SliverChildListDelegate(const [ SizedBox(height: 400.0, child: Text('a')), ]), ), @@ -297,9 +352,9 @@ void main() { textDirection: TextDirection.ltr, child: Viewport( offset: ViewportOffset.fixed(100.0), - slivers: const [ + slivers: [ SliverList( - delegate: SliverChildListDelegate([ + delegate: SliverChildListDelegate(const [ SizedBox(height: 4000.0, child: Text('a')), ]), ), @@ -315,9 +370,9 @@ void main() { textDirection: TextDirection.ltr, child: Viewport( offset: ViewportOffset.zero(), - slivers: const [ + slivers: [ SliverList( - delegate: SliverChildListDelegate([ + delegate: SliverChildListDelegate(const [ SizedBox(height: 4000.0, child: Text('a')), ]), ), diff --git a/packages/flutter/test/widgets/slivers_keepalive_test.dart b/packages/flutter/test/widgets/slivers_keepalive_test.dart new file mode 100644 index 0000000000..964098a050 --- /dev/null +++ b/packages/flutter/test/widgets/slivers_keepalive_test.dart @@ -0,0 +1,646 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; + +void main() { + testWidgets('Sliver with keep alive without key - should dispose after reodering', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', keepAlive: true), + WidgetTest1(text: 'child 1', keepAlive: true), + WidgetTest2(text: 'child 2', keepAlive: true), + ]; + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 2'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsNothing); + + expect(state0.hasBeenDisposed, true); + expect(state2.hasBeenDisposed, false); + }); + + testWidgets('Sliver without keep alive without key - should dispose after reodering', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0'), + WidgetTest1(text: 'child 1'), + WidgetTest2(text: 'child 2'), + ]; + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 2'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsNothing); + + expect(state0.hasBeenDisposed, true); + expect(state2.hasBeenDisposed, false); + }); + + testWidgets('Sliver without keep alive with key - should dispose after reodering', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', key: GlobalKey()), + WidgetTest1(text: 'child 1', key: GlobalKey()), + WidgetTest2(text: 'child 2', key: GlobalKey()), + ]; + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 2'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsNothing); + + expect(state0.hasBeenDisposed, true); + expect(state2.hasBeenDisposed, false); + }); + + testWidgets('Sliver with keep alive with key - should not dispose after reodering', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', key: GlobalKey(), keepAlive: true), + WidgetTest1(text: 'child 1', key: GlobalKey(), keepAlive: true), + WidgetTest2(text: 'child 2', key: GlobalKey(), keepAlive: true), + ]; + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 2'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + expect(state0.hasBeenDisposed, false); + expect(state2.hasBeenDisposed, false); + }); + + testWidgets('Sliver with keep alive with Unique key - should not dispose after reodering', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', key: UniqueKey(), keepAlive: true), + WidgetTest1(text: 'child 1', key: UniqueKey(), keepAlive: true), + WidgetTest2(text: 'child 2', key: UniqueKey(), keepAlive: true), + ]; + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 2'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + expect(state0.hasBeenDisposed, false); + expect(state2.hasBeenDisposed, false); + }); + + testWidgets('Sliver with keep alive with Value key - should not dispose after reodering', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', key: const ValueKey(0), keepAlive: true), + WidgetTest1(text: 'child 1', key: const ValueKey(1), keepAlive: true), + WidgetTest2(text: 'child 2', key: const ValueKey(2), keepAlive: true), + ]; + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 2'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + expect(state0.hasBeenDisposed, false); + expect(state2.hasBeenDisposed, false); + }); + + testWidgets('Sliver complex case 1', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', key: GlobalKey(), keepAlive: true), + WidgetTest1(text: 'child 1', key: GlobalKey(), keepAlive: true), + WidgetTest2(text: 'child 2', keepAlive: true), + ]; + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 2'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + + childList = createSwitchedChildList(childList, 0, 1); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest1State state1 = tester.state(find.byType(WidgetTest1)); + expect(find.text('child 1'), findsOneWidget); + expect(find.text('child 2', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + + childList = createSwitchedChildList(childList, 1, 2); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + expect(find.text('child 1'), findsOneWidget); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 1); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsOneWidget); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + expect(state0.hasBeenDisposed, false); + expect(state1.hasBeenDisposed, false); + // Child 2 does not have a key. + expect(state2.hasBeenDisposed, true); + }); + + testWidgets('Sliver complex case 2', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', key: GlobalKey(), keepAlive: true), + WidgetTest1(text: 'child 1', key: UniqueKey()), + WidgetTest2(text: 'child 2', keepAlive: true), + ]; + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 2'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + + childList = createSwitchedChildList(childList, 0, 1); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + final _WidgetTest1State state1 = tester.state(find.byType(WidgetTest1)); + expect(find.text('child 1'), findsOneWidget); + expect(find.text('child 2', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + + childList = createSwitchedChildList(childList, 1, 2); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + expect(find.text('child 1'), findsOneWidget); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 1); + await tester.pumpWidget(SwitchingChildListTest(children: childList)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + expect(state0.hasBeenDisposed, false); + expect(state1.hasBeenDisposed, true); + expect(state2.hasBeenDisposed, true); + }); + + testWidgets('Sliver with SliverChildBuilderDelegate', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', key: UniqueKey(), keepAlive: true), + WidgetTest1(text: 'child 1', key: GlobalKey()), + WidgetTest2(text: 'child 2', keepAlive: true), + ]; + await tester.pumpWidget(SwitchingChildBuilderTest(children: childList)); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget(SwitchingChildBuilderTest(children: childList)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 2'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + + childList = createSwitchedChildList(childList, 0, 1); + await tester.pumpWidget(SwitchingChildBuilderTest(children: childList)); + final _WidgetTest1State state1 = tester.state(find.byType(WidgetTest1)); + expect(find.text('child 1'), findsOneWidget); + expect(find.text('child 2', skipOffstage: false), findsNothing); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + + childList = createSwitchedChildList(childList, 1, 2); + await tester.pumpWidget(SwitchingChildBuilderTest(children: childList)); + expect(find.text('child 1'), findsOneWidget); + expect(find.text('child 0', skipOffstage: false), findsOneWidget); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + childList = createSwitchedChildList(childList, 0, 1); + await tester.pumpWidget(SwitchingChildBuilderTest(children: childList)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1', skipOffstage: false), findsNothing); + expect(find.text('child 2', skipOffstage: false), findsNothing); + + expect(state0.hasBeenDisposed, false); + expect(state1.hasBeenDisposed, true); + expect(state2.hasBeenDisposed, true); + }); + + testWidgets('SliverFillViewport should not dispose widget with key during in screen reordering', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', key: UniqueKey(), keepAlive: true), + WidgetTest1(text: 'child 1', key: UniqueKey()), + WidgetTest2(text: 'child 2', keepAlive: true), + ]; + await tester.pumpWidget( + SwitchingChildListTest(children: childList, viewportFraction: 0.1) + ); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + final _WidgetTest1State state1 = tester.state(find.byType(WidgetTest1)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1'), findsOneWidget); + expect(find.text('child 2'), findsOneWidget); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget( + SwitchingChildListTest(children: childList, viewportFraction: 0.1) + ); + + childList = createSwitchedChildList(childList, 0, 1); + await tester.pumpWidget( + SwitchingChildListTest(children: childList, viewportFraction: 0.1) + ); + + childList = createSwitchedChildList(childList, 1, 2); + await tester.pumpWidget( + SwitchingChildListTest(children: childList, viewportFraction: 0.1) + ); + + childList = createSwitchedChildList(childList, 0, 1); + await tester.pumpWidget( + SwitchingChildListTest(children: childList, viewportFraction: 0.1) + ); + + expect(state0.hasBeenDisposed, false); + expect(state1.hasBeenDisposed, false); + expect(state2.hasBeenDisposed, true); + }); + + testWidgets('SliverList should not dispose widget with key during in screen reordering', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', key: UniqueKey(), keepAlive: true), + WidgetTest1(text: 'child 1', keepAlive: true), + WidgetTest2(text: 'child 2', key: UniqueKey()), + ]; + await tester.pumpWidget( + SwitchingSliverListTest(children: childList, viewportFraction: 0.1) + ); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + final _WidgetTest1State state1 = tester.state(find.byType(WidgetTest1)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1'), findsOneWidget); + expect(find.text('child 2'), findsOneWidget); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget( + SwitchingSliverListTest(children: childList, viewportFraction: 0.1) + ); + + childList = createSwitchedChildList(childList, 1, 2); + await tester.pumpWidget( + SwitchingSliverListTest(children: childList, viewportFraction: 0.1) + ); + + childList = createSwitchedChildList(childList, 1, 2); + await tester.pumpWidget( + SwitchingSliverListTest(children: childList, viewportFraction: 0.1) + ); + + childList = createSwitchedChildList(childList, 0, 1); + await tester.pumpWidget( + SwitchingSliverListTest(children: childList, viewportFraction: 0.1) + ); + + childList = createSwitchedChildList(childList, 0, 2); + await tester.pumpWidget( + SwitchingSliverListTest(children: childList, viewportFraction: 0.1) + ); + + childList = createSwitchedChildList(childList, 0, 1); + await tester.pumpWidget( + SwitchingSliverListTest(children: childList, viewportFraction: 0.1) + ); + expect(state0.hasBeenDisposed, false); + expect(state1.hasBeenDisposed, true); + expect(state2.hasBeenDisposed, false); + }); + + testWidgets('SliverList remove child from child list', (WidgetTester tester) async { + List childList= [ + WidgetTest0(text: 'child 0', key: UniqueKey(), keepAlive: true), + WidgetTest1(text: 'child 1', keepAlive: true), + WidgetTest2(text: 'child 2', key: UniqueKey()), + ]; + await tester.pumpWidget( + SwitchingSliverListTest(children: childList, viewportFraction: 0.1) + ); + final _WidgetTest0State state0 = tester.state(find.byType(WidgetTest0)); + final _WidgetTest1State state1 = tester.state(find.byType(WidgetTest1)); + final _WidgetTest2State state2 = tester.state(find.byType(WidgetTest2)); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1'), findsOneWidget); + expect(find.text('child 2'), findsOneWidget); + + childList = createSwitchedChildList(childList, 0, 1); + childList.removeAt(2); + await tester.pumpWidget( + SwitchingSliverListTest(children: childList, viewportFraction: 0.1) + ); + expect(find.text('child 0'), findsOneWidget); + expect(find.text('child 1'), findsOneWidget); + expect(find.text('child 2'), findsNothing); + expect(state0.hasBeenDisposed, false); + expect(state1.hasBeenDisposed, true); + expect(state2.hasBeenDisposed, true); + }); +} + +List createSwitchedChildList(List childList, int i, int j) { + final Widget w = childList[i]; + childList[i] = childList[j]; + childList[j] = w; + return List.from(childList); +} + +class SwitchingChildBuilderTest extends StatefulWidget { + SwitchingChildBuilderTest({ + this.children, + Key key + }) : super(key: key); + + final List children; + + @override + _SwitchingChildBuilderTest createState() => _SwitchingChildBuilderTest(); +} + +class _SwitchingChildBuilderTest extends State { + List children; + Map _mapKeyToIndex; + + @override + void initState() { + super.initState(); + children = widget.children; + _mapKeyToIndex = {}; + for (int index = 0; index < children.length; index += 1) { + final Key key = children[index].key; + if (key != null) { + _mapKeyToIndex[key] = index; + } + } + } + + @override + void didUpdateWidget(SwitchingChildBuilderTest oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.children != widget.children) { + children = widget.children; + _mapKeyToIndex = {}; + for (int index = 0; index < children.length; index += 1) { + final Key key = children[index].key; + if (key != null) { + _mapKeyToIndex[key] = index; + } + } + } + } + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Container( + height: 100, + child: CustomScrollView( + cacheExtent: 0, + slivers: [ + SliverFillViewport( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return children[index]; + }, + childCount: children.length, + findChildIndexCallback: (Key key) { + return _mapKeyToIndex[key] == null ? -1 : _mapKeyToIndex[key]; + } + ), + ) + ], + ), + ), + ), + ); + } +} + +class SwitchingChildListTest extends StatefulWidget { + SwitchingChildListTest({ + this.children, + this.viewportFraction = 1.0, + Key key + }) : super(key: key); + + final List children; + final double viewportFraction; + + @override + _SwitchingChildListTest createState() => _SwitchingChildListTest(); +} + +class _SwitchingChildListTest extends State { + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Container( + height: 100, + child: CustomScrollView( + cacheExtent: 0, + slivers: [ + SliverFillViewport( + viewportFraction: widget.viewportFraction, + delegate: SliverChildListDelegate(widget.children), + ) + ], + ), + ), + ), + ); + } +} + +class SwitchingSliverListTest extends StatefulWidget { + SwitchingSliverListTest({ + this.children, + this.viewportFraction = 1.0, + Key key + }) : super(key: key); + + final List children; + final double viewportFraction; + + @override + _SwitchingSliverListTest createState() => _SwitchingSliverListTest(); +} + +class _SwitchingSliverListTest extends State { + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Container( + height: 100, + child: CustomScrollView( + cacheExtent: 0, + slivers: [ + SliverList( + delegate: SliverChildListDelegate(widget.children), + ) + ], + ), + ), + ), + ); + } +} + +class WidgetTest0 extends StatefulWidget { + WidgetTest0({ + this.text, + this.keepAlive = false, + Key key + }) : super(key: key); + + final String text; + final bool keepAlive; + + @override + _WidgetTest0State createState() => _WidgetTest0State(); +} + +class _WidgetTest0State extends State with AutomaticKeepAliveClientMixin{ + bool hasBeenDisposed = false; + + @override + Widget build(BuildContext context) { + super.build(context); + return Text(widget.text); + } + + @override + void dispose() { + hasBeenDisposed = true; + super.dispose(); + } + + @override + bool get wantKeepAlive => widget.keepAlive; +} + +class WidgetTest1 extends StatefulWidget { + WidgetTest1({ + this.text, + this.keepAlive = false, + Key key + }) : super(key: key); + + final String text; + final bool keepAlive; + + @override + _WidgetTest1State createState() => _WidgetTest1State(); +} + +class _WidgetTest1State extends State with AutomaticKeepAliveClientMixin{ + bool hasBeenDisposed = false; + + @override + Widget build(BuildContext context) { + super.build(context); + return Text(widget.text); + } + + @override + void dispose() { + hasBeenDisposed = true; + super.dispose(); + } + + @override + bool get wantKeepAlive => widget.keepAlive; +} + +class WidgetTest2 extends StatefulWidget { + WidgetTest2({ + this.text, + this.keepAlive = false, + Key key + }) : super(key: key); + + final String text; + final bool keepAlive; + + @override + _WidgetTest2State createState() => _WidgetTest2State(); +} + +class _WidgetTest2State extends State with AutomaticKeepAliveClientMixin{ + bool hasBeenDisposed = false; + + @override + Widget build(BuildContext context) { + super.build(context); + return Text(widget.text); + } + + @override + void dispose() { + hasBeenDisposed = true; + super.dispose(); + } + + @override + bool get wantKeepAlive => widget.keepAlive; +} diff --git a/packages/flutter/test/widgets/slivers_test.dart b/packages/flutter/test/widgets/slivers_test.dart index 91b8041209..ed5e21351b 100644 --- a/packages/flutter/test/widgets/slivers_test.dart +++ b/packages/flutter/test/widgets/slivers_test.dart @@ -206,7 +206,8 @@ void main() { addRepaintBoundaries: false, addSemanticIndexes: false, ); - expect(builderThrowsDelegate.build(null, 0), errorText); + final KeyedSubtree wrapped = builderThrowsDelegate.build(null, 0); + expect(wrapped.child, errorText); expect(tester.takeException(), 'builder'); ErrorWidget.builder = oldBuilder; });