Keep-alive for widgets in lazy lists (#11010)

diff --git a/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart b/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart
index 4177742..2b3eefb 100644
--- a/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart
+++ b/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart
@@ -130,8 +130,9 @@
       final int oldLastIndex = indexOf(lastChild);
       final int leadingGarbage = (firstIndex - oldFirstIndex).clamp(0, childCount);
       final int trailingGarbage = targetLastIndex == null ? 0 : (oldLastIndex - targetLastIndex).clamp(0, childCount);
-      if (leadingGarbage + trailingGarbage > 0)
-        collectGarbage(leadingGarbage, trailingGarbage);
+      collectGarbage(leadingGarbage, trailingGarbage);
+    } else {
+      collectGarbage(0, 0);
     }
 
     if (firstChild == null) {
diff --git a/packages/flutter/lib/src/rendering/sliver_grid.dart b/packages/flutter/lib/src/rendering/sliver_grid.dart
index 2bebc23..7fb0cd5 100644
--- a/packages/flutter/lib/src/rendering/sliver_grid.dart
+++ b/packages/flutter/lib/src/rendering/sliver_grid.dart
@@ -507,8 +507,9 @@
       final int oldLastIndex = indexOf(lastChild);
       final int leadingGarbage = (firstIndex - oldFirstIndex).clamp(0, childCount);
       final int trailingGarbage = targetLastIndex == null ? 0 : (oldLastIndex - targetLastIndex).clamp(0, childCount);
-      if (leadingGarbage + trailingGarbage > 0)
-        collectGarbage(leadingGarbage, trailingGarbage);
+      collectGarbage(leadingGarbage, trailingGarbage);
+    } else {
+      collectGarbage(0, 0);
     }
 
     final SliverGridGeometry firstChildGridGeometry = layout.getGeometryForChildIndex(firstIndex);
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 09a791e..0dff9b9 100644
--- a/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart
+++ b/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart
@@ -111,8 +111,15 @@
   /// The index of this child according to the [RenderSliverBoxChildManager].
   int index;
 
+  /// Whether to keep the child alive even when it is no longer visible.
+  bool keepAlive = false;
+
+  /// Whether the widget is currently in the
+  /// [RenderSliverMultiBoxAdaptor._keepAliveBucket].
+  bool _keptAlive = false;
+
   @override
-  String toString() => 'index=$index; ${super.toString()}';
+  String toString() => 'index=$index; ${keepAlive == true ? "keepAlive; " : ""}${super.toString()}';
 }
 
 /// A sliver with multiple box children.
@@ -168,10 +175,15 @@
   RenderSliverBoxChildManager get childManager => _childManager;
   final RenderSliverBoxChildManager _childManager;
 
+  /// The nodes being kept alive despite not being visible.
+  final Map<int, RenderBox> _keepAliveBucket = <int, RenderBox>{};
+
   @override
   void adoptChild(RenderObject child) {
     super.adoptChild(child);
-    childManager.didAdoptChild(child);
+    final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
+    if (!childParentData._keptAlive)
+      childManager.didAdoptChild(child);
   }
 
   bool _debugAssertChildListLocked() => childManager.debugAssertChildListLocked();
@@ -192,64 +204,139 @@
     });
   }
 
+  @override
+  void remove(RenderBox child) {
+    final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
+    if (!childParentData._keptAlive) {
+      super.remove(child);
+      return;
+    }
+    assert(_keepAliveBucket[childParentData.index] == child);
+    _keepAliveBucket.remove(childParentData.index);
+    dropChild(child);
+  }
+
+  @override
+  void removeAll() {
+    super.removeAll();
+    for (RenderBox child in _keepAliveBucket.values)
+      dropChild(child);
+    _keepAliveBucket.clear();
+  }
+
+  void _createOrObtainChild(int index, { RenderBox after }) {
+    invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
+      assert(constraints == this.constraints);
+      if (_keepAliveBucket.containsKey(index)) {
+        final RenderBox child = _keepAliveBucket.remove(index);
+        final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
+        assert(childParentData._keptAlive);
+        dropChild(child);
+        child.parentData = childParentData;
+        insert(child, after: after);
+        childParentData._keptAlive = false;
+      } else {
+        _childManager.createChild(index, after: after);
+      }
+    });
+  }
+
+  void _destroyOrCacheChild(RenderBox child) {
+    final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
+    if (childParentData.keepAlive) {
+      assert(!childParentData._keptAlive);
+      remove(child);
+      _keepAliveBucket[childParentData.index] = child;
+      child.parentData = childParentData;
+      super.adoptChild(child);
+      childParentData._keptAlive = true;
+    } else {
+      assert(child.parent == this);
+      _childManager.removeChild(child);
+      assert(child.parent == null);
+    }
+  }
+
+  @override
+  void attach(PipelineOwner owner) {
+    super.attach(owner);
+    for (RenderBox child in _keepAliveBucket.values)
+      child.attach(owner);
+  }
+
+  @override
+  void detach() {
+    super.detach();
+    for (RenderBox child in _keepAliveBucket.values)
+      child.detach();
+  }
+
+  @override
+  void redepthChildren() {
+    super.redepthChildren();
+    for (RenderBox child in _keepAliveBucket.values)
+      redepthChild(child);
+  }
+
+  @override
+  void visitChildren(RenderObjectVisitor visitor) {
+    super.visitChildren(visitor);
+    for (RenderBox child in _keepAliveBucket.values)
+      visitor(child);
+  }
+
   /// Called during layout to create and add the child with the given index and
   /// scroll offset.
   ///
   /// Calls [RenderSliverBoxChildManager.createChild] to actually create and add
-  /// the child.
+  /// the child if necessary. The child may instead be obtained from a cache;
+  /// see [SliverMultiBoxAdaptorParentData.keepAlive].
   ///
-  /// Returns false if createChild did not add any child, otherwise returns
-  /// true.
+  /// Returns false if there was no cached child and `createChild` did not add
+  /// any child, otherwise returns true.
   ///
   /// Does not layout the new child.
   ///
