From 1a0b03cb2530e29dd2bc93f8b65209b424bbac18 Mon Sep 17 00:00:00 2001 From: Tae Hyung Kim Date: Fri, 28 Apr 2023 16:41:58 -0700 Subject: [PATCH] Sliver Cross Axis Group (#123862) This widget implements the ability to place slivers side by side in a single ScrollView so that they scroll together. The design document for `SliverCrossAxisGroup` can be found [here](https://docs.google.com/document/d/1e2bdLSYV_Dq2h8aHpF8mda67aOmZocPiMyjCcTTZhTg/edit?resourcekey=0-Xj2X2XA3CAFae22Sv3hAiA). Fixes #56756. --- .../sliver/sliver_cross_axis_group.0.dart | 88 +++ .../sliver_cross_axis_group.0_test.dart | 43 ++ packages/flutter/lib/rendering.dart | 1 + .../flutter/lib/src/rendering/sliver.dart | 19 +- .../lib/src/rendering/sliver_group.dart | 170 ++++++ packages/flutter/lib/src/widgets/sliver.dart | 115 +++- .../widgets/sliver_cross_axis_group_test.dart | 570 ++++++++++++++++++ 7 files changed, 1003 insertions(+), 3 deletions(-) create mode 100644 examples/api/lib/widgets/sliver/sliver_cross_axis_group.0.dart create mode 100644 examples/api/test/widgets/sliver/sliver_cross_axis_group.0_test.dart create mode 100644 packages/flutter/lib/src/rendering/sliver_group.dart create mode 100644 packages/flutter/test/widgets/sliver_cross_axis_group_test.dart diff --git a/examples/api/lib/widgets/sliver/sliver_cross_axis_group.0.dart b/examples/api/lib/widgets/sliver/sliver_cross_axis_group.0.dart new file mode 100644 index 0000000000..fefbb4767e --- /dev/null +++ b/examples/api/lib/widgets/sliver/sliver_cross_axis_group.0.dart @@ -0,0 +1,88 @@ +// Copyright 2014 The Flutter 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/material.dart'; + +void main() => runApp(const SliverCrossAxisGroupExampleApp()); + +class SliverCrossAxisGroupExampleApp extends StatelessWidget { + const SliverCrossAxisGroupExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('SliverCrossAxisGroup Sample')), + body: const SliverCrossAxisGroupExample(), + ), + ); + } +} + +class SliverCrossAxisGroupExample extends StatelessWidget { + const SliverCrossAxisGroupExample({super.key}); + + @override + Widget build(BuildContext context) { + return CustomScrollView( + slivers: [ + SliverCrossAxisGroup( + slivers: [ + SliverList.builder( + itemBuilder: (BuildContext context, int index) { + return Container( + color: index.isEven ? Colors.amber[300] : Colors.blue[300], + height: 100.0, + child: Center( + child: Text( + 'Item $index', + style: const TextStyle(fontSize: 24), + ), + ), + ); + }, + itemCount: 5, + ), + SliverConstrainedCrossAxis( + maxExtent: 200, + sliver: SliverList.builder( + itemBuilder: (BuildContext context, int index) { + return Container( + color: index.isEven ? Colors.green[300] : Colors.red[300], + height: 100.0, + child: Center( + child: Text( + 'Item ${index + 5}', + style: const TextStyle(fontSize: 24), + ), + ), + ); + }, + itemCount: 5, + ), + ), + SliverCrossAxisExpanded( + flex: 2, + sliver: SliverList.builder( + itemBuilder: (BuildContext context, int index) { + return Container( + color: index.isEven ? Colors.purple[300] : Colors.orange[300], + height: 100.0, + child: Center( + child: Text( + 'Item ${index + 10}', + style: const TextStyle(fontSize: 24), + ), + ), + ); + }, + itemCount: 5, + ), + ), + ], + ), + ], + ); + } +} diff --git a/examples/api/test/widgets/sliver/sliver_cross_axis_group.0_test.dart b/examples/api/test/widgets/sliver/sliver_cross_axis_group.0_test.dart new file mode 100644 index 0000000000..d49b6f5935 --- /dev/null +++ b/examples/api/test/widgets/sliver/sliver_cross_axis_group.0_test.dart @@ -0,0 +1,43 @@ +// Copyright 2014 The Flutter 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/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_api_samples/widgets/sliver/sliver_cross_axis_group.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SliverCrossAxisGroup example', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SliverCrossAxisGroupExampleApp(), + ); + + final RenderSliverCrossAxisGroup renderSliverGroup = tester.renderObject(find.byType(SliverCrossAxisGroup)); + expect(renderSliverGroup, isNotNull); + + final double crossAxisExtent = renderSliverGroup.constraints.crossAxisExtent; + + final List renderSliverLists = tester.renderObjectList(find.byType(SliverList)).toList(); + final RenderSliverList firstList = renderSliverLists[0]; + final RenderSliverList secondList = renderSliverLists[1]; + final RenderSliverList thirdList = renderSliverLists[2]; + + final double expectedFirstExtent = (crossAxisExtent - 200) / 3; + const double expectedSecondExtent = 200; + final double expectedThirdExtent = 2 * (crossAxisExtent - 200) / 3; + expect(firstList.constraints.crossAxisExtent, equals(expectedFirstExtent)); + expect(secondList.constraints.crossAxisExtent, equals(expectedSecondExtent)); + expect(thirdList.constraints.crossAxisExtent, equals(expectedThirdExtent)); + + // Also check that the paint offsets are correct. + final RenderSliverConstrainedCrossAxis renderConstrained = tester.renderObject( + find.byType(SliverConstrainedCrossAxis) + ); + + expect((firstList.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(0)); + expect((renderConstrained.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(expectedFirstExtent)); + expect((thirdList.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(expectedFirstExtent + expectedSecondExtent)); + }); +} diff --git a/packages/flutter/lib/rendering.dart b/packages/flutter/lib/rendering.dart index a00a220681..4b47bc196d 100644 --- a/packages/flutter/lib/rendering.dart +++ b/packages/flutter/lib/rendering.dart @@ -61,6 +61,7 @@ export 'src/rendering/sliver.dart'; export 'src/rendering/sliver_fill.dart'; export 'src/rendering/sliver_fixed_extent_list.dart'; export 'src/rendering/sliver_grid.dart'; +export 'src/rendering/sliver_group.dart'; export 'src/rendering/sliver_list.dart'; export 'src/rendering/sliver_multi_box_adaptor.dart'; export 'src/rendering/sliver_padding.dart'; diff --git a/packages/flutter/lib/src/rendering/sliver.dart b/packages/flutter/lib/src/rendering/sliver.dart index 7d3f61b400..7ccc6eaa19 100644 --- a/packages/flutter/lib/src/rendering/sliver.dart +++ b/packages/flutter/lib/src/rendering/sliver.dart @@ -760,8 +760,10 @@ class SliverGeometry with Diagnosticable { /// /// See also: /// - /// * [SliverConstrainedCrossAxis] for an example of a sliver which takes up - /// a smaller cross axis extent than the provided constraint. + /// * [SliverConstrainedCrossAxis] for an example of a sliver which takes up + /// a smaller cross axis extent than the provided constraint. + /// * [SliverCrossAxisGroup] for an example of a sliver which makes use of this + /// [crossAxisExtent] to lay out their children. final double? crossAxisExtent; /// Asserts that this geometry is internally consistent. @@ -1004,7 +1006,20 @@ class SliverPhysicalParentData extends ParentData { /// The [crossAxisFlex] factor to use for this sliver child. /// + /// If used outside of a [SliverCrossAxisGroup] widget, this value has no meaning. + /// /// If null or zero, the child is inflexible and determines its own size in the cross axis. + /// If non-zero, the amount of space the child can occupy in the cross axis is + /// determined by dividing the free space (after placing the inflexible children) + /// according to the flex factors of the flexible children. + /// + /// This value is only used by the [SliverCrossAxisGroup] widget to determine + /// how to allocate its [SliverConstraints.crossAxisExtent] to its children. + /// + /// See also: + /// + /// * [SliverCrossAxisGroup], which lays out multiple slivers along the + /// cross axis direction. int? crossAxisFlex; /// Apply the [paintOffset] to the given [transform]. diff --git a/packages/flutter/lib/src/rendering/sliver_group.dart b/packages/flutter/lib/src/rendering/sliver_group.dart new file mode 100644 index 0000000000..fdf922c018 --- /dev/null +++ b/packages/flutter/lib/src/rendering/sliver_group.dart @@ -0,0 +1,170 @@ +// Copyright 2014 The Flutter 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:math' as math; +import 'package:vector_math/vector_math_64.dart'; + +import 'object.dart'; +import 'sliver.dart'; + +/// A sliver that places multiple sliver children in a linear array along the cross +/// axis. +/// +/// Since the extent of the viewport in the cross axis direction is finite, +/// this extent will be divided up and allocated to the children slivers. +/// +/// The algorithm for dividing up the cross axis extent is as follows. +/// Every widget has a [SliverPhysicalParentData.crossAxisFlex] value associated with them. +/// First, lay out all of the slivers with flex of 0 or null, in which case the slivers themselves will +/// figure out how much cross axis extent to take up. For example, [SliverConstrainedCrossAxis] +/// is an example of a widget which sets its own flex to 0. Then [RenderSliverCrossAxisGroup] will +/// divide up the remaining space to all the remaining children proportionally +/// to each child's flex factor. By default, children of [SliverCrossAxisGroup] +/// are setup to have a flex factor of 1, but a different flex factor can be +/// specified via the [SliverCrossAxisExpanded] widgets. +class RenderSliverCrossAxisGroup extends RenderSliver with ContainerRenderObjectMixin { + @override + void setupParentData(RenderObject child) { + if (child.parentData is! SliverPhysicalContainerParentData) { + child.parentData = SliverPhysicalContainerParentData(); + (child.parentData! as SliverPhysicalParentData).crossAxisFlex = 1; + } + } + + @override + double childMainAxisPosition(RenderSliver child) => 0.0; + + @override + double childCrossAxisPosition(RenderSliver child) { + switch (constraints.axisDirection) { + case AxisDirection.up: + case AxisDirection.down: + return (child.parentData! as SliverPhysicalParentData).paintOffset.dx; + case AxisDirection.left: + case AxisDirection.right: + return (child.parentData! as SliverPhysicalParentData).paintOffset.dy; + } + } + + @override + void performLayout() { + // Iterate through each sliver. + // Get the parent's dimensions. + final double crossAxisExtent = constraints.crossAxisExtent; + assert(crossAxisExtent.isFinite); + + // First, layout each child with flex == 0 or null. + int totalFlex = 0; + double remainingExtent = crossAxisExtent; + RenderSliver? child = firstChild; + while (child != null) { + final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData; + final int flex = childParentData.crossAxisFlex ?? 0; + if (flex == 0) { + // If flex is 0 or null, then the child sliver must provide their own crossAxisExtent. + assert(_assertOutOfExtent(remainingExtent)); + child.layout(constraints.copyWith(crossAxisExtent: remainingExtent), parentUsesSize: true); + final double? childCrossAxisExtent = child.geometry!.crossAxisExtent; + assert(childCrossAxisExtent != null); + remainingExtent = math.max(0.0, remainingExtent - childCrossAxisExtent!); + } else { + totalFlex += flex; + } + child = childAfter(child); + } + final double extentPerFlexValue = remainingExtent / totalFlex; + + child = firstChild; + double offset = 0.0; + + // At this point, all slivers with constrained cross axis should already be laid out. + // Layout the rest and keep track of the child geometry with greatest scrollExtent. + geometry = SliverGeometry.zero; + while (child != null) { + final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData; + final int flex = childParentData.crossAxisFlex ?? 0; + double childExtent; + if (flex != 0) { + childExtent = extentPerFlexValue * flex; + assert(_assertOutOfExtent(childExtent)); + child.layout(constraints.copyWith( + crossAxisExtent: extentPerFlexValue * flex, + ), parentUsesSize: true); + } else { + childExtent = child.geometry!.crossAxisExtent!; + } + // Set child parent data. + switch (constraints.axis) { + case Axis.vertical: + childParentData.paintOffset = Offset(offset, 0.0); + case Axis.horizontal: + childParentData.paintOffset = Offset(0.0, offset); + } + offset += childExtent; + if (geometry!.scrollExtent < child.geometry!.scrollExtent) { + geometry = child.geometry; + } + child = childAfter(child); + } + + // Set the geometry with the proper crossAxisExtent. + geometry = geometry!.copyWith(crossAxisExtent: constraints.crossAxisExtent); + } + + @override + void paint(PaintingContext context, Offset offset) { + RenderSliver? child = firstChild; + + while (child != null) { + final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData; + context.paintChild(child, offset + childParentData.paintOffset); + child = childAfter(child); + } + } + + @override + void applyPaintTransform(RenderSliver child, Matrix4 transform) { + final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData; + childParentData.applyPaintTransform(transform); + } + + @override + bool hitTestChildren(SliverHitTestResult result, {required double mainAxisPosition, required double crossAxisPosition}) { + RenderSliver? child = lastChild; + while (child != null) { + final bool isHit = result.addWithAxisOffset( + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + paintOffset: null, + mainAxisOffset: childMainAxisPosition(child), + crossAxisOffset: childCrossAxisPosition(child), + hitTest: child.hitTest, + ); + if (isHit) { + return true; + } + child = childBefore(child); + } + return false; + } +} + +bool _assertOutOfExtent(double extent) { + if(extent <= 0.0) { + throw FlutterError.fromParts([ + ErrorSummary('SliverCrossAxisGroup ran out of extent before child could be laid out.'), + ErrorDescription( + 'SliverCrossAxisGroup lays out any slivers with a constrained cross ' + 'axis before laying out those which expand. In this case, cross axis ' + 'extent was used up before the next sliver could be laid out.' + ), + ErrorHint( + 'Make sure that the total amount of extent allocated by constrained ' + 'child slivers does not exceed the cross axis extent that is available ' + 'for the SliverCrossAxisGroup.' + ), + ]); + } + return true; +} diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart index 6b4b9f82cc..28d60ced82 100644 --- a/packages/flutter/lib/src/widgets/sliver.dart +++ b/packages/flutter/lib/src/widgets/sliver.dart @@ -1375,12 +1375,21 @@ class KeepAlive extends ParentDataWidget { /// This is useful when you want to apply a custom cross-axis extent constraint /// to a sliver child, as slivers typically consume the full cross axis extent. /// +/// This widget also sets its parent data's [SliverPhysicalParentData.crossAxisFlex] +/// to 0, so that it informs [SliverCrossAxisGroup] that it should not flex +/// in the cross axis direction. +/// /// {@tool dartpad} /// In this sample the [SliverConstrainedCrossAxis] sizes its child so that the /// cross axis extent takes up less space than the actual viewport. /// /// ** See code in examples/api/lib/widgets/sliver/sliver_constrained_cross_axis.0.dart ** /// {@end-tool} +/// +/// See also: +/// +/// * [SliverCrossAxisGroup], the widget which makes use of 0 flex factor set by +/// this widget. class SliverConstrainedCrossAxis extends StatelessWidget { /// Creates a sliver that constrains the cross axis extent of its sliver child. /// @@ -1436,7 +1445,7 @@ class _SliverZeroFlexParentDataWidget extends ParentDataWidget Widget; + Type get debugTypicalAncestorWidgetClass => SliverCrossAxisGroup; } class _SliverConstrainedCrossAxis extends SingleChildRenderObjectWidget { @@ -1461,3 +1470,107 @@ class _SliverConstrainedCrossAxis extends SingleChildRenderObjectWidget { renderObject.maxExtent = maxExtent; } } + +/// Set a flex factor for allocating space in the cross axis direction. +/// +/// This is a [ParentDataWidget] to be used in [SliverCrossAxisGroup]. +/// After all slivers with null or zero flex (e.g. [SliverConstrainedCrossAxis]) +/// are laid out (which should determine their own [SliverGeometry.crossAxisExtent]), +/// the remaining space is laid out among the slivers with nonzero flex +/// proportionally to their flex value. +class SliverCrossAxisExpanded extends ParentDataWidget { + /// Creates an object that assigns a [flex] value to the child sliver. + /// + /// The provided [flex] value must be greater than 0. + const SliverCrossAxisExpanded({ + super.key, + required this.flex, + required Widget sliver, + }): assert(flex > 0 && flex < double.infinity), + super(child: sliver); + + /// Flex value for allocating cross axis extent left after laying out the children with + /// constrained cross axis. The children with flex values will have the remaining extent + /// allocated proportionally to their flex value. This must an integer between + /// 0 and infinity, exclusive. + final int flex; + + @override + void applyParentData(RenderObject renderObject) { + assert(renderObject.parentData is SliverPhysicalContainerParentData); + assert(renderObject.parent is RenderSliverCrossAxisGroup); + final SliverPhysicalParentData parentData = renderObject.parentData! as SliverPhysicalParentData; + bool needsLayout = false; + + if (parentData.crossAxisFlex != flex) { + parentData.crossAxisFlex = flex; + needsLayout = true; + } + + if (needsLayout) { + final AbstractNode? targetParent = renderObject.parent; + if (targetParent is RenderObject) { + targetParent.markNeedsLayout(); + } + } + } + + @override + Type get debugTypicalAncestorWidgetClass => SliverCrossAxisGroup; +} + + +/// A sliver that places multiple sliver children in a linear array along +/// the cross axis. +/// +/// ## Layout algorithm +/// +/// _This section describes how the framework causes [RenderSliverCrossAxisGroup] +/// to position its children._ +/// +/// Layout for a [RenderSliverCrossAxisGroup] has four steps: +/// +/// 1. Layout each child with a null or zero flex factor with cross axis constraint +/// being whatever cross axis space is remaining after laying out any previous +/// sliver. Slivers with null or zero flex factor should determine their own +/// [SliverGeometry.crossAxisExtent]. For example, the [SliverConstrainedCrossAxis] +/// widget uses either [SliverConstrainedCrossAxis.maxExtent] or +/// [SliverConstraints.crossAxisExtent], deciding between whichever is smaller. +/// 2. Divide up the remaining cross axis space among the children with non-zero flex +/// factors according to their flex factor. For example, a child with a flex +/// factor of 2.0 will receive twice the amount of cross axis space as a child +/// with a flex factor 1.0. +/// 3. Layout each of the remaining children with the cross axis constraint +/// allocated in the previous step. +/// 4. Set the geometry to that of whichever child has the longest +/// [SliverGeometry.scrollExtent] with the [SliverGeometry.crossAxisExtent] adjusted +/// to [SliverConstraints.crossAxisExtent]. +/// +/// {@tool dartpad} +/// In this sample the [SliverCrossAxisGroup] sizes its three [children] so that +/// the first normal [SliverList] has a flex factor of 1, the second [SliverConstrainedCrossAxis] +/// has a flex factor of 0 and a maximum cross axis extent of 200.0, and the third +/// [SliverCrossAxisExpanded] has a flex factor of 2. +/// +/// ** See code in examples/api/lib/widgets/sliver/sliver_cross_axis_group.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SliverCrossAxisExpanded], which is the [ParentDataWidget] for setting a flex +/// value to a widget. +/// * [SliverConstrainedCrossAxis], which is a [RenderObjectWidget] for setting +/// an extent to constrain the widget to. +class SliverCrossAxisGroup extends MultiChildRenderObjectWidget { + /// Creates a sliver that places sliver children in a linear array along + /// the cross axis. + const SliverCrossAxisGroup({ + super.key, + required List slivers, + }): super(children: slivers); + + @override + RenderSliverCrossAxisGroup createRenderObject(BuildContext context) { + return RenderSliverCrossAxisGroup(); + } +} diff --git a/packages/flutter/test/widgets/sliver_cross_axis_group_test.dart b/packages/flutter/test/widgets/sliver_cross_axis_group_test.dart new file mode 100644 index 0000000000..f54b743d50 --- /dev/null +++ b/packages/flutter/test/widgets/sliver_cross_axis_group_test.dart @@ -0,0 +1,570 @@ +// Copyright 2014 The Flutter 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/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const double VIEWPORT_HEIGHT = 600; +const double VIEWPORT_WIDTH = 300; + +void main() { + testWidgets('SliverCrossAxisGroup is laid out properly', (WidgetTester tester) async { + final List items = List.generate(20, (int i) => i); + final ScrollController controller = ScrollController(); + + await tester.pumpWidget(_buildSliverCrossAxisGroup( + controller: controller, + slivers: [ + _buildSliverList(itemMainAxisExtent: 300, items: items, label: (int item) => Text('Group 0 Tile $item')), + _buildSliverList(itemMainAxisExtent: 200, items: items, label: (int item) => Text('Group 1 Tile $item')), + ]), + ); + await tester.pumpAndSettle(); + + expect(controller.offset, 0); + + expect(find.text('Group 0 Tile 0'), findsOneWidget); + expect(find.text('Group 0 Tile 1'), findsOneWidget); + expect(find.text('Group 0 Tile 2'), findsNothing); + + expect(find.text('Group 1 Tile 0'), findsOneWidget); + expect(find.text('Group 1 Tile 2'), findsOneWidget); + expect(find.text('Group 1 Tile 3'), findsNothing); + + const double scrollOffset = 18 * 300.0; + controller.jumpTo(scrollOffset); + await tester.pumpAndSettle(); + + expect(controller.offset, scrollOffset); + expect(find.text('Group 0 Tile 17'), findsNothing); + expect(find.text('Group 0 Tile 18'), findsOneWidget); + expect(find.text('Group 0 Tile 19'), findsOneWidget); + expect(find.text('Group 1 Tile 19'), findsNothing); + + final List renderSlivers = tester.renderObjectList(find.byType(SliverList)).toList(); + final RenderSliverList first = renderSlivers[0]; + final RenderSliverList second = renderSlivers[1]; + + expect(first.constraints.crossAxisExtent, equals(VIEWPORT_WIDTH / 2)); + expect(second.constraints.crossAxisExtent, equals(VIEWPORT_WIDTH / 2)); + + expect((first.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(0)); + expect((second.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(VIEWPORT_WIDTH / 2)); + + final RenderSliverCrossAxisGroup renderGroup = tester.renderObject(find.byType(SliverCrossAxisGroup)); + expect(renderGroup.geometry!.scrollExtent, equals(300 * 20)); + }); + + testWidgets('SliverExpanded is laid out properly', (WidgetTester tester) async { + final List items = List.generate(20, (int i) => i); + await tester.pumpWidget(_buildSliverCrossAxisGroup( + slivers: [ + SliverCrossAxisExpanded( + flex: 3, + sliver: _buildSliverList( + itemMainAxisExtent: 300, + items: items, + label: (int item) => Text('Group 0 Tile $item') + ), + ), + SliverCrossAxisExpanded( + flex: 2, + sliver: _buildSliverList( + itemMainAxisExtent: 200, + items: items, + label: (int item) => Text('Group 1 Tile $item') + ), + ), + ]), + ); + await tester.pumpAndSettle(); + + final List renderSlivers = tester.renderObjectList(find.byType(SliverList)).toList(); + final RenderSliverList first = renderSlivers[0]; + final RenderSliverList second = renderSlivers[1]; + + expect(first.constraints.crossAxisExtent, equals(3 * VIEWPORT_WIDTH / 5)); + expect(second.constraints.crossAxisExtent, equals(2 * VIEWPORT_WIDTH / 5)); + + expect((first.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(0)); + expect((second.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(3 * VIEWPORT_WIDTH / 5)); + + final RenderSliverCrossAxisGroup renderGroup = tester.renderObject(find.byType(SliverCrossAxisGroup)); + expect(renderGroup.geometry!.scrollExtent, equals(300 * 20)); + }); + + testWidgets('SliverConstrainedCrossAxis is laid out properly', (WidgetTester tester) async { + final List items = List.generate(20, (int i) => i); + await tester.pumpWidget(_buildSliverCrossAxisGroup( + slivers: [ + SliverConstrainedCrossAxis(maxExtent: 60, sliver: _buildSliverList(itemMainAxisExtent: 300, items: items, label: (int item) => Text('Group 0 Tile $item'))), + SliverConstrainedCrossAxis(maxExtent: 120, sliver: _buildSliverList(itemMainAxisExtent: 200, items: items, label: (int item) => Text('Group 1 Tile $item'))), + ]), + ); + await tester.pumpAndSettle(); + + final List renderSlivers = tester.renderObjectList(find.byType(SliverList)).toList(); + final RenderSliverList first = renderSlivers[0]; + final RenderSliverList second = renderSlivers[1]; + + expect(first.constraints.crossAxisExtent, equals(60)); + expect(second.constraints.crossAxisExtent, equals(120)); + + // Check that their parent SliverConstrainedCrossAxis have the correct paintOffsets. + final List renderSliversConstrained = tester.renderObjectList(find.byType(SliverConstrainedCrossAxis)).toList(); + final RenderSliverConstrainedCrossAxis firstConstrained = renderSliversConstrained[0]; + final RenderSliverConstrainedCrossAxis secondConstrained = renderSliversConstrained[1]; + + expect((firstConstrained.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(0)); + expect((secondConstrained.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(60)); + + final RenderSliverCrossAxisGroup renderGroup = tester.renderObject(find.byType(SliverCrossAxisGroup)); + expect(renderGroup.geometry!.scrollExtent, equals(300 * 20)); + }); + + testWidgets('Mix of slivers is laid out properly', (WidgetTester tester) async { + final List items = List.generate(20, (int i) => i); + await tester.pumpWidget(_buildSliverCrossAxisGroup( + slivers: [ + SliverConstrainedCrossAxis(maxExtent: 30, sliver: _buildSliverList(itemMainAxisExtent: 300, items: items, label: (int item) => Text('Group 0 Tile $item'))), + SliverCrossAxisExpanded(flex: 2, sliver: _buildSliverList(itemMainAxisExtent: 200, items: items, label: (int item) => Text('Group 1 Tile $item'))), + _buildSliverList(itemMainAxisExtent: 200, items: items, label: (int item) => Text('Group 2 Tile $item')), + ]), + ); + await tester.pumpAndSettle(); + + final List renderSlivers = tester.renderObjectList(find.byType(SliverList)).toList(); + final RenderSliverList first = renderSlivers[0]; + final RenderSliverList second = renderSlivers[1]; + final RenderSliverList third = renderSlivers[2]; + + expect(first.constraints.crossAxisExtent, equals(30)); + expect(second.constraints.crossAxisExtent, equals(180)); + expect(third.constraints.crossAxisExtent, equals(90)); + + // Check that paint offset for sliver children are correct as well. + final RenderSliverCrossAxisGroup sliverCrossAxisRenderObject = tester.renderObject(find.byType(SliverCrossAxisGroup)); + RenderSliver child = sliverCrossAxisRenderObject.firstChild!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(0)); + child = sliverCrossAxisRenderObject.childAfter(child)!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(30)); + child = sliverCrossAxisRenderObject.childAfter(child)!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(210)); + + final RenderSliverCrossAxisGroup renderGroup = tester.renderObject(find.byType(SliverCrossAxisGroup)); + expect(renderGroup.geometry!.scrollExtent, equals(300 * 20)); + }); + + testWidgets('Mix of slivers is laid out properly when horizontal', (WidgetTester tester) async { + final List items = List.generate(20, (int i) => i); + await tester.pumpWidget(_buildSliverCrossAxisGroup( + scrollDirection: Axis.horizontal, + slivers: [ + SliverConstrainedCrossAxis( + maxExtent: 30, + sliver: _buildSliverList( + scrollDirection: Axis.horizontal, + itemMainAxisExtent: 300, + items: items, + label: (int item) => Text('Group 0 Tile $item') + ) + ), + SliverCrossAxisExpanded( + flex: 2, + sliver: _buildSliverList( + scrollDirection: Axis.horizontal, + itemMainAxisExtent: 200, + items: items, + label: (int item) => Text('Group 1 Tile $item') + ) + ), + _buildSliverList( + scrollDirection: Axis.horizontal, + itemMainAxisExtent: 200, + items: items, + label: (int item) => Text('Group 2 Tile $item') + ), + ]), + ); + await tester.pumpAndSettle(); + + final List renderSlivers = tester.renderObjectList(find.byType(SliverList)).toList(); + final RenderSliverList first = renderSlivers[0]; + final RenderSliverList second = renderSlivers[1]; + final RenderSliverList third = renderSlivers[2]; + + expect(first.constraints.crossAxisExtent, equals(30)); + expect(second.constraints.crossAxisExtent, equals(380)); + expect(third.constraints.crossAxisExtent, equals(190)); + + // Check that paint offset for sliver children are correct as well. + final RenderSliverCrossAxisGroup sliverCrossAxisRenderObject = tester.renderObject(find.byType(SliverCrossAxisGroup)); + RenderSliver child = sliverCrossAxisRenderObject.firstChild!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dy, equals(0)); + child = sliverCrossAxisRenderObject.childAfter(child)!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dy, equals(30)); + child = sliverCrossAxisRenderObject.childAfter(child)!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dy, equals(410)); + + final RenderSliverCrossAxisGroup renderGroup = tester.renderObject(find.byType(SliverCrossAxisGroup)); + expect(renderGroup.geometry!.scrollExtent, equals(300 * 20)); + }); + + testWidgets('Mix of slivers is laid out properly when reversed horizontal', (WidgetTester tester) async { + final List items = List.generate(20, (int i) => i); + await tester.pumpWidget(_buildSliverCrossAxisGroup( + scrollDirection: Axis.horizontal, + reverse: true, + slivers: [ + SliverConstrainedCrossAxis( + maxExtent: 30, + sliver: _buildSliverList( + scrollDirection: Axis.horizontal, + itemMainAxisExtent: 300, + items: items, + label: (int item) => Text('Group 0 Tile $item') + ) + ), + SliverCrossAxisExpanded( + flex: 2, + sliver: _buildSliverList( + scrollDirection: Axis.horizontal, + itemMainAxisExtent: 200, + items: items, + label: (int item) => Text('Group 1 Tile $item') + ) + ), + _buildSliverList( + scrollDirection: Axis.horizontal, + itemMainAxisExtent: 200, + items: items, + label: (int item) => Text('Group 2 Tile $item') + ), + ]), + ); + await tester.pumpAndSettle(); + + final List renderSlivers = tester.renderObjectList(find.byType(SliverList)).toList(); + final RenderSliverList first = renderSlivers[0]; + final RenderSliverList second = renderSlivers[1]; + final RenderSliverList third = renderSlivers[2]; + + expect(first.constraints.crossAxisExtent, equals(30)); + expect(second.constraints.crossAxisExtent, equals(380)); + expect(third.constraints.crossAxisExtent, equals(190)); + + // Check that paint offset for sliver children are correct as well. + final RenderSliverCrossAxisGroup sliverCrossAxisRenderObject = tester.renderObject(find.byType(SliverCrossAxisGroup)); + RenderSliver child = sliverCrossAxisRenderObject.firstChild!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dy, equals(0)); + child = sliverCrossAxisRenderObject.childAfter(child)!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dy, equals(30)); + child = sliverCrossAxisRenderObject.childAfter(child)!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dy, equals(410)); + + final RenderSliverCrossAxisGroup renderGroup = tester.renderObject(find.byType(SliverCrossAxisGroup)); + expect(renderGroup.geometry!.scrollExtent, equals(300 * 20)); + }); + + testWidgets('Mix of slivers is laid out properly when reversed vertical', (WidgetTester tester) async { + final List items = List.generate(20, (int i) => i); + await tester.pumpWidget(_buildSliverCrossAxisGroup( + reverse: true, + slivers: [ + SliverConstrainedCrossAxis( + maxExtent: 30, + sliver: _buildSliverList( + itemMainAxisExtent: 300, + items: items, + label: (int item) => Text('Group 0 Tile $item') + ) + ), + SliverCrossAxisExpanded( + flex: 2, + sliver: _buildSliverList( + itemMainAxisExtent: 200, + items: items, + label: (int item) => Text('Group 1 Tile $item') + ) + ), + _buildSliverList( + itemMainAxisExtent: 200, + items: items, + label: (int item) => Text('Group 2 Tile $item') + ), + ]), + ); + await tester.pumpAndSettle(); + + final List renderSlivers = tester.renderObjectList(find.byType(SliverList)).toList(); + final RenderSliverList first = renderSlivers[0]; + final RenderSliverList second = renderSlivers[1]; + final RenderSliverList third = renderSlivers[2]; + + expect(first.constraints.crossAxisExtent, equals(30)); + expect(second.constraints.crossAxisExtent, equals(180)); + expect(third.constraints.crossAxisExtent, equals(90)); + + // Check that paint offset for sliver children are correct as well. + final RenderSliverCrossAxisGroup sliverCrossAxisRenderObject = tester.renderObject(find.byType(SliverCrossAxisGroup)); + RenderSliver child = sliverCrossAxisRenderObject.firstChild!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(0)); + child = sliverCrossAxisRenderObject.childAfter(child)!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(30)); + child = sliverCrossAxisRenderObject.childAfter(child)!; + expect((child.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(210)); + + final RenderSliverCrossAxisGroup renderGroup = tester.renderObject(find.byType(SliverCrossAxisGroup)); + expect(renderGroup.geometry!.scrollExtent, equals(300 * 20)); + }); + + testWidgets('Assertion error when SliverExpanded is used outside of SliverCrossAxisGroup', (WidgetTester tester) async { + final List errors = []; + final Function(FlutterErrorDetails)? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails error) => errors.add(error); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CustomScrollView( + slivers: [ + SliverCrossAxisExpanded( + flex: 2, + sliver: SliverToBoxAdapter( + child: Text('Hello World'), + ), + ), + ], + ), + ), + ), + ); + FlutterError.onError = oldHandler; + expect(errors, isNotEmpty); + final AssertionError error = errors.first.exception as AssertionError; + expect( + error.toString(), + contains('renderObject.parent is RenderSliverCrossAxisGroup'), + ); + }); + + testWidgets('Hit test works properly on various parts of SliverCrossAxisGroup', (WidgetTester tester) async { + final List items = List.generate(20, (int i) => i); + final ScrollController controller = ScrollController(); + + String? clickedTile; + + int group = 0; + int tile = 0; + + await tester.pumpWidget(_buildSliverCrossAxisGroup( + controller: controller, + slivers: [ + _buildSliverList( + itemMainAxisExtent: 300, + items: items, + label: (int item) => tile == item && group == 0 + ? TextButton( + onPressed: () => clickedTile = 'Group 0 Tile $item', + child: Text('Group 0 Tile $item'), + ) + : Text('Group 0 Tile $item'), + ), + _buildSliverList( + items: items, + label: (int item) => tile == item && group == 1 + ? TextButton( + onPressed: () => clickedTile = 'Group 1 Tile $item', + child: Text('Group 1 Tile $item'), + ) + : Text('Group 1 Tile $item'), + ), + ]), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + expect(clickedTile, equals('Group 0 Tile 0')); + + clickedTile = null; + group = 1; + tile = 2; + await tester.pumpWidget(_buildSliverCrossAxisGroup( + controller: controller, + slivers: [ + _buildSliverList( + itemMainAxisExtent: 300, + items: items, + label: (int item) => tile == item && group == 0 + ? TextButton( + onPressed: () => clickedTile = 'Group 0 Tile $item', + child: Text('Group 0 Tile $item'), + ) + : Text('Group 0 Tile $item'), + ), + _buildSliverList( + items: items, + label: (int item) => tile == item && group == 1 + ? TextButton( + onPressed: () => clickedTile = 'Group 1 Tile $item', + child: Text('Group 1 Tile $item'), + ) + : Text('Group 1 Tile $item'), + ), + ]), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + expect(clickedTile, equals('Group 1 Tile 2')); + }); + + testWidgets('Constrained sliver takes up remaining space', (WidgetTester tester) async { + final List items = List.generate(20, (int i) => i); + await tester.pumpWidget(_buildSliverCrossAxisGroup( + slivers: [ + SliverConstrainedCrossAxis(maxExtent: 200, sliver: _buildSliverList(itemMainAxisExtent: 300, items: items, label: (int item) => Text('Group 0 Tile $item'))), + SliverConstrainedCrossAxis(maxExtent: 200, sliver: _buildSliverList(itemMainAxisExtent: 200, items: items, label: (int item) => Text('Group 1 Tile $item'))), + ]), + ); + await tester.pumpAndSettle(); + + final List renderSlivers = tester.renderObjectList(find.byType(SliverList)).toList(); + final RenderSliverList first = renderSlivers[0]; + final RenderSliverList second = renderSlivers[1]; + + expect(first.constraints.crossAxisExtent, equals(200)); + expect(second.constraints.crossAxisExtent, equals(100)); + + // Check that their parent SliverConstrainedCrossAxis have the correct paintOffsets. + final List renderSliversConstrained = tester.renderObjectList(find.byType(SliverConstrainedCrossAxis)).toList(); + final RenderSliverConstrainedCrossAxis firstConstrained = renderSliversConstrained[0]; + final RenderSliverConstrainedCrossAxis secondConstrained = renderSliversConstrained[1]; + + expect((firstConstrained.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(0)); + expect((secondConstrained.parentData! as SliverPhysicalParentData).paintOffset.dx, equals(200)); + + final RenderSliverCrossAxisGroup renderGroup = tester.renderObject(find.byType(SliverCrossAxisGroup)); + expect(renderGroup.geometry!.scrollExtent, equals(300 * 20)); + }); + + testWidgets('Assertion error when constrained widget runs out of cross axis extent', (WidgetTester tester) async { + final List errors = []; + final Function(FlutterErrorDetails)? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails error) => errors.add(error); + + final List items = List.generate(20, (int i) => i); + await tester.pumpWidget(_buildSliverCrossAxisGroup( + slivers: [ + SliverConstrainedCrossAxis(maxExtent: 400, sliver: _buildSliverList(itemMainAxisExtent: 300, items: items, label: (int item) => Text('Group 0 Tile $item'))), + SliverConstrainedCrossAxis(maxExtent: 200, sliver: _buildSliverList(itemMainAxisExtent: 200, items: items, label: (int item) => Text('Group 1 Tile $item'))), + ]), + ); + await tester.pumpAndSettle(); + FlutterError.onError = oldHandler; + expect(errors, isNotEmpty); + final AssertionError error = errors.first.exception as AssertionError; + expect( + error.toString(), + contains('SliverCrossAxisGroup ran out of extent before child could be laid out.'), + ); + }); + + testWidgets('Assertion error when expanded widget runs out of cross axis extent', (WidgetTester tester) async { + final List errors = []; + final Function(FlutterErrorDetails)? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails error) => errors.add(error); + + final List items = List.generate(20, (int i) => i); + await tester.pumpWidget(_buildSliverCrossAxisGroup( + slivers: [ + SliverConstrainedCrossAxis(maxExtent: 200, sliver: _buildSliverList(itemMainAxisExtent: 300, items: items, label: (int item) => Text('Group 0 Tile $item'))), + SliverConstrainedCrossAxis(maxExtent: 100, sliver: _buildSliverList(itemMainAxisExtent: 200, items: items, label: (int item) => Text('Group 1 Tile $item'))), + _buildSliverList(itemMainAxisExtent: 200, items: items, label: (int item) => Text('Group 2 Tile $item')), + ]), + ); + await tester.pumpAndSettle(); + FlutterError.onError = oldHandler; + expect(errors, isNotEmpty); + final AssertionError error = errors.first.exception as AssertionError; + expect( + error.toString(), + contains('SliverCrossAxisGroup ran out of extent before child could be laid out.'), + ); + }); + + testWidgets('applyPaintTransform is implemented properly', (WidgetTester tester) async { + await tester.pumpWidget(_buildSliverCrossAxisGroup( + slivers: [ + const SliverToBoxAdapter(child: Text('first box')), + const SliverToBoxAdapter(child: Text('second box')), + ]), + ); + await tester.pumpAndSettle(); + + // localToGlobal calculates offset via applyPaintTransform + final RenderBox first = tester.renderObject(find.text('first box')); + final RenderBox second = tester.renderObject(find.text('second box')); + expect(first.localToGlobal(Offset.zero), Offset.zero); + expect(second.localToGlobal(Offset.zero), const Offset(VIEWPORT_WIDTH / 2, 0)); + }); +} + +Widget _buildSliverList({ + double itemMainAxisExtent = 100, + List items = const [], + required Widget Function(int) label, + Axis scrollDirection = Axis.vertical, +}) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int i) { + return scrollDirection == Axis.vertical + ? SizedBox( + key: ValueKey(items[i]), + height: itemMainAxisExtent, + child: label(items[i]), + ) + : SizedBox( + key: ValueKey(items[i]), + width: itemMainAxisExtent, + child: label(items[i])); + }, + findChildIndexCallback: (Key key) { + final ValueKey valueKey = key as ValueKey; + final int index = items.indexOf(valueKey.value); + return index == -1 ? null : index; + }, + childCount: items.length, + ), + ); +} + +Widget _buildSliverCrossAxisGroup({ + required List slivers, + ScrollController? controller, + double viewportHeight = VIEWPORT_HEIGHT, + double viewportWidth = VIEWPORT_WIDTH, + Axis scrollDirection = Axis.vertical, + bool reverse = false, +}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: viewportHeight, + width: viewportWidth, + child: CustomScrollView( + scrollDirection: scrollDirection, + reverse: reverse, + controller: controller, + slivers: [SliverCrossAxisGroup(slivers: slivers)], + ), + ), + ), + ); +}