From 7dbd586e04aed6510dfa26b526ef49410d51ff92 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 26 Feb 2021 22:10:19 -0800 Subject: [PATCH] Reland "ListTile Material Ripple and Shape Patch (#74373)" (#76892) This reverts commit f8cd24d in an attempt to re-land it. There are no changes in this PR from #74373, since it only failed Google internal tests, and we think that the solution involves updating those tests instead of changing this code. --- .../lib/src/material/ink_decoration.dart | 27 +-- .../flutter/lib/src/material/list_tile.dart | 19 +- .../rendering/sliver_multi_box_adaptor.dart | 8 +- .../material/checkbox_list_tile_test.dart | 20 +- .../flutter/test/material/list_tile_test.dart | 186 +++++++++++++----- .../test/material/radio_list_tile_test.dart | 21 +- .../test/material/switch_list_tile_test.dart | 18 +- .../sliver_prototype_item_extent_test.dart | 19 +- 8 files changed, 213 insertions(+), 105 deletions(-) diff --git a/packages/flutter/lib/src/material/ink_decoration.dart b/packages/flutter/lib/src/material/ink_decoration.dart index 4a5d577d75..7a760bd9fd 100644 --- a/packages/flutter/lib/src/material/ink_decoration.dart +++ b/packages/flutter/lib/src/material/ink_decoration.dart @@ -215,13 +215,13 @@ class Ink extends StatefulWidget { /// any [padding]. final double? height; - EdgeInsetsGeometry? get _paddingIncludingDecoration { + EdgeInsetsGeometry get _paddingIncludingDecoration { if (decoration == null || decoration!.padding == null) - return padding; - final EdgeInsetsGeometry? decorationPadding = decoration!.padding; + return padding ?? EdgeInsets.zero; + final EdgeInsetsGeometry decorationPadding = decoration!.padding!; if (padding == null) return decorationPadding; - return padding!.add(decorationPadding!); + return padding!.add(decorationPadding); } @override @@ -236,6 +236,7 @@ class Ink extends StatefulWidget { } class _InkState extends State { + final GlobalKey _boxKey = GlobalKey(); InkDecoration? _ink; void _handleRemoved() { @@ -249,31 +250,31 @@ class _InkState extends State { super.deactivate(); } - Widget _build(BuildContext context, BoxConstraints constraints) { + Widget _build(BuildContext context) { + // By creating the InkDecoration from within a Builder widget, we can + // use the RenderBox of the Padding widget. if (_ink == null) { _ink = InkDecoration( decoration: widget.decoration, configuration: createLocalImageConfiguration(context), controller: Material.of(context)!, - referenceBox: context.findRenderObject()! as RenderBox, + referenceBox: _boxKey.currentContext!.findRenderObject()! as RenderBox, onRemoved: _handleRemoved, ); } else { _ink!.decoration = widget.decoration; _ink!.configuration = createLocalImageConfiguration(context); } - Widget? current = widget.child; - final EdgeInsetsGeometry? effectivePadding = widget._paddingIncludingDecoration; - if (effectivePadding != null) - current = Padding(padding: effectivePadding, child: current); - return current ?? Container(); + return widget.child ?? Container(); } @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); - Widget result = LayoutBuilder( - builder: _build, + Widget result = Padding( + key: _boxKey, + padding: widget._paddingIncludingDecoration, + child: Builder(builder: _build), ); if (widget.width != null || widget.height != null) { result = SizedBox( diff --git a/packages/flutter/lib/src/material/list_tile.dart b/packages/flutter/lib/src/material/list_tile.dart index 6dab56cc39..52dc65b6a5 100644 --- a/packages/flutter/lib/src/material/list_tile.dart +++ b/packages/flutter/lib/src/material/list_tile.dart @@ -11,6 +11,7 @@ import 'colors.dart'; import 'constants.dart'; import 'debug.dart'; import 'divider.dart'; +import 'ink_decoration.dart'; import 'ink_well.dart'; import 'material_state.dart'; import 'theme.dart'; @@ -108,7 +109,7 @@ class ListTileTheme extends InheritedTheme { final bool dense; /// {@template flutter.material.ListTileTheme.shape} - /// If specified, [shape] defines the shape of the [ListTile]'s [InkWell] border. + /// If specified, [shape] defines the [ListTile]'s shape. /// {@endtemplate} final ShapeBorder? shape; @@ -837,13 +838,12 @@ class ListTile extends StatelessWidget { /// widgets within a [Theme]. final VisualDensity? visualDensity; - /// The shape of the tile's [InkWell]. + /// The tile's shape. /// - /// Defines the tile's [InkWell.customBorder]. + /// Defines the tile's [InkWell.customBorder] and [Ink.decoration] shape. /// - /// If this property is null then [CardTheme.shape] of [ThemeData.cardTheme] - /// is used. If that's null then the shape will be a [RoundedRectangleBorder] - /// with a circular corner radius of 4.0. + /// If this property is null then [ListTileTheme.shape] is used. + /// If that's null then a rectangular [Border] will be used. final ShapeBorder? shape; /// The tile's internal padding. @@ -1185,8 +1185,11 @@ class ListTile extends StatelessWidget { child: Semantics( selected: selected, enabled: enabled, - child: ColoredBox( - color: _tileBackgroundColor(tileTheme), + child: Ink( + decoration: ShapeDecoration( + shape: shape ?? tileTheme.shape ?? const Border(), + color: _tileBackgroundColor(tileTheme), + ), child: SafeArea( top: false, bottom: false, 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 7bb575ad92..f0dfe0c0e1 100644 --- a/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart +++ b/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart @@ -577,7 +577,13 @@ abstract class RenderSliverMultiBoxAdaptor extends RenderSliver @override void applyPaintTransform(RenderBox child, Matrix4 transform) { - if (_keepAliveBucket.containsKey(indexOf(child))) { + final SliverMultiBoxAdaptorParentData childParentData = child.parentData! as SliverMultiBoxAdaptorParentData; + if (childParentData.index == null) { + // If the child has no index, such as with the prototype of a + // SliverPrototypeExtentList, then it is not visible, so we give it a + // zero transform to prevent it from painting. + transform.setZero(); + } else if (_keepAliveBucket.containsKey(childParentData.index)) { // It is possible that widgets under kept alive children want to paint // themselves. For example, the Material widget tries to paint all // InkFeatures under its subtree as long as they are not disposed. In diff --git a/packages/flutter/test/material/checkbox_list_tile_test.dart b/packages/flutter/test/material/checkbox_list_tile_test.dart index 2985710878..f476c59264 100644 --- a/packages/flutter/test/material/checkbox_list_tile_test.dart +++ b/packages/flutter/test/material/checkbox_list_tile_test.dart @@ -134,7 +134,7 @@ void main() { ) ); - final Rect paddingRect = tester.getRect(find.byType(Padding)); + final Rect paddingRect = tester.getRect(find.byType(SafeArea)); final Rect checkboxRect = tester.getRect(find.byType(Checkbox)); final Rect titleRect = tester.getRect(find.text('Title')); @@ -246,35 +246,34 @@ void main() { }); testWidgets('CheckboxListTile respects tileColor', (WidgetTester tester) async { - const Color tileColor = Colors.black; + final Color tileColor = Colors.red.shade500; await tester.pumpWidget( wrap( - child: const Center( + child: Center( child: CheckboxListTile( value: false, onChanged: null, - title: Text('Title'), + title: const Text('Title'), tileColor: tileColor, ), ), ), ); - final ColoredBox coloredBox = tester.firstWidget(find.byType(ColoredBox)); - expect(coloredBox.color, equals(tileColor)); + expect(find.byType(Material), paints..path(color: tileColor)); }); testWidgets('CheckboxListTile respects selectedTileColor', (WidgetTester tester) async { - const Color selectedTileColor = Colors.black; + final Color selectedTileColor = Colors.green.shade500; await tester.pumpWidget( wrap( - child: const Center( + child: Center( child: CheckboxListTile( value: false, onChanged: null, - title: Text('Title'), + title: const Text('Title'), selected: true, selectedTileColor: selectedTileColor, ), @@ -282,7 +281,6 @@ void main() { ), ); - final ColoredBox coloredBox = tester.firstWidget(find.byType(ColoredBox)); - expect(coloredBox.color, equals(selectedTileColor)); + expect(find.byType(Material), paints..path(color: selectedTileColor)); }); } diff --git a/packages/flutter/test/material/list_tile_test.dart b/packages/flutter/test/material/list_tile_test.dart index 5a10e0d189..156de730a8 100644 --- a/packages/flutter/test/material/list_tile_test.dart +++ b/packages/flutter/test/material/list_tile_test.dart @@ -1254,7 +1254,6 @@ void main() { testWidgets('ListTile is focusable and has correct focus color', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(debugLabel: 'ListTile'); tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; - const Key tileKey = Key('listTile'); Widget buildApp({bool enabled = true}) { return MaterialApp( home: Material( @@ -1265,7 +1264,6 @@ void main() { height: 100, color: Colors.white, child: ListTile( - key: tileKey, onTap: enabled ? () {} : null, focusColor: Colors.orange[500], autofocus: true, @@ -1282,16 +1280,14 @@ void main() { await tester.pumpAndSettle(); expect(focusNode.hasPrimaryFocus, isTrue); expect( - Material.of(tester.element(find.byKey(tileKey))), + find.byType(Material), paints ..rect( - color: Colors.orange[500], - rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), - ) + color: Colors.orange[500], + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)) ..rect( - color: const Color(0xffffffff), - rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), - ), + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)), ); // Check when the list tile is disabled. @@ -1299,7 +1295,7 @@ void main() { await tester.pumpAndSettle(); expect(focusNode.hasPrimaryFocus, isFalse); expect( - Material.of(tester.element(find.byKey(tileKey))), + find.byType(Material), paints ..rect( color: const Color(0xffffffff), @@ -1309,7 +1305,6 @@ void main() { testWidgets('ListTile can be hovered and has correct hover color', (WidgetTester tester) async { tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; - const Key tileKey = Key('ListTile'); Widget buildApp({bool enabled = true}) { return MaterialApp( home: Material( @@ -1320,7 +1315,6 @@ void main() { height: 100, color: Colors.white, child: ListTile( - key: tileKey, onTap: enabled ? () {} : null, hoverColor: Colors.orange[500], autofocus: true, @@ -1336,7 +1330,7 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); expect( - Material.of(tester.element(find.byKey(tileKey))), + find.byType(Material), paints ..rect( color: const Color(0x1f000000), @@ -1349,30 +1343,30 @@ void main() { // Start hovering final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(find.byKey(tileKey))); + await gesture.moveTo(tester.getCenter(find.byType(ListTile))); await tester.pumpWidget(buildApp()); await tester.pump(); await tester.pumpAndSettle(); expect( - Material.of(tester.element(find.byKey(tileKey))), - paints - ..rect( - color: const Color(0x1f000000), - rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)) - ..rect( - color: Colors.orange[500], - rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)) - ..rect( - color: const Color(0xffffffff), - rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)), + find.byType(Material), + paints + ..rect( + color: const Color(0x1f000000), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)) + ..rect( + color: Colors.orange[500], + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)) + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)), ); await tester.pumpWidget(buildApp(enabled: false)); await tester.pump(); await tester.pumpAndSettle(); expect( - Material.of(tester.element(find.byKey(tileKey))), + find.byType(Material), paints ..rect( color: Colors.orange[500], @@ -1459,6 +1453,75 @@ void main() { expect(box.size, equals(const Size(800, 44))); }); + testWidgets('ListTile shape is painted correctly', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/63877 + const ShapeBorder rectShape = RoundedRectangleBorder(); + const ShapeBorder stadiumShape = StadiumBorder(); + final Color tileColor = Colors.red.shade500; + + Widget buildListTile(ShapeBorder shape) { + return MaterialApp( + home: Material( + child: Center( + child: ListTile(shape: shape, tileColor: tileColor), + ), + ), + ); + } + + // Test rectangle shape + await tester.pumpWidget(buildListTile(rectShape)); + Rect rect = tester.getRect(find.byType(ListTile)); + + // Check if a path was painted with the correct color and shape + expect( + find.byType(Material), + paints..path( + color: tileColor, + // Corners should be included + includes: [ + Offset(rect.left, rect.top), + Offset(rect.right, rect.top), + Offset(rect.left, rect.bottom), + Offset(rect.right, rect.bottom), + ], + // Points outside rect should be excluded + excludes: [ + Offset(rect.left - 1, rect.top - 1), + Offset(rect.right + 1, rect.top - 1), + Offset(rect.left - 1, rect.bottom + 1), + Offset(rect.right + 1, rect.bottom + 1), + ], + ), + ); + + // Test stadium shape + await tester.pumpWidget(buildListTile(stadiumShape)); + rect = tester.getRect(find.byType(ListTile)); + + // Check if a path was painted with the correct color and shape + expect( + find.byType(Material), + paints..path( + color: tileColor, + // Center points of sides should be included + includes: [ + Offset(rect.left + rect.width / 2, rect.top), + Offset(rect.left, rect.top + rect.height / 2), + Offset(rect.right, rect.top + rect.height / 2), + Offset(rect.left + rect.width / 2, rect.bottom), + ], + // Corners should be excluded + excludes: [ + Offset(rect.left, rect.top), + Offset(rect.right, rect.top), + Offset(rect.left, rect.bottom), + Offset(rect.right, rect.bottom), + ], + ), + ); + }); + testWidgets('ListTile changes mouse cursor when hovered', (WidgetTester tester) async { // Test ListTile() constructor await tester.pumpWidget( @@ -1540,8 +1603,8 @@ void main() { testWidgets('ListTile respects tileColor & selectedTileColor', (WidgetTester tester) async { bool isSelected = false; - const Color selectedTileColor = Colors.red; - const Color tileColor = Colors.green; + final Color tileColor = Colors.green.shade500; + final Color selectedTileColor = Colors.red.shade500; await tester.pumpWidget( MaterialApp( @@ -1566,16 +1629,48 @@ void main() { ); // Initially, when isSelected is false, the ListTile should respect tileColor. - ColoredBox coloredBox = tester.widget(find.byType(ColoredBox)); - expect(coloredBox.color, tileColor); + expect(find.byType(Material), paints..path(color: tileColor)); // Tap on tile to change isSelected. await tester.tap(find.byType(ListTile)); await tester.pumpAndSettle(); // When isSelected is true, the ListTile should respect selectedTileColor. - coloredBox = tester.widget(find.byType(ColoredBox)); - expect(coloredBox.color, selectedTileColor); + expect(find.byType(Material), paints..path(color: selectedTileColor)); + }); + + testWidgets('ListTile shows Material ripple effects on top of tileColor', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/73616 + final Color tileColor = Colors.red.shade500; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: ListTile( + tileColor: tileColor, + onTap: () {}, + title: const Text('Title'), + ), + ), + ), + ), + ); + + // Before ListTile is tapped, it should be tileColor + expect(find.byType(Material), paints..path(color: tileColor)); + + // Tap on tile to trigger ink effect and wait for it to be underway. + await tester.tap(find.byType(ListTile)); + await tester.pump(const Duration(milliseconds: 200)); + + // After tap, the tile could be drawn in tileColor, with the ripple (circle) on top + expect( + find.byType(Material), + paints + ..path(color: tileColor) + ..circle(), + ); }); testWidgets('ListTile default tile color', (WidgetTester tester) async { @@ -1602,16 +1697,13 @@ void main() { ), ); - ColoredBox coloredBox = tester.widget(find.byType(ColoredBox)); - expect(coloredBox.color, defaultColor); + expect(find.byType(Material), paints..path(color: defaultColor)); // Tap on tile to change isSelected. await tester.tap(find.byType(ListTile)); await tester.pumpAndSettle(); - coloredBox = tester.widget(find.byType(ColoredBox)); - expect(isSelected, isTrue); - expect(coloredBox.color, defaultColor); + expect(find.byType(Material), paints..path(color: defaultColor)); }); testWidgets('ListTile respects ListTileTheme\'s tileColor & selectedTileColor', (WidgetTester tester) async { @@ -1622,8 +1714,8 @@ void main() { MaterialApp( home: Material( child: ListTileTheme( - selectedTileColor: Colors.green, - tileColor: Colors.red, + tileColor: Colors.green.shade500, + selectedTileColor: Colors.red.shade500, child: Center( child: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { @@ -1643,21 +1735,19 @@ void main() { ), ); - ColoredBox coloredBox = tester.widget(find.byType(ColoredBox)); - expect(coloredBox.color, theme.tileColor); + expect(find.byType(Material), paints..path(color: theme.tileColor)); // Tap on tile to change isSelected. await tester.tap(find.byType(ListTile)); await tester.pumpAndSettle(); - coloredBox = tester.widget(find.byType(ColoredBox)); - expect(coloredBox.color, theme.selectedTileColor); + expect(find.byType(Material), paints..path(color: theme.selectedTileColor)); }); testWidgets('ListTileTheme\'s tileColor & selectedTileColor are overridden by ListTile properties', (WidgetTester tester) async { bool isSelected = false; - const Color tileColor = Colors.brown; - const Color selectedTileColor = Colors.purple; + final Color tileColor = Colors.green.shade500; + final Color selectedTileColor = Colors.red.shade500; await tester.pumpWidget( MaterialApp( @@ -1685,15 +1775,13 @@ void main() { ), ); - ColoredBox coloredBox = tester.widget(find.byType(ColoredBox)); - expect(coloredBox.color, tileColor); + expect(find.byType(Material), paints..path(color: tileColor)); // Tap on tile to change isSelected. await tester.tap(find.byType(ListTile)); await tester.pumpAndSettle(); - coloredBox = tester.widget(find.byType(ColoredBox)); - expect(coloredBox.color, selectedTileColor); + expect(find.byType(Material), paints..path(color: selectedTileColor)); }); testWidgets('ListTile layout at zero size', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/radio_list_tile_test.dart b/packages/flutter/test/material/radio_list_tile_test.dart index 403ac1c660..1b411a4c07 100644 --- a/packages/flutter/test/material/radio_list_tile_test.dart +++ b/packages/flutter/test/material/radio_list_tile_test.dart @@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; +import '../rendering/mock_canvas.dart'; import '../widgets/semantics_tester.dart'; @@ -627,7 +628,7 @@ void main() { ) ); - final Rect paddingRect = tester.getRect(find.byType(Padding)); + final Rect paddingRect = tester.getRect(find.byType(SafeArea)); final Rect radioRect = tester.getRect(find.byType(radioType)); final Rect titleRect = tester.getRect(find.text('Title')); @@ -667,37 +668,36 @@ void main() { }); testWidgets('RadioListTile respects tileColor', (WidgetTester tester) async { - const Color tileColor = Colors.red; + final Color tileColor = Colors.red.shade500; await tester.pumpWidget( wrap( - child: const Center( + child: Center( child: RadioListTile( value: false, groupValue: true, onChanged: null, - title: Text('Title'), + title: const Text('Title'), tileColor: tileColor, ), ), ), ); - final ColoredBox coloredBox = tester.firstWidget(find.byType(ColoredBox)); - expect(coloredBox.color, tileColor); + expect(find.byType(Material), paints..path(color: tileColor)); }); testWidgets('RadioListTile respects selectedTileColor', (WidgetTester tester) async { - const Color selectedTileColor = Colors.black; + final Color selectedTileColor = Colors.green.shade500; await tester.pumpWidget( wrap( - child: const Center( + child: Center( child: RadioListTile( value: false, groupValue: true, onChanged: null, - title: Text('Title'), + title: const Text('Title'), selected: true, selectedTileColor: selectedTileColor, ), @@ -705,7 +705,6 @@ void main() { ), ); - final ColoredBox coloredBox = tester.firstWidget(find.byType(ColoredBox)); - expect(coloredBox.color, equals(selectedTileColor)); + expect(find.byType(Material), paints..path(color: selectedTileColor)); }); } diff --git a/packages/flutter/test/material/switch_list_tile_test.dart b/packages/flutter/test/material/switch_list_tile_test.dart index 4c7a3c075a..eb03860d4b 100644 --- a/packages/flutter/test/material/switch_list_tile_test.dart +++ b/packages/flutter/test/material/switch_list_tile_test.dart @@ -359,35 +359,34 @@ void main() { }); testWidgets('SwitchListTile respects tileColor', (WidgetTester tester) async { - const Color tileColor = Colors.red; + final Color tileColor = Colors.red.shade500; await tester.pumpWidget( wrap( - child: const Center( + child: Center( child: SwitchListTile( value: false, onChanged: null, - title: Text('Title'), + title: const Text('Title'), tileColor: tileColor, ), ), ), ); - final ColoredBox coloredBox = tester.firstWidget(find.byType(ColoredBox)); - expect(coloredBox.color, tileColor); + expect(find.byType(Material), paints..path(color: tileColor)); }); testWidgets('SwitchListTile respects selectedTileColor', (WidgetTester tester) async { - const Color selectedTileColor = Colors.black; + final Color selectedTileColor = Colors.green.shade500; await tester.pumpWidget( wrap( - child: const Center( + child: Center( child: SwitchListTile( value: false, onChanged: null, - title: Text('Title'), + title: const Text('Title'), selected: true, selectedTileColor: selectedTileColor, ), @@ -395,8 +394,7 @@ void main() { ), ); - final ColoredBox coloredBox = tester.firstWidget(find.byType(ColoredBox)); - expect(coloredBox.color, equals(selectedTileColor)); + expect(find.byType(Material), paints..path(color: selectedTileColor)); }); } diff --git a/packages/flutter/test/widgets/sliver_prototype_item_extent_test.dart b/packages/flutter/test/widgets/sliver_prototype_item_extent_test.dart index ad513be557..3a4b0a0dfe 100644 --- a/packages/flutter/test/widgets/sliver_prototype_item_extent_test.dart +++ b/packages/flutter/test/widgets/sliver_prototype_item_extent_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/widgets.dart'; @@ -21,14 +22,14 @@ class TestItem extends StatelessWidget { } } -Widget buildFrame({ int? count, double? width, double? height, Axis? scrollDirection }) { +Widget buildFrame({ int? count, double? width, double? height, Axis? scrollDirection, Key? prototypeKey }) { return Directionality( textDirection: TextDirection.ltr, child: CustomScrollView( scrollDirection: scrollDirection ?? Axis.vertical, slivers: [ SliverPrototypeExtentList( - prototypeItem: TestItem(item: -1, width: width, height: height), + prototypeItem: TestItem(item: -1, width: width, height: height, key: prototypeKey), delegate: SliverChildBuilderDelegate( (BuildContext context, int index) => TestItem(item: index), childCount: count, @@ -136,4 +137,18 @@ void main() { for (int i = 1; i < 10; i += 1) expect(find.text('Item $i'), findsOneWidget); }); + + testWidgets('SliverPrototypeExtentList prototypeItem paint transform is zero.', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/67117 + // This test ensures that the SliverPrototypeExtentList does not cause an + // assertion error when calculating the paint transform of its prototypeItem. + // The paint transform of the prototypeItem should be zero, since it is not visible. + final GlobalKey prototypeKey = GlobalKey(); + await tester.pumpWidget(buildFrame(count: 20, height: 100.0, prototypeKey: prototypeKey)); + + final RenderObject scrollView = tester.renderObject(find.byType(CustomScrollView)); + final RenderObject prototype = prototypeKey.currentContext!.findRenderObject()!; + + expect(prototype.getTransformTo(scrollView), Matrix4.zero()); + }); }