-  /// When this is called, there are no children, so no children can be removed
-  /// during the call to createChild. No child should be added during that call
-  /// either, except for the one that is created and returned by createChild.
+  /// When this is called, there are no visible children, so no children can be
+  /// removed during the call to `createChild`. No child should be added during
+  /// that call either, except for the one that is created and returned by
+  /// `createChild`.
   @protected
   bool addInitialChild({ int index: 0, double layoutOffset: 0.0 }) {
     assert(_debugAssertChildListLocked());
     assert(firstChild == null);
-    bool result;
-    invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
-      assert(constraints == this.constraints);
-      _childManager.createChild(index, after: null);
-      if (firstChild != null) {
-        assert(firstChild == lastChild);
-        assert(indexOf(firstChild) == index);
-        final SliverMultiBoxAdaptorParentData firstChildParentData = firstChild.parentData;
-        firstChildParentData.layoutOffset = layoutOffset;
-        result = true;
-      } else {
-        childManager.setDidUnderflow(true);
-        result = false;
-      }
-    });
-    return result;
+    _createOrObtainChild(index, after: null);
+    if (firstChild != null) {
+      assert(firstChild == lastChild);
+      assert(indexOf(firstChild) == index);
+      final SliverMultiBoxAdaptorParentData firstChildParentData = firstChild.parentData;
+      firstChildParentData.layoutOffset = layoutOffset;
+      return true;
+    }
+    childManager.setDidUnderflow(true);
+    return false;
   }
 
   /// Called during layout to create, add, and layout the child before
   /// [firstChild].
   ///
   /// Calls [RenderSliverBoxChildManager.createChild] to actually create and add
-  /// the child.
+  /// the child if necessary. The child may instead be obtained from a cache;
+  /// see [SliverMultiBoxAdaptorParentData.keepAlive].
   ///
-  /// Returns the new child or null if no child is created.
+  /// Returns the new child or null if no child was obtained.
   ///
   /// The child that was previously the first child, as well as any subsequent
   /// children, may be removed by this call if they have not yet been laid out
   /// during this layout pass. No child should be added during that call except
-  /// for the one that is created and returned by createChild.
+  /// for the one that is created and returned by `createChild`.
   @protected
   RenderBox insertAndLayoutLeadingChild(BoxConstraints childConstraints, {
     bool parentUsesSize: false,
   }) {
     assert(_debugAssertChildListLocked());
     final int index = indexOf(firstChild) - 1;
-    invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
-      assert(constraints == this.constraints);
-      _childManager.createChild(index, after: null);
-    });
+    _createOrObtainChild(index, after: null);
     if (indexOf(firstChild) == index) {
       firstChild.layout(childConstraints, parentUsesSize: parentUsesSize);
       return firstChild;
@@ -262,7 +349,8 @@
   /// the given child.
   ///
   /// Calls [RenderSliverBoxChildManager.createChild] to actually create and add
-  /// the child.
+  /// the child if necessary. The child may instead be obtained from a cache;
+  /// see [SliverMultiBoxAdaptorParentData.keepAlive].
   ///
   /// Returns the new child. It is the responsibility of the caller to configure
   /// the child's scroll offset.
@@ -277,13 +365,9 @@
     assert(_debugAssertChildListLocked());
     assert(after != null);
     final int index = indexOf(after) + 1;
-    invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
-      assert(constraints == this.constraints);
-      _childManager.createChild(index, after: after);
-    });
+    _createOrObtainChild(index, after: after);
     final RenderBox child = childAfter(after);
     if (child != null && indexOf(child) == index) {
-      assert(indexOf(child) == index);
       child.layout(childConstraints, parentUsesSize: parentUsesSize);
       return child;
     }
@@ -293,19 +377,37 @@
 
   /// Called after layout with the number of children that can be garbage
   /// collected at the head and tail of the child list.
+  ///
+  /// Children whose [SliverMultiBoxAdaptorParentData.keepAlive] property is
+  /// set to true will be removed to a cache instead of being dropped.
+  ///
+  /// This method also collects any children that were previously kept alive but
+  /// are now no longer necessary. As such, it should be called every time
+  /// [performLayout] is run, even if the arguments are both zero.
   @protected
   void collectGarbage(int leadingGarbage, int trailingGarbage) {
     assert(_debugAssertChildListLocked());
     assert(childCount >= leadingGarbage + trailingGarbage);
     invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
       while (leadingGarbage > 0) {
-        _childManager.removeChild(firstChild);
+        _destroyOrCacheChild(firstChild);
         leadingGarbage -= 1;
       }
       while (trailingGarbage > 0) {
-        _childManager.removeChild(lastChild);
+        _destroyOrCacheChild(lastChild);
         trailingGarbage -= 1;
       }
+      // Ask the child manager to remove the children that are no longer being
+      // kept alive. (This should cause _keepAliveBucket to change, so we have
+      // to prepare our list ahead of time.)
+      _keepAliveBucket.values.where((RenderBox child) {
+        final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
+        return !childParentData.keepAlive;
+      }).toList().forEach(_childManager.removeChild);
+      assert(_keepAliveBucket.values.where((RenderBox child) {
+        final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
+        return !childParentData.keepAlive;
+      }).isEmpty);
     });
   }
 
@@ -442,4 +544,42 @@
     });
     return true;
   }
+
+  @override
+  String debugDescribeChildren(String prefix) {
+    StringBuffer result;
+    if (firstChild != null) {
+      result = new StringBuffer()
+        ..write(prefix)
+        ..write(' \u2502\n');
+      RenderBox child = firstChild;
+      while (child != lastChild) {
+        final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
+        result.write(child.toStringDeep("$prefix \u251C\u2500child with index ${childParentData.index}: ", "$prefix \u2502"));
+        child = childParentData.nextSibling;
+      }
+      if (child != null) {
+        assert(child == lastChild);
+        final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
+        if (_keepAliveBucket.isEmpty) {
+          result.write(child.toStringDeep("$prefix \u2514\u2500child with index ${childParentData.index}: ", "$prefix  "));
+        } else {
+          result.write(child.toStringDeep("$prefix \u251C\u2500child with index ${childParentData.index}: ", "$prefix \u254E"));
+        }
+      }
+    }
+    if (_keepAliveBucket.isNotEmpty) {
+      result ??= new StringBuffer()
+        ..write(prefix)
+        ..write(' \u254E\n');
+      final List<int> indices = _keepAliveBucket.keys.toList()..sort();
+      final int lastIndex = indices.removeLast();
+      if (indices.isNotEmpty) {
+        for (int index in indices)
+          result.write(_keepAliveBucket[index].toStringDeep("$prefix \u251C\u2500child with index $index (kept alive offstage): ", "$prefix \u254E"));
+      }
+      result.write(_keepAliveBucket[lastIndex].toStringDeep("$prefix \u2514\u2500child with index $lastIndex (kept alive offstage): ", "$prefix  "));
+    }
+    return result?.toString() ?? '';
+  }
 }
diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart
index 372a845..77f6fa3 100644
--- a/packages/flutter/lib/src/widgets/basic.dart
+++ b/packages/flutter/lib/src/widgets/basic.dart
@@ -1201,8 +1201,9 @@
 
 /// Meta data for identifying children in a [CustomMultiChildLayout].
 ///
-/// The [MultiChildLayoutDelegate] hasChild, layoutChild, and positionChild
-/// methods use these identifiers.
+/// The [MultiChildLayoutDelegate.hasChild],
+/// [MultiChildLayoutDelegate.layoutChild], and
+/// [MultiChildLayoutDelegate.positionChild] methods use these identifiers.
 class LayoutId extends ParentDataWidget<CustomMultiChildLayout> {
   /// Marks a child with a layout identifier.
   ///
diff --git a/packages/flutter/lib/src/widgets/scroll_view.dart b/packages/flutter/lib/src/widgets/scroll_view.dart
index 09b5a36..e482b75 100644
--- a/packages/flutter/lib/src/widgets/scroll_view.dart
+++ b/packages/flutter/lib/src/widgets/scroll_view.dart
@@ -514,6 +514,10 @@
   ///
   /// It is usually more efficient to create children on demand using [new
   /// ListView.builder].
+  ///
+  /// The `addRepaintBoundaries` argument corresponds to the
+  /// [SliverChildListDelegate.addRepaintBoundaries] property and must not be
+  /// null.
   ListView({
     Key key,
     Axis scrollDirection: Axis.vertical,
@@ -524,8 +528,12 @@
     bool shrinkWrap: false,
     EdgeInsets padding,
     this.itemExtent,
+    bool addRepaintBoundaries: true,
     List<Widget> children: const <Widget>[],
-  }) : childrenDelegate = new SliverChildListDelegate(children), super(
+  }) : childrenDelegate = new SliverChildListDelegate(
+         children,
+         addRepaintBoundaries: addRepaintBoundaries,
+       ), super(
     key: key,
     scrollDirection: scrollDirection,
     reverse: reverse,
@@ -554,6 +562,10 @@
   /// [ListView] itself is created, it is more efficient to use [new ListView].
   /// Even more efficient, however, is to create the instances on demand using
   /// this constructor's `itemBuilder` callback.
+  ///
+  /// The `addRepaintBoundaries` argument corresponds to the
+  /// [SliverChildBuilderDelegate.addRepaintBoundaries] property and must not be
+  /// null.
   ListView.builder({
     Key key,
     Axis scrollDirection: Axis.vertical,
@@ -566,7 +578,12 @@
     this.itemExtent,
     @required IndexedWidgetBuilder itemBuilder,
     int itemCount,
-  }) : childrenDelegate = new SliverChildBuilderDelegate(itemBuilder, childCount: itemCount), super(
+    bool addRepaintBoundaries: true,
+  }) : childrenDelegate = new SliverChildBuilderDelegate(
+         itemBuilder,
+         childCount: itemCount,
+         addRepaintBoundaries: addRepaintBoundaries,
+       ), super(
     key: key,
     scrollDirection: scrollDirection,
     reverse: reverse,
@@ -765,6 +782,10 @@
   /// [SliverGridDelegate].
   ///
   /// The [gridDelegate] argument must not be null.
+  ///
+  /// The `addRepaintBoundaries` argument corresponds to the
+  /// [SliverChildListDelegate.addRepaintBoundaries] property and must not be
+  /// null.
   GridView({
     Key key,
     Axis scrollDirection: Axis.vertical,
@@ -775,9 +796,13 @@
     bool shrinkWrap: false,
     EdgeInsets padding,
     @required this.gridDelegate,
+    bool addRepaintBoundaries: true,
     List<Widget> children: const <Widget>[],
   }) : assert(gridDelegate != null),
-       childrenDelegate = new SliverChildListDelegate(children),
+       childrenDelegate = new SliverChildListDelegate(
+         children,
+         addRepaintBoundaries: addRepaintBoundaries,
+       ),
        super(
          key: key,
          scrollDirection: scrollDirection,
@@ -802,6 +827,10 @@
   /// zero and less than `itemCount`.
   ///
   /// The [gridDelegate] argument must not be null.
+  ///
+  /// The `addRepaintBoundaries` argument corresponds to the
+  /// [SliverChildBuilderDelegate.addRepaintBoundaries] property and must not be
+  /// null.
   GridView.builder({
     Key key,
     Axis scrollDirection: Axis.vertical,
@@ -814,8 +843,13 @@
     @required this.gridDelegate,
     @required IndexedWidgetBuilder itemBuilder,
     int itemCount,
+    bool addRepaintBoundaries: true,
   }) : assert(gridDelegate != null),
-       childrenDelegate = new SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
+       childrenDelegate = new SliverChildBuilderDelegate(
+         itemBuilder,
+         childCount: itemCount,
+         addRepaintBoundaries: addRepaintBoundaries,
+       ),
        super(
          key: key,
          scrollDirection: scrollDirection,
@@ -863,6 +897,10 @@
   ///
   /// Uses a [SliverGridDelegateWithFixedCrossAxisCount] as the [gridDelegate].
   ///
+  /// The `addRepaintBoundaries` argument corresponds to the
+  /// [SliverChildListDelegate.addRepaintBoundaries] property and must not be
+  /// null.
+  ///
   /// See also:
   ///
   ///  * [new SliverGrid.count], the equivalent constructor for [SliverGrid].
@@ -879,6 +917,7 @@
     double mainAxisSpacing: 0.0,
     double crossAxisSpacing: 0.0,
     double childAspectRatio: 1.0,
+    bool addRepaintBoundaries: true,
     List<Widget> children: const <Widget>[],
   }) : gridDelegate = new SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: crossAxisCount,
@@ -886,7 +925,10 @@
          crossAxisSpacing: crossAxisSpacing,
          childAspectRatio: childAspectRatio,
        ),
-       childrenDelegate = new SliverChildListDelegate(children), super(
+       childrenDelegate = new SliverChildListDelegate(
+         children,
+         addRepaintBoundaries: addRepaintBoundaries,
+       ), super(
     key: key,
     scrollDirection: scrollDirection,
     reverse: reverse,
@@ -902,6 +944,10 @@
   ///
   /// Uses a [SliverGridDelegateWithMaxCrossAxisExtent] as the [gridDelegate].
   ///
+  /// The `addRepaintBoundaries` argument corresponds to the
+  /// [SliverChildListDelegate.addRepaintBoundaries] property and must not be
+  /// null.
+  ///
   /// See also:
   ///
   ///  * [new SliverGrid.extent], the equivalent constructor for [SliverGrid].
@@ -918,6 +964,7 @@
     double mainAxisSpacing: 0.0,
     double crossAxisSpacing: 0.0,
     double childAspectRatio: 1.0,
+    bool addRepaintBoundaries: true,
     List<Widget> children: const <Widget>[],
   }) : gridDelegate = new SliverGridDelegateWithMaxCrossAxisExtent(
          maxCrossAxisExtent: maxCrossAxisExtent,
@@ -925,7 +972,10 @@
          crossAxisSpacing: crossAxisSpacing,
          childAspectRatio: childAspectRatio,
        ),
-       childrenDelegate = new SliverChildListDelegate(children), super(
+       childrenDelegate = new SliverChildListDelegate(
+         children,
+         addRepaintBoundaries: addRepaintBoundaries,
+       ), super(
     key: key,
     scrollDirection: scrollDirection,
     reverse: reverse,
diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart
index e5ece91..98b76fb 100644
--- a/packages/flutter/lib/src/widgets/sliver.dart
+++ b/packages/flutter/lib/src/widgets/sliver.dart
@@ -115,8 +115,11 @@
 ///
 /// Many slivers lazily construct their box children to avoid creating more
 /// children than are visible through the [Viewport]. This delegate provides
-/// children using an [IndexedWidgetBuilder] callback. The widgets returned from
-/// the builder callback are wrapped in [RepaintBoundary] widgets.
+/// children using an [IndexedWidgetBuilder] callback, so that the children do
+/// not even have to be built until they are displayed.
+///
+/// The widgets returned from the builder callback are automatically wrapped in
+/// [RepaintBoundary] widgets if [addRepaintBoundaries] is true (the default).
 ///
 /// See also:
 ///
@@ -124,8 +127,15 @@
 ///    of children.
 class SliverChildBuilderDelegate extends SliverChildDelegate {
   /// Creates a delegate that supplies children for slivers using the given
-  /// builder callback
-  const SliverChildBuilderDelegate(this.builder, { this.childCount });
+  /// builder callback.
+  ///
+  /// The [builder] and [addRepaintBoundaries] arguments must not be null.
+  const SliverChildBuilderDelegate(
+    this.builder, {
+    this.childCount,
+    this.addRepaintBoundaries: true,
+  }) : assert(builder != null),
+       assert(addRepaintBoundaries != null);
 
   /// Called to build children for the sliver.
   ///
@@ -145,6 +155,17 @@
   /// [builder] returns null.
   final int childCount;
 
+  /// Whether to wrap each child in a [RepaintBoundary].
+  ///
+  /// Typically, children in a scrolling container are wrapped in repaint
+  /// boundaries so that they do not need to be repainted as the list scrolls.
+  /// If the children are easy to repaint (e.g., solid color blocks or a short
+  /// snippet of text), it might be more efficient to not add a repaint boundary
+  /// and simply repaint the children during scrolling.
+  ///
+  /// Defaults to true.
+  final bool addRepaintBoundaries;
+
   @override
   Widget build(BuildContext context, int index) {
     assert(builder != null);
@@ -153,7 +174,7 @@
     final Widget child = builder(context, index);
     if (child == null)
       return null;
-    return new RepaintBoundary.wrap(child, index);
+    return addRepaintBoundaries ? new RepaintBoundary.wrap(child, index) : child;
   }
 
   @override
@@ -183,6 +204,9 @@
 /// demand). For example, the body of a dialog box might fit both of these
 /// conditions.
 ///
+/// The widgets in the given [children] list are automatically wrapped in
+/// [RepaintBoundary] widgets if [addRepaintBoundaries] is true (the default).
+///
 /// See also:
 ///
 ///  * [SliverChildBuilderDelegate], which is a delegate that uses a builder
@@ -190,7 +214,13 @@
 class SliverChildListDelegate extends SliverChildDelegate {
   /// Creates a delegate that supplies children for slivers using the given
   /// list.
-  const SliverChildListDelegate(this.children, { this.addRepaintBoundaries: true });
+  ///
+  /// The [children] and [addRepaintBoundaries] arguments must not be null.
+  const SliverChildListDelegate(
+    this.children, {
+    this.addRepaintBoundaries: true,
+  }) : assert(children != null),
+       assert(addRepaintBoundaries != null);
 
   /// Whether to wrap each child in a [RepaintBoundary].
   ///
@@ -815,3 +845,44 @@
   @override
   RenderSliverFillRemaining createRenderObject(BuildContext context) => new RenderSliverFillRemaining();
 }
+
+/// Mark a child as needing to stay alive even when it's in a lazy list that
+/// would otherwise remove it.
+///
+/// This widget is for use in [SliverMultiBoxAdaptorWidget]s, such as
+/// [SliverGrid] or [SliverList].
+class KeepAlive extends ParentDataWidget<SliverMultiBoxAdaptorWidget> {
+  /// Marks a child as needing to remain alive.
+  ///
+  /// The [child] and [keepAlive] arguments must not be null.
+  KeepAlive({
+    Key key,
+    @required this.keepAlive,
+    @required Widget child,
+  }) : assert(child != null),
+       assert(keepAlive != null),
+       super(key: key, child: child);
+
+  /// Whether to keep the child alive.
+  ///
+  /// If this is false, it is as if this widget was omitted.
+  final bool keepAlive;
+
+  @override
+  void applyParentData(RenderObject renderObject) {
+    assert(renderObject.parentData is SliverMultiBoxAdaptorParentData);
+    final SliverMultiBoxAdaptorParentData parentData = renderObject.parentData;
+    if (parentData.keepAlive != keepAlive) {
+      parentData.keepAlive = keepAlive;
+      final AbstractNode targetParent = renderObject.parent;
+      if (targetParent is RenderObject)
+        targetParent.markNeedsLayout();
+    }
+  }
+
+  @override
+  void debugFillDescription(List<String> description) {
+    super.debugFillDescription(description);
+    description.add('keepAlive: $keepAlive');
+  }
+}
diff --git a/packages/flutter/test/rendering/slivers_block_test.dart b/packages/flutter/test/rendering/slivers_block_test.dart
index 7752617..aee0b65 100644
--- a/packages/flutter/test/rendering/slivers_block_test.dart
+++ b/packages/flutter/test/rendering/slivers_block_test.dart
@@ -244,4 +244,23 @@
 
     expect(inner.geometry.scrollOffsetCorrection, isNull);
   });
+
+  test('SliverMultiBoxAdaptorParentData.toString', () {
+    final SliverMultiBoxAdaptorParentData candidate = new SliverMultiBoxAdaptorParentData();
+    expect(candidate.keepAlive, isFalse);
+    expect(candidate.index, isNull);
+    expect(candidate.toString(), 'index=null; layoutOffset=0.0');
+    candidate.keepAlive = null;
+    expect(candidate.toString(), 'index=null; layoutOffset=0.0');
+    candidate.keepAlive = true;
+    expect(candidate.toString(), 'index=null; keepAlive; layoutOffset=0.0');
+    candidate.keepAlive = false;
+    expect(candidate.toString(), 'index=null; layoutOffset=0.0');
+    candidate.index = 0;
+    expect(candidate.toString(), 'index=0; layoutOffset=0.0');
+    candidate.index = 1;
+    expect(candidate.toString(), 'index=1; layoutOffset=0.0');
+    candidate.index = -1;
+    expect(candidate.toString(), 'index=-1; layoutOffset=0.0');
+  });
 }
diff --git a/packages/flutter/test/widgets/keep_alive_test.dart b/packages/flutter/test/widgets/keep_alive_test.dart
new file mode 100644
index 0000000..c211c6d
--- /dev/null
+++ b/packages/flutter/test/widgets/keep_alive_test.dart
@@ -0,0 +1,561 @@
+// Copyright 2017 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 'dart:io' show Platform;
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter/widgets.dart';
+
+class Leaf extends StatefulWidget {
+  Leaf({ Key key, this.index, this.child }) : super(key: key);
+  final int index;
+  final Widget child;
+  @override
+  _LeafState createState() => new _LeafState();
+}
+
+class _LeafState extends State<Leaf> {
+  bool _keepAlive = false;
+
+  void setKeepAlive(bool value) {
+    setState(() { _keepAlive = value; });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return new KeepAlive(
+      keepAlive: _keepAlive,
+      child: widget.child,
+    );
+  }
+}
+
+List<Widget> generateList(Widget child) {
+  return new List<Widget>.generate(
+    100,
+    (int index) => new Leaf(
+      key: new GlobalObjectKey<_LeafState>(index),
+      index: index,
+      child: child,
+    ),
+    growable: false,
+  );
+}
+
+void main() {
+  testWidgets('KeepAlive with ListView with itemExtent', (WidgetTester tester) async {
+    await tester.pumpWidget(new ListView(
+      addRepaintBoundaries: false,
+      itemExtent: 12.3, // about 50 widgets visible
+      children: generateList(const Placeholder()),
+    ));
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+    await tester.drag(find.byType(ListView), const Offset(0.0, -300.0)); // about 25 widgets' worth
+    await tester.pump();
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+    const GlobalObjectKey<_LeafState>(60).currentState.setKeepAlive(true);
+    await tester.drag(find.byType(ListView), const Offset(0.0, 300.0)); // back to top
+    await tester.pump();
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+    const GlobalObjectKey<_LeafState>(60).currentState.setKeepAlive(false);
+    await tester.pump();
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+  });
+
+  testWidgets('KeepAlive with ListView without itemExtent', (WidgetTester tester) async {
+    await tester.pumpWidget(new ListView(
+      addRepaintBoundaries: false,
+      children: generateList(new Container(height: 12.3, child: const Placeholder())), // about 50 widgets visible
+    ));
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+    await tester.drag(find.byType(ListView), const Offset(0.0, -300.0)); // about 25 widgets' worth
+    await tester.pump();
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+    const GlobalObjectKey<_LeafState>(60).currentState.setKeepAlive(true);
+    await tester.drag(find.byType(ListView), const Offset(0.0, 300.0)); // back to top
+    await tester.pump();
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+    const GlobalObjectKey<_LeafState>(60).currentState.setKeepAlive(false);
+    await tester.pump();
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+  });
+
+  testWidgets('KeepAlive with GridView', (WidgetTester tester) async {
+    await tester.pumpWidget(new GridView.count(
+      addRepaintBoundaries: false,
+      crossAxisCount: 2,
+      childAspectRatio: 400.0 / 24.6, // about 50 widgets visible
+      children: generateList(new Container(child: const Placeholder())),
+    ));
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+    await tester.drag(find.byType(GridView), const Offset(0.0, -300.0)); // about 25 widgets' worth
+    await tester.pump();
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+    const GlobalObjectKey<_LeafState>(60).currentState.setKeepAlive(true);
+    await tester.drag(find.byType(GridView), const Offset(0.0, 300.0)); // back to top
+    await tester.pump();
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+    const GlobalObjectKey<_LeafState>(60).currentState.setKeepAlive(false);
+    await tester.pump();
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(3)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(30)), findsOneWidget);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(59)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(60)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(61)), findsNothing);
+    expect(find.byKey(const GlobalObjectKey<_LeafState>(90)), findsNothing);
+  });
+
+  testWidgets('KeepAlive render tree description', (WidgetTester tester) async {
+    await tester.pumpWidget(new ListView(
+      addRepaintBoundaries: false,
+      itemExtent: 400.0, // 2 visible children
+      children: generateList(const Placeholder()),
+    ));
+    // The important lines below are the ones marked with "<----"
+    expect(tester.binding.renderView.toStringDeep(), equalsIgnoringHashCodes(
+      'RenderView#00000\n'
+      ' │ debug mode enabled - ${Platform.operatingSystem}\n'
+      ' │ window size: Size(2400.0, 1800.0) (in physical pixels)\n'
+      ' │ device pixel ratio: 3.0 (physical pixels per logical pixel)\n'
+      ' │ configuration: Size(800.0, 600.0) at 3.0x (in logical pixels)\n'
+      ' │\n'
+      ' └─child: RenderRepaintBoundary#00000\n'
+      '   │ creator: RepaintBoundary ←\n'
+      '   │   NotificationListener<ScrollNotification> ←\n'
+      '   │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '   │ parentData: <none>\n'
+      '   │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '   │ layer: OffsetLayer#00000\n'
+      '   │ size: Size(800.0, 600.0)\n'
+      '   │ metrics: 0.0% useful (1 bad vs 0 good)\n'
+      '   │ diagnosis: insufficient data to draw conclusion (less than five\n'
+      '   │   repaints)\n'
+      '   │\n'
+      '   └─child: RenderCustomPaint#00000\n'
+      '     │ creator: CustomPaint ← RepaintBoundary ←\n'
+      '     │   NotificationListener<ScrollNotification> ←\n'
+      '     │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '     │ parentData: <none> (can use size)\n'
+      '     │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '     │ size: Size(800.0, 600.0)\n'
+      '     │\n'
+      '     └─child: RenderRepaintBoundary#00000\n'
+      '       │ creator: RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '       │   NotificationListener<ScrollNotification> ←\n'
+      '       │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '       │ parentData: <none> (can use size)\n'
+      '       │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '       │ layer: OffsetLayer#00000\n'
+      '       │ size: Size(800.0, 600.0)\n'
+      '       │ metrics: 0.0% useful (1 bad vs 0 good)\n'
+      '       │ diagnosis: insufficient data to draw conclusion (less than five\n'
+      '       │   repaints)\n'
+      '       │\n'
+      '       └─child: RenderSemanticsGestureHandler#00000\n'
+      '         │ creator: _GestureSemantics ←\n'
+      '         │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '         │   ← RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '         │   NotificationListener<ScrollNotification> ←\n'
+      '         │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '         │ parentData: <none> (can use size)\n'
+      '         │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '         │ semantic boundary\n'
+      '         │ size: Size(800.0, 600.0)\n'
+      '         │ gestures: horizontal scroll, vertical scroll\n'
+      '         │\n'
+      '         └─child: RenderPointerListener#00000\n'
+      '           │ creator: Listener ← _GestureSemantics ←\n'
+      '           │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '           │   ← RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '           │   NotificationListener<ScrollNotification> ←\n'
+      '           │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '           │ parentData: <none> (can use size)\n'
+      '           │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '           │ size: Size(800.0, 600.0)\n'
+      '           │ behavior: opaque\n'
+      '           │ listeners: down\n'
+      '           │\n'
+      '           └─child: RenderIgnorePointer#00000\n'
+      '             │ creator: IgnorePointer-[GlobalKey#00000] ← Listener ←\n'
+      '             │   _GestureSemantics ←\n'
+      '             │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '             │   ← RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '             │   NotificationListener<ScrollNotification> ←\n'
+      '             │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '             │ parentData: <none> (can use size)\n'
+      '             │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '             │ size: Size(800.0, 600.0)\n'
+      '             │ ignoring: false\n'
+      '             │ ignoringSemantics: implicitly false\n'
+      '             │\n'
+      '             └─child: RenderViewport#00000\n'
+      '               │ creator: Viewport ← _ScrollableScope ←\n'
+      '               │   IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '               │   ←\n'
+      '               │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '               │   ← RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '               │   NotificationListener<ScrollNotification> ←\n'
+      '               │   GlowingOverscrollIndicator ← Scrollable ← ⋯\n'
+      '               │ parentData: <none> (can use size)\n'
+      '               │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '               │ layer: OffsetLayer#00000\n'
+      '               │ size: Size(800.0, 600.0)\n'
+      '               │ AxisDirection.down\n'
+      '               │ offset: ScrollPositionWithSingleContext#00000(offset: 0.0, range:\n'
+      '               │   0.0..39400.0, viewport: 600.0, ScrollableState,\n'
+      '               │   AlwaysScrollableScrollPhysics -> ClampingScrollPhysics,\n'
+      '               │   IdleScrollActivity#00000, ScrollDirection.idle)\n'
+      '               │ anchor: 0.0\n'
+      '               │\n'
+      '               └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n'
+      '                 │ creator: SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                 │   IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                 │   ←\n'
+      '                 │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                 │   ← RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '                 │   NotificationListener<ScrollNotification> ←\n'
+      '                 │   GlowingOverscrollIndicator ← ⋯\n'
+      '                 │ parentData: paintOffset=Offset(0.0, 0.0) (can use size)\n'
+      '                 │ constraints: SliverConstraints(AxisDirection.down,\n'
+      '                 │   GrowthDirection.forward, ScrollDirection.idle, scrollOffset:\n'
+      '                 │   0.0, remainingPaintExtent: 600.0, crossAxisExtent: 800.0,\n'
+      '                 │   viewportMainAxisExtent: 600.0)\n'
+      '                 │ geometry: SliverGeometry(scrollExtent: 40000.0, paintExtent:\n'
+      '                 │   600.0, maxPaintExtent: 40000.0, hasVisualOverflow: true, )\n'
+      '                 │ currently live children: 0 to 1\n'
+      '                 │\n'
+      '                 ├─child with index 0: RenderLimitedBox#00000\n'
+      '                 │ │ creator: LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                 │ │   Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                 │ │   SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                 │ │   IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                 │ │   ←\n'
+      '                 │ │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                 │ │   ← RepaintBoundary ← ⋯\n'
+      '                 │ │ parentData: index=0; layoutOffset=0.0\n'
+      '                 │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                 │ │ size: Size(800.0, 400.0)\n'
+      '                 │ │ maxWidth: 400.0\n'
+      '                 │ │ maxHeight: 400.0\n'
+      '                 │ │\n'
+      '                 │ └─child: RenderCustomPaint#00000\n'
+      '                 │     creator: CustomPaint ← LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                 │       Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                 │       SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                 │       IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                 │       ←\n'
+      '                 │       RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                 │       ← ⋯\n'
+      '                 │     parentData: <none> (can use size)\n'
+      '                 │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                 │     size: Size(800.0, 400.0)\n'
+      '                 │\n'
+      '                 └─child with index 1: RenderLimitedBox#00000\n'                                       // <----- no dashed line starts here
+      '                   │ creator: LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                   │   Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                   │   SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                   │   IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                   │   ←\n'
+      '                   │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                   │   ← RepaintBoundary ← ⋯\n'
+      '                   │ parentData: index=1; layoutOffset=400.0\n'
+      '                   │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                   │ size: Size(800.0, 400.0)\n'
+      '                   │ maxWidth: 400.0\n'
+      '                   │ maxHeight: 400.0\n'
+      '                   │\n'
+      '                   └─child: RenderCustomPaint#00000\n'
+      '                       creator: CustomPaint ← LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                         Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                         SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                         IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                         ←\n'
+      '                         RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                         ← ⋯\n'
+      '                       parentData: <none> (can use size)\n'
+      '                       constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       size: Size(800.0, 400.0)\n'
+      '' // TODO(ianh): remove blank line
+    ));
+    const GlobalObjectKey<_LeafState>(0).currentState.setKeepAlive(true);
+    await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
+    await tester.pump();
+    const GlobalObjectKey<_LeafState>(3).currentState.setKeepAlive(true);
+    await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
+    await tester.pump();
+    expect(tester.binding.renderView.toStringDeep(), equalsIgnoringHashCodes(
+      'RenderView#00000\n'
+      ' │ debug mode enabled - ${Platform.operatingSystem}\n'
+      ' │ window size: Size(2400.0, 1800.0) (in physical pixels)\n'
+      ' │ device pixel ratio: 3.0 (physical pixels per logical pixel)\n'
+      ' │ configuration: Size(800.0, 600.0) at 3.0x (in logical pixels)\n'
+      ' │\n'
+      ' └─child: RenderRepaintBoundary#00000\n'
+      '   │ creator: RepaintBoundary ←\n'
+      '   │   NotificationListener<ScrollNotification> ←\n'
+      '   │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '   │ parentData: <none>\n'
+      '   │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '   │ layer: OffsetLayer#00000\n'
+      '   │ size: Size(800.0, 600.0)\n'
+      '   │ metrics: 0.0% useful (1 bad vs 0 good)\n'
+      '   │ diagnosis: insufficient data to draw conclusion (less than five\n'
+      '   │   repaints)\n'
+      '   │\n'
+      '   └─child: RenderCustomPaint#00000\n'
+      '     │ creator: CustomPaint ← RepaintBoundary ←\n'
+      '     │   NotificationListener<ScrollNotification> ←\n'
+      '     │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '     │ parentData: <none> (can use size)\n'
+      '     │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '     │ size: Size(800.0, 600.0)\n'
+      '     │\n'
+      '     └─child: RenderRepaintBoundary#00000\n'
+      '       │ creator: RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '       │   NotificationListener<ScrollNotification> ←\n'
+      '       │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '       │ parentData: <none> (can use size)\n'
+      '       │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '       │ layer: OffsetLayer#00000\n'
+      '       │ size: Size(800.0, 600.0)\n'
+      '       │ metrics: 0.0% useful (1 bad vs 0 good)\n'
+      '       │ diagnosis: insufficient data to draw conclusion (less than five\n'
+      '       │   repaints)\n'
+      '       │\n'
+      '       └─child: RenderSemanticsGestureHandler#00000\n'
+      '         │ creator: _GestureSemantics ←\n'
+      '         │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '         │   ← RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '         │   NotificationListener<ScrollNotification> ←\n'
+      '         │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '         │ parentData: <none> (can use size)\n'
+      '         │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '         │ semantic boundary\n'
+      '         │ size: Size(800.0, 600.0)\n'
+      '         │ gestures: horizontal scroll, vertical scroll\n'
+      '         │\n'
+      '         └─child: RenderPointerListener#00000\n'
+      '           │ creator: Listener ← _GestureSemantics ←\n'
+      '           │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '           │   ← RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '           │   NotificationListener<ScrollNotification> ←\n'
+      '           │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '           │ parentData: <none> (can use size)\n'
+      '           │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '           │ size: Size(800.0, 600.0)\n'
+      '           │ behavior: opaque\n'
+      '           │ listeners: down\n'
+      '           │\n'
+      '           └─child: RenderIgnorePointer#00000\n'
+      '             │ creator: IgnorePointer-[GlobalKey#00000] ← Listener ←\n'
+      '             │   _GestureSemantics ←\n'
+      '             │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '             │   ← RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '             │   NotificationListener<ScrollNotification> ←\n'
+      '             │   GlowingOverscrollIndicator ← Scrollable ← ListView ← [root]\n'
+      '             │ parentData: <none> (can use size)\n'
+      '             │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '             │ size: Size(800.0, 600.0)\n'
+      '             │ ignoring: false\n'
+      '             │ ignoringSemantics: implicitly false\n'
+      '             │\n'
+      '             └─child: RenderViewport#00000\n'
+      '               │ creator: Viewport ← _ScrollableScope ←\n'
+      '               │   IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '               │   ←\n'
+      '               │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '               │   ← RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '               │   NotificationListener<ScrollNotification> ←\n'
+      '               │   GlowingOverscrollIndicator ← Scrollable ← ⋯\n'
+      '               │ parentData: <none> (can use size)\n'
+      '               │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '               │ layer: OffsetLayer#00000\n'
+      '               │ size: Size(800.0, 600.0)\n'
+      '               │ AxisDirection.down\n'
+      '               │ offset: ScrollPositionWithSingleContext#00000(offset: 2000.0,\n'
+      '               │   range: 0.0..39400.0, viewport: 600.0, ScrollableState,\n'
+      '               │   AlwaysScrollableScrollPhysics -> ClampingScrollPhysics,\n'
+      '               │   IdleScrollActivity#00000, ScrollDirection.idle)\n'
+      '               │ anchor: 0.0\n'
+      '               │\n'
+      '               └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n'
+      '                 │ creator: SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                 │   IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                 │   ←\n'
+      '                 │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                 │   ← RepaintBoundary ← CustomPaint ← RepaintBoundary ←\n'
+      '                 │   NotificationListener<ScrollNotification> ←\n'
+      '                 │   GlowingOverscrollIndicator ← ⋯\n'
+      '                 │ parentData: paintOffset=Offset(0.0, 0.0) (can use size)\n'
+      '                 │ constraints: SliverConstraints(AxisDirection.down,\n'
+      '                 │   GrowthDirection.forward, ScrollDirection.idle, scrollOffset:\n'
+      '                 │   2000.0, remainingPaintExtent: 600.0, crossAxisExtent: 800.0,\n'
+      '                 │   viewportMainAxisExtent: 600.0)\n'
+      '                 │ geometry: SliverGeometry(scrollExtent: 40000.0, paintExtent:\n'
+      '                 │   600.0, maxPaintExtent: 40000.0, hasVisualOverflow: true, )\n'
+      '                 │ currently live children: 5 to 6\n'
+      '                 │\n'
+      '                 ├─child with index 5: RenderLimitedBox#00000\n'                                       // <----- this is index 5, not 0
+      '                 │ │ creator: LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                 │ │   Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                 │ │   SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                 │ │   IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                 │ │   ←\n'
+      '                 │ │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                 │ │   ← RepaintBoundary ← ⋯\n'
+      '                 │ │ parentData: index=5; layoutOffset=2000.0\n'
+      '                 │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                 │ │ size: Size(800.0, 400.0)\n'
+      '                 │ │ maxWidth: 400.0\n'
+      '                 │ │ maxHeight: 400.0\n'
+      '                 │ │\n'
+      '                 │ └─child: RenderCustomPaint#00000\n'
+      '                 │     creator: CustomPaint ← LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                 │       Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                 │       SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                 │       IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                 │       ←\n'
+      '                 │       RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                 │       ← ⋯\n'
+      '                 │     parentData: <none> (can use size)\n'
+      '                 │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                 │     size: Size(800.0, 400.0)\n'
+      '                 │\n'
+      '                 ├─child with index 6: RenderLimitedBox#00000\n'
+      '                 ╎ │ creator: LimitedBox ← Placeholder ← KeepAlive ←\n'                                // <----- the line starts becoming dashed here
+      '                 ╎ │   Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                 ╎ │   SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                 ╎ │   IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                 ╎ │   ←\n'
+      '                 ╎ │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                 ╎ │   ← RepaintBoundary ← ⋯\n'
+      '                 ╎ │ parentData: index=6; layoutOffset=2400.0\n'
+      '                 ╎ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                 ╎ │ size: Size(800.0, 400.0)\n'
+      '                 ╎ │ maxWidth: 400.0\n'
+      '                 ╎ │ maxHeight: 400.0\n'
+      '                 ╎ │\n'
+      '                 ╎ └─child: RenderCustomPaint#00000\n'
+      '                 ╎     creator: CustomPaint ← LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                 ╎       Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                 ╎       SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                 ╎       IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                 ╎       ←\n'
+      '                 ╎       RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                 ╎       ← ⋯\n'
+      '                 ╎     parentData: <none> (can use size)\n'
+      '                 ╎     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                 ╎     size: Size(800.0, 400.0)\n'
+      '                 ╎\n'
+      '                 ├─child with index 0 (kept alive offstage): RenderLimitedBox#00000\n'                 // <----- this one is index 0 and is marked as being offstage
+      '                 ╎ │ creator: LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                 ╎ │   Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                 ╎ │   SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                 ╎ │   IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                 ╎ │   ←\n'
+      '                 ╎ │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                 ╎ │   ← RepaintBoundary ← ⋯\n'
+      '                 ╎ │ parentData: index=0; keepAlive; layoutOffset=0.0\n'
+      '                 ╎ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                 ╎ │ size: Size(800.0, 400.0)\n'
+      '                 ╎ │ maxWidth: 400.0\n'
+      '                 ╎ │ maxHeight: 400.0\n'
+      '                 ╎ │\n'
+      '                 ╎ └─child: RenderCustomPaint#00000\n'
+      '                 ╎     creator: CustomPaint ← LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                 ╎       Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                 ╎       SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                 ╎       IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                 ╎       ←\n'
+      '                 ╎       RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                 ╎       ← ⋯\n'
+      '                 ╎     parentData: <none> (can use size)\n'
+      '                 ╎     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                 ╎     size: Size(800.0, 400.0)\n'
+      '                 ╎\n'                                                                                  // <----- dashed line ends here
+      '                 └─child with index 3 (kept alive offstage): RenderLimitedBox#00000\n'
+      '                   │ creator: LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                   │   Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                   │   SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                   │   IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                   │   ←\n'
+      '                   │   RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                   │   ← RepaintBoundary ← ⋯\n'
+      '                   │ parentData: index=3; keepAlive; layoutOffset=1200.0\n'
+      '                   │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                   │ size: Size(800.0, 400.0)\n'
+      '                   │ maxWidth: 400.0\n'
+      '                   │ maxHeight: 400.0\n'
+      '                   │\n'
+      '                   └─child: RenderCustomPaint#00000\n'
+      '                       creator: CustomPaint ← LimitedBox ← Placeholder ← KeepAlive ←\n'
+      '                         Leaf-[GlobalObjectKey<_LeafState> int#00000] ←\n'
+      '                         SliverFixedExtentList ← Viewport ← _ScrollableScope ←\n'
+      '                         IgnorePointer-[GlobalKey#00000] ← Listener ← _GestureSemantics\n'
+      '                         ←\n'
+      '                         RawGestureDetector-[LabeledGlobalKey<RawGestureDetectorState>#00000]\n'
+      '                         ← ⋯\n'
+      '                       parentData: <none> (can use size)\n'
+      '                       constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       size: Size(800.0, 400.0)\n'
+      '' // TODO(ianh): remove blank line
+    ));
+  });
+
+}
diff --git a/packages/flutter/test/widgets/list_view_viewporting_test.dart b/packages/flutter/test/widgets/list_view_viewporting_test.dart
index 4c8a997..05bd5c5 100644
--- a/packages/flutter/test/widgets/list_view_viewporting_test.dart
+++ b/packages/flutter/test/widgets/list_view_viewporting_test.dart
@@ -295,7 +295,7 @@
         ' │   maxPaintExtent: 300.0, )\n'
         ' │ currently live children: 0 to 2\n'
         ' │\n'
-        ' ├─child 1: RenderRepaintBoundary#00000 relayoutBoundary=up2\n'
+        ' ├─child with index 0: RenderRepaintBoundary#00000 relayoutBoundary=up2\n'
         ' │ │ creator: RepaintBoundary-[<0>] ← SliverList ← Viewport ←\n'
         ' │ │   _ScrollableScope ← IgnorePointer-[GlobalKey#00000] ← Listener ←\n'
         ' │ │   _GestureSemantics ←\n'
@@ -347,7 +347,7 @@
         ' │         size: Size(800.0, 100.0)\n'
         ' │         additionalConstraints: BoxConstraints(biggest)\n'
         ' │\n'
-        ' ├─child 2: RenderRepaintBoundary#00000 relayoutBoundary=up2\n'
+        ' ├─child with index 1: RenderRepaintBoundary#00000 relayoutBoundary=up2\n'
         ' │ │ creator: RepaintBoundary-[<1>] ← SliverList ← Viewport ←\n'
         ' │ │   _ScrollableScope ← IgnorePointer-[GlobalKey#00000] ← Listener ←\n'
         ' │ │   _GestureSemantics ←\n'
@@ -399,7 +399,7 @@
         ' │         size: Size(800.0, 100.0)\n'
         ' │         additionalConstraints: BoxConstraints(biggest)\n'
         ' │\n'
-        ' └─child 3: RenderRepaintBoundary#00000 relayoutBoundary=up2\n'
+        ' └─child with index 2: RenderRepaintBoundary#00000 relayoutBoundary=up2\n'
         '   │ creator: RepaintBoundary-[<2>] ← SliverList ← Viewport ←\n'
         '   │   _ScrollableScope ← IgnorePointer-[GlobalKey#00000] ← Listener ←\n'
         '   │   _GestureSemantics ←\n'
diff --git a/packages/flutter/test/widgets/sliver_fill_viewport_test.dart b/packages/flutter/test/widgets/sliver_fill_viewport_test.dart
index 9b9b8db..dbd8c50 100644
--- a/packages/flutter/test/widgets/sliver_fill_viewport_test.dart
+++ b/packages/flutter/test/widgets/sliver_fill_viewport_test.dart
@@ -80,7 +80,7 @@
         ' │   600.0, maxPaintExtent: 12000.0, hasVisualOverflow: true, )\n'
         ' │ currently live children: 0 to 0\n'
         ' │\n'
-        ' └─child 1: RenderRepaintBoundary#00000\n'
+        ' └─child with index 0: RenderRepaintBoundary#00000\n'
         '   │ creator: RepaintBoundary-[<0>] ← SliverFillViewport ← Viewport ←\n'
         '   │   _ScrollableScope ← IgnorePointer-[GlobalKey#00000] ← Listener ←\n'
         '   │   _GestureSemantics ←\n'