From 3a48ebc1e4ef3c0f80e878d028dec62da0b1ea76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20David=20L=C3=B3pez=20Maga=C3=B1a?= <79760620+DavBot02@users.noreply.github.com> Date: Wed, 31 Aug 2022 12:59:14 -0500 Subject: [PATCH] [dynamic_layouts] Add Staggered Layout (#2520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added SliverGridStaggeredTileLayout and staggered constructor for DynamicGridView * Added documentation, tests and bug fixes for DynamicSliverGridGeometry and others * Added simple demo and fixed formating * Added copyright * Fixed formatting * Added const constructor to DynamicSliverGridDelegateWithFixedCrossAxisCount * Updated tests with new sizes * Updated readme * Fixed formatting and documentation * Modified types and functionality for main axis counts and extents * Updated readme * Added test * Fixed TODO * Fixed readme * Added example (needed for code excerpts) * Added build_runner * Test excerpts * Changed sdk version for example * Changed sdk version again * Removed example * Removed assets * Final rebase * Formatting * Formatting * Fixed test Co-authored-by: Luis López --- packages/dynamic_layouts/README.md | 43 +- .../dynamic_layouts/lib/dynamic_layouts.dart | 1 + .../dynamic_layouts/lib/src/dynamic_grid.dart | 54 ++- .../lib/src/staggered_layout.dart | 318 +++++++++++++ .../test/dynamic_grid_test.dart | 2 +- .../test/staggered_layout_test.dart | 427 ++++++++++++++++++ 6 files changed, 821 insertions(+), 24 deletions(-) create mode 100644 packages/dynamic_layouts/lib/src/staggered_layout.dart create mode 100644 packages/dynamic_layouts/test/staggered_layout_test.dart diff --git a/packages/dynamic_layouts/README.md b/packages/dynamic_layouts/README.md index 3df1897f728..e0b89ff5574 100644 --- a/packages/dynamic_layouts/README.md +++ b/packages/dynamic_layouts/README.md @@ -1,20 +1,4 @@ - - - +A package that provides two dynamic grid layouts: wrap and staggered. ## Features This package provides support for multi sized tiles and different layouts. @@ -54,11 +38,22 @@ This layout can be used with `DynamicGridView.wrap` and with ## Getting started - +### Depend on it +Run this command with Flutter: +```sh +$ flutter pub add dynamic_layouts +``` +### Import it + +Now in your Dart code, you can use: + +```sh +import 'package:dynamic_layouts/dynamic_layouts.dart'; +``` ## Usage -Use `DynamicGridView`s to access this layouts. +Use `DynamicGridView`s to access these layouts. `DynamicGridView` has some constructors that use `SliverChildListDelegate` like `.wrap` and `.stagger`. For a more efficient option that uses `SliverChildBuilderDelegate` use `.builder`, it works the same as `GridView.builder`. @@ -88,6 +83,10 @@ like `SliverGridDelegateWithMaxCrossAxisExtent` and ## Additional information - +The staggered layout is similar to Android's [StaggeredGridLayoutManager](https://developer.android.com/reference/androidx/recyclerview/widget/StaggeredGridLayoutManager), while the wrap layout +emulates iOS' [UICollectionView](https://developer.apple.com/documentation/uikit/uicollectionview). + +The inner functionality of this package is exposed, meaning that other dynamic layouts +can be created on top of it and added to the collection. If you want to contribute to +this package, you can open a pull request in [Flutter Packages](https://github.com/flutter/packages) +and add the tag "p: dynamic_layouts". diff --git a/packages/dynamic_layouts/lib/dynamic_layouts.dart b/packages/dynamic_layouts/lib/dynamic_layouts.dart index ad714663fbc..de26b07b37b 100644 --- a/packages/dynamic_layouts/lib/dynamic_layouts.dart +++ b/packages/dynamic_layouts/lib/dynamic_layouts.dart @@ -5,4 +5,5 @@ export 'src/base_grid_layout.dart'; export 'src/dynamic_grid.dart'; export 'src/render_dynamic_grid.dart'; +export 'src/staggered_layout.dart'; export 'src/wrap_layout.dart'; diff --git a/packages/dynamic_layouts/lib/src/dynamic_grid.dart b/packages/dynamic_layouts/lib/src/dynamic_grid.dart index a9aceccd50e..57f4c8fdb7a 100644 --- a/packages/dynamic_layouts/lib/src/dynamic_grid.dart +++ b/packages/dynamic_layouts/lib/src/dynamic_grid.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'render_dynamic_grid.dart'; +import 'staggered_layout.dart'; import 'wrap_layout.dart'; /// A scrollable, 2D array of widgets. @@ -97,7 +98,58 @@ class DynamicGridView extends GridView { ), ); - // TODO(DavBot09): DynamicGridView.stagger? + /// Creates a scrollable, 2D array of widgets where each tile's main axis + /// extent will be determined by the child's corresponding finite size and + /// the cross axis extent will be fixed, generating a staggered layout. + /// + /// Either a [crossAxisCount] or a [maxCrossAxisExtent] must be provided. + /// The constructor will then use a + /// [DynamicSliverGridDelegateWithFixedCrossAxisCount] or a + /// [DynamicSliverGridDelegateWithMaxCrossAxisExtent] as its [gridDelegate], + /// respectively. + /// + /// This sample code shows how to use the constructor with a + /// [maxCrossAxisExtent] and a simple layout: + /// + /// ```dart + /// DynamicGridView.staggered( + /// maxCrossAxisExtent: 100, + /// crossAxisSpacing: 2, + /// mainAxisSpacing: 2, + /// children: List.generate( + /// 50, + /// (int index) => Container( + /// height: index % 3 * 50 + 20, + /// color: Colors.amber[index % 9 * 100], + /// child: Center(child: Text("Index $index")), + /// ), + /// ), + /// ); + /// ``` + DynamicGridView.staggered({ + super.key, + super.scrollDirection, + super.reverse, + int? crossAxisCount, + double? maxCrossAxisExtent, + double mainAxisSpacing = 0.0, + double crossAxisSpacing = 0.0, + super.children = const [], + }) : assert(crossAxisCount != null || maxCrossAxisExtent != null), + assert(crossAxisCount == null || maxCrossAxisExtent == null), + super( + gridDelegate: crossAxisCount != null + ? DynamicSliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + ) + : DynamicSliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: maxCrossAxisExtent!, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + ), + ); @override Widget buildChildLayout(BuildContext context) { diff --git a/packages/dynamic_layouts/lib/src/staggered_layout.dart b/packages/dynamic_layouts/lib/src/staggered_layout.dart new file mode 100644 index 00000000000..190f80949ef --- /dev/null +++ b/packages/dynamic_layouts/lib/src/staggered_layout.dart @@ -0,0 +1,318 @@ +// Copyright 2013 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:flutter/rendering.dart'; + +import 'base_grid_layout.dart'; +import 'render_dynamic_grid.dart'; +import 'wrap_layout.dart'; + +/// A [DynamicSliverGridLayout] that creates tiles with varying main axis +/// sizes and fixed cross axis sizes, generating a staggered layout. The extent +/// in the main axis will be defined by the child's size and must be finite. +/// Similar to Android's StaggeredGridLayoutManager: +/// [https://developer.android.com/reference/androidx/recyclerview/widget/StaggeredGridLayoutManager]. +/// +/// The tiles are placed in the column (or row in case [scrollDirection] is set +/// to [Axis.horizontal]) with the minimum extent, which means children are not +/// neccesarily layed out in sequential order. +/// +/// See also: +/// +/// * [SliverGridWrappingTileLayout], a similar layout that allows tiles to be +/// freely sized in the main and cross axis. +/// * [DynamicSliverGridDelegateWithMaxCrossAxisExtent], which creates +/// staggered layouts with a maximum extent in the cross axis. +/// * [DynamicSliverGridDelegateWithFixedCrossAxisCount], which creates +/// staggered layouts with a consistent amount of tiles in the cross axis. +/// * [DynamicGridView.staggered], which uses these delegates to create +/// staggered layouts. +/// * [DynamicSliverGridGeometry], which establishes the position of a child +/// and pass the child's desired proportions to a [DynamicSliverGridLayout]. +/// * [RenderDynamicSliverGrid], which is the sliver where the dynamic sized +/// tiles are positioned. +class SliverGridStaggeredTileLayout extends DynamicSliverGridLayout { + /// Creates a layout with dynamic main axis extents determined by the child's + /// size and fixed cross axis extents. + /// + /// All arguments must be not null. The [crossAxisCount] argument must be + /// greater than zero. The [mainAxisSpacing], [crossAxisSpacing] and + /// [childCrossAxisExtent] arguments must not be negative. + SliverGridStaggeredTileLayout({ + required this.crossAxisCount, + required this.mainAxisSpacing, + required this.crossAxisSpacing, + required this.childCrossAxisExtent, + required this.scrollDirection, + }) : assert(crossAxisCount != null && crossAxisCount > 0), + assert(crossAxisSpacing != null && crossAxisSpacing >= 0), + assert(childCrossAxisExtent != null && childCrossAxisExtent >= 0); + + /// The number of children in the cross axis. + final int crossAxisCount; + + /// The number of logical pixels between each child along the main axis. + final double mainAxisSpacing; + + /// The number of logical pixels between each child along the cross axis. + final double crossAxisSpacing; + + /// The number of pixels from the leading edge of one tile to the trailing + /// edge of the same tile in the cross axis. + final double childCrossAxisExtent; + + /// The axis along which the scroll view scrolls. + final Axis scrollDirection; + + /// The collection of scroll offsets for every row or column across the main + /// axis. It includes the tiles' sizes and the spacing between them. + final List _scrollOffsetForMainAxis = []; + + /// The amount of tiles in every row or column across the main axis. + final List _mainAxisCount = []; + + /// Returns the row or column with the minimum extent for the next child to + /// be layed out. + int _getNextCrossAxisSlot() { + int nextCrossAxisSlot = 0; + double minScrollOffset = double.infinity; + + if (_scrollOffsetForMainAxis.length < crossAxisCount) { + nextCrossAxisSlot = _scrollOffsetForMainAxis.length; + _scrollOffsetForMainAxis.add(0.0); + return nextCrossAxisSlot; + } + + for (int i = 0; i < crossAxisCount; i++) { + if (_scrollOffsetForMainAxis[i] < minScrollOffset) { + nextCrossAxisSlot = i; + minScrollOffset = _scrollOffsetForMainAxis[i]; + } + } + return nextCrossAxisSlot; + } + + @override + bool reachedTargetScrollOffset(double targetOffset) { + for (final double scrollOffset in _scrollOffsetForMainAxis) { + if (scrollOffset < targetOffset) { + return false; + } + } + return true; + } + + @override + DynamicSliverGridGeometry getGeometryForChildIndex(int index) { + return DynamicSliverGridGeometry( + scrollOffset: 0.0, + crossAxisOffset: 0.0, + mainAxisExtent: double.infinity, + crossAxisExtent: childCrossAxisExtent, + ); + } + + @override + DynamicSliverGridGeometry updateGeometryForChildIndex( + int index, + Size childSize, + ) { + final int crossAxisSlot = _getNextCrossAxisSlot(); + final double currentScrollOffset = _scrollOffsetForMainAxis[crossAxisSlot]; + final double childMainAxisExtent = + scrollDirection == Axis.vertical ? childSize.height : childSize.width; + final double scrollOffset = currentScrollOffset + + (_mainAxisCount.length >= crossAxisCount + ? _mainAxisCount[crossAxisSlot] + : 0) * + mainAxisSpacing; + final double crossAxisOffset = + crossAxisSlot * (childCrossAxisExtent + crossAxisSpacing); + final double mainAxisExtent = + scrollDirection == Axis.vertical ? childSize.height : childSize.width; + _scrollOffsetForMainAxis[crossAxisSlot] = + childMainAxisExtent + _scrollOffsetForMainAxis[crossAxisSlot]; + _mainAxisCount.length >= crossAxisCount + ? _mainAxisCount[crossAxisSlot] += 1 + : _mainAxisCount.add(1); + + return DynamicSliverGridGeometry( + scrollOffset: scrollOffset, + crossAxisOffset: crossAxisOffset, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: childCrossAxisExtent, + ); + } +} + +/// Creates dynamic grid layouts with a fixed number of tiles in the cross axis +/// and varying main axis size dependent on the child's corresponding finite +/// extent. It uses the same logic as +/// [SliverGridDelegateWithFixedCrossAxisCount] where the total extent in the +/// cross axis is distributed equally between the specified amount of tiles, +/// but with a [SliverGridStaggeredTileLayout]. +/// +/// For example, if the grid is vertical, this delegate will create a layout +/// with a fixed number of columns. If the grid is horizontal, this delegate +/// will create a layout with a fixed number of rows. +/// +/// This sample code shows how to use it independently with a [DynamicGridView] +/// constructor: +/// +/// ```dart +/// DynamicGridView( +/// gridDelegate: const DynamicSliverGridDelegateWithFixedCrossAxisCount( +/// crossAxisCount: 4, +/// ), +/// children: List.generate( +/// 50, +/// (int index) => SizedBox( +/// height: index % 2 * 20 + 20, +/// child: Text('Index $index'), +/// ), +/// ), +/// ); +/// ``` +/// +/// See also: +/// +/// * [DynamicSliverGridDelegateWithMaxCrossAxisExtent], which creates a +/// dynamic layout with tiles that have a maximum cross-axis extent +/// and varying main axis size. +/// * [DynamicGridView], which can use this delegate to control the layout of +/// its tiles. +/// * [DynamicSliverGridGeometry], which establishes the position of a child +/// and pass the child's desired proportions to [DynamicSliverGridLayout]. +/// * [RenderDynamicSliverGrid], which is the sliver where the dynamic sized +/// tiles are positioned. +class DynamicSliverGridDelegateWithFixedCrossAxisCount + extends SliverGridDelegateWithFixedCrossAxisCount { + /// Creates a delegate that makes grid layouts with a fixed number of tiles + /// in the cross axis and varying main axis size dependent on the child's + /// corresponding finite extent. + /// + /// Only the [crossAxisCount] argument needs to be greater than zero. All of + /// them must be not null. + const DynamicSliverGridDelegateWithFixedCrossAxisCount({ + required super.crossAxisCount, + super.mainAxisSpacing = 0.0, + super.crossAxisSpacing = 0.0, + }) : assert(crossAxisCount != null && crossAxisCount > 0), + assert(mainAxisSpacing != null && mainAxisSpacing >= 0), + assert(crossAxisSpacing != null && crossAxisSpacing >= 0); + + bool _debugAssertIsValid() { + assert(crossAxisCount > 0); + assert(mainAxisSpacing >= 0.0); + assert(crossAxisSpacing >= 0.0); + assert(childAspectRatio > 0.0); + return true; + } + + @override + DynamicSliverGridLayout getLayout(SliverConstraints constraints) { + assert(_debugAssertIsValid()); + final double usableCrossAxisExtent = math.max( + 0.0, + constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1), + ); + final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount; + return SliverGridStaggeredTileLayout( + crossAxisCount: crossAxisCount, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + childCrossAxisExtent: childCrossAxisExtent, + scrollDirection: axisDirectionToAxis(constraints.axisDirection), + ); + } +} + +/// Creates dynamic grid layouts with tiles that each have a maximum cross-axis +/// extent and varying main axis size dependent on the child's corresponding +/// finite extent. It uses the same logic as +/// [SliverGridDelegateWithMaxCrossAxisExtent] where every tile has the same +/// cross axis size and does not exceed the provided max extent, but with a +/// [SliverGridStaggeredTileLayout]. +/// +/// This delegate will select a cross-axis extent for the tiles that is as +/// large as possible subject to the following conditions: +/// +/// * The extent evenly divides the cross-axis extent of the grid. +/// * The extent is at most [maxCrossAxisExtent]. +/// +/// This sample code shows how to use it independently with a [DynamicGridView] +/// constructor: +/// +/// ```dart +/// DynamicGridView( +/// gridDelegate: const DynamicSliverGridDelegateWithMaxCrossAxisExtent( +/// maxCrossAxisExtent: 100, +/// ), +/// children: List.generate( +/// 50, +/// (int index) => SizedBox( +/// height: index % 2 * 20 + 20, +/// child: Text('Index $index'), +/// ), +/// ), +/// ); +/// ``` +/// +/// See also: +/// +/// * [DynamicSliverGridDelegateWithFixedCrossAxisCount], which creates a +/// layout with a fixed number of tiles in the cross axis. +/// * [DynamicGridView], which can use this delegate to control the layout of +/// its tiles. +/// * [DynamicSliverGridGeometry], which establishes the position of a child +/// and pass the child's desired proportions to [DynamicSliverGridLayout]. +/// * [RenderDynamicSliverGrid], which is the sliver where the dynamic sized +/// tiles are positioned. +class DynamicSliverGridDelegateWithMaxCrossAxisExtent + extends SliverGridDelegateWithMaxCrossAxisExtent { + /// Creates a delegate that makes grid layouts with tiles that have a maximum + /// cross-axis extent and varying main axis size dependent on the child's + /// corresponding finite extent. + /// + /// Only the [maxCrossAxisExtent] argument needs to be greater than zero. + /// All of them must be not null. + const DynamicSliverGridDelegateWithMaxCrossAxisExtent({ + required super.maxCrossAxisExtent, + super.mainAxisSpacing = 0.0, + super.crossAxisSpacing = 0.0, + }) : assert(maxCrossAxisExtent != null && maxCrossAxisExtent > 0), + assert(mainAxisSpacing != null && mainAxisSpacing >= 0), + assert(crossAxisSpacing != null && crossAxisSpacing >= 0); + + bool _debugAssertIsValid(double crossAxisExtent) { + assert(crossAxisExtent > 0.0); + assert(maxCrossAxisExtent > 0.0); + assert(mainAxisSpacing >= 0.0); + assert(crossAxisSpacing >= 0.0); + assert(childAspectRatio > 0.0); + return true; + } + + @override + DynamicSliverGridLayout getLayout(SliverConstraints constraints) { + assert(_debugAssertIsValid(constraints.crossAxisExtent)); + final int crossAxisCount = + (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)) + .ceil(); + final double usableCrossAxisExtent = math.max( + 0.0, + constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1), + ); + final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount; + return SliverGridStaggeredTileLayout( + crossAxisCount: crossAxisCount, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + childCrossAxisExtent: childCrossAxisExtent, + scrollDirection: axisDirectionToAxis(constraints.axisDirection), + ); + } +} diff --git a/packages/dynamic_layouts/test/dynamic_grid_test.dart b/packages/dynamic_layouts/test/dynamic_grid_test.dart index 52b17dd37e3..a4005d3c9e4 100644 --- a/packages/dynamic_layouts/test/dynamic_grid_test.dart +++ b/packages/dynamic_layouts/test/dynamic_grid_test.dart @@ -97,7 +97,7 @@ void main() { ), ); - // Only the visible tiles have ben laid out. + // Only the visible tiles have been laid out. expect(find.text('Index 0'), findsOneWidget); expect(tester.getTopLeft(find.text('Index 0')), Offset.zero); expect(find.text('Index 1'), findsOneWidget); diff --git a/packages/dynamic_layouts/test/staggered_layout_test.dart b/packages/dynamic_layouts/test/staggered_layout_test.dart new file mode 100644 index 00000000000..0e0a1a307ed --- /dev/null +++ b/packages/dynamic_layouts/test/staggered_layout_test.dart @@ -0,0 +1,427 @@ +// Copyright 2013 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:dynamic_layouts/dynamic_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DynamicGridView', () { + testWidgets( + 'DynamicGridView works when using DynamicSliverGridDelegateWithFixedCrossAxisCount', + (WidgetTester tester) async { + tester.binding.window.physicalSizeTestValue = const Size(400, 100); + tester.binding.window.devicePixelRatioTestValue = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DynamicGridView( + gridDelegate: + const DynamicSliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + ), + children: List.generate( + 50, + (int index) => SizedBox( + height: index % 2 * 20 + 20, + child: Text('Index $index'), + ), + ), + ), + ), + ), + ); + + expect(find.text('Index 0'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 0')), Offset.zero); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 1')), const Offset(100.0, 0.0)); + expect(find.text('Index 2'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 2')), const Offset(200.0, 0.0)); + expect(find.text('Index 3'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 3')), const Offset(300.0, 0.0)); + expect(find.text('Index 4'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 4')), const Offset(0.0, 20.0)); + + expect(find.text('Index 14'), findsNothing); + expect(find.text('Index 47'), findsNothing); + expect(find.text('Index 48'), findsNothing); + expect(find.text('Index 49'), findsNothing); + }); + + testWidgets( + 'DynamicGridView works when using DynamicSliverGridDelegateWithMaxCrossAxisExtent', + (WidgetTester tester) async { + tester.binding.window.physicalSizeTestValue = const Size(440, 100); + tester.binding.window.devicePixelRatioTestValue = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DynamicGridView( + gridDelegate: + const DynamicSliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 100, + ), + children: List.generate( + 50, + (int index) => SizedBox( + height: index % 2 * 20 + 20, + child: Text('Index $index'), + ), + ), + ), + ), + ), + ); + + expect(find.text('Index 0'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 0')), Offset.zero); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 1')), const Offset(88.0, 0.0)); + expect(find.text('Index 2'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 2')), const Offset(176.0, 0.0)); + expect(find.text('Index 3'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 3')), const Offset(264.0, 0.0)); + expect(find.text('Index 4'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 4')), const Offset(352.0, 0.0)); + + expect(find.text('Index 47'), findsNothing); + expect(find.text('Index 48'), findsNothing); + expect(find.text('Index 49'), findsNothing); + }); + }); + group('DynamicGridView.staggered', () { + testWidgets('DynamicGridView.staggered works with simple layout', + (WidgetTester tester) async { + tester.binding.window.physicalSizeTestValue = const Size(400, 100); + tester.binding.window.devicePixelRatioTestValue = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DynamicGridView.staggered( + crossAxisCount: 4, + children: List.generate( + 50, + (int index) => SizedBox( + height: index % 2 * 50 + 20, + child: Text('Index $index'), + ), + ), + ), + ), + ), + ); + + expect(find.text('Index 0'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 0')), Offset.zero); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 1')), const Offset(100.0, 0.0)); + expect(find.text('Index 2'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 2')), const Offset(200.0, 0.0)); + expect(find.text('Index 3'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 3')), const Offset(300.0, 0.0)); + expect(find.text('Index 4'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 4')), const Offset(0.0, 20.0)); + expect(find.text('Index 5'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Index 5')), + const Offset(200.0, 20.0), + ); + expect(find.text('Index 6'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 6')), const Offset(0.0, 40.0)); + expect(find.text('Index 7'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 7')), const Offset(0.0, 60.0)); + + expect(find.text('Index 12'), findsNothing); // 100 - 120 + expect(find.text('Index 47'), findsNothing); + expect(find.text('Index 48'), findsNothing); + expect(find.text('Index 49'), findsNothing); + }); + testWidgets('DynamicGridView.staggered works with a horizontal grid', + (WidgetTester tester) async { + tester.binding.window.physicalSizeTestValue = const Size(100, 500); + tester.binding.window.devicePixelRatioTestValue = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DynamicGridView.staggered( + crossAxisCount: 4, + scrollDirection: Axis.horizontal, + children: List.generate( + 50, + (int index) => SizedBox( + width: index % 3 * 50 + 20, + child: Text('Index $index'), + ), + ), + ), + ), + ), + ); + + expect(find.text('Index 0'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 0')), Offset.zero); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 1')), const Offset(0.0, 125.0)); + expect(find.text('Index 2'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 2')), const Offset(0.0, 250.0)); + expect(find.text('Index 3'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 3')), const Offset(0.0, 375.0)); + expect(find.text('Index 4'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 4')), const Offset(20.0, 0.0)); + expect(find.text('Index 5'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Index 5')), + const Offset(20.0, 375.0), + ); + expect(find.text('Index 6'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Index 6')), + const Offset(70.0, 125.0), + ); + expect(find.text('Index 7'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Index 7')), + const Offset(90.0, 0.0), + ); + + expect(find.text('Index 47'), findsNothing); + expect(find.text('Index 48'), findsNothing); + expect(find.text('Index 49'), findsNothing); + }); + testWidgets('DynamicGridView.staggered works with a reversed grid', + (WidgetTester tester) async { + tester.binding.window.physicalSizeTestValue = const Size(600, 200); + tester.binding.window.devicePixelRatioTestValue = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DynamicGridView.staggered( + crossAxisCount: 4, + reverse: true, + children: List.generate( + 50, + (int index) => SizedBox( + height: index % 3 * 50 + 20, + child: Text('Index $index'), + ), + ), + ), + ), + ), + ); + + expect(find.text('Index 0'), findsOneWidget); + expect( + tester.getBottomLeft(find.text('Index 0')), + const Offset(0.0, 200.0), + ); + expect(find.text('Index 1'), findsOneWidget); + expect( + tester.getBottomLeft(find.text('Index 1')), + const Offset(150.0, 200.0), + ); + expect(find.text('Index 2'), findsOneWidget); + expect( + tester.getBottomLeft(find.text('Index 2')), + const Offset(300.0, 200.0), + ); + expect(find.text('Index 3'), findsOneWidget); + expect( + tester.getBottomLeft(find.text('Index 3')), + const Offset(450.0, 200.0), + ); + expect(find.text('Index 4'), findsOneWidget); + expect( + tester.getBottomLeft(find.text('Index 4')), + const Offset(0.0, 180.0), + ); + expect(find.text('Index 5'), findsOneWidget); + expect( + tester.getBottomLeft(find.text('Index 5')), + const Offset(450.0, 180.0), + ); + expect(find.text('Index 6'), findsOneWidget); + expect( + tester.getBottomLeft(find.text('Index 6')), + const Offset(150.0, 130.0), + ); + expect(find.text('Index 7'), findsOneWidget); + expect( + tester.getBottomLeft(find.text('Index 7')), + const Offset(0.0, 110.0), + ); + + expect(find.text('Index 47'), findsNothing); + expect(find.text('Index 48'), findsNothing); + expect(find.text('Index 49'), findsNothing); + }); + + testWidgets('DynamicGridView.staggered deletes children appropriately', + (WidgetTester tester) async { + tester.binding.window.physicalSizeTestValue = const Size(600, 1000); + tester.binding.window.devicePixelRatioTestValue = 1.0; + final List children = List.generate( + 50, + (int index) => SizedBox( + height: index % 3 * 50 + 20, + child: Text('Index $index'), + ), + ); + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return DynamicGridView.staggered( + maxCrossAxisExtent: 150, + children: [...children], + ); + }), + ), + ), + ); + + expect(find.text('Index 0'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 0')), Offset.zero); + expect(find.text('Index 7'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 7')), const Offset(0.0, 90.0)); + expect(find.text('Index 8'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Index 8')), + const Offset(150.0, 90.0), + ); + expect(find.text('Index 27'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Index 27')), + const Offset(300.0, 420.0), + ); + expect(find.text('Index 28'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Index 28')), + const Offset(300.0, 440.0), + ); + expect(find.text('Index 32'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Index 32')), + const Offset(300.0, 510.0), + ); + expect(find.text('Index 33'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Index 33')), + const Offset(150.0, 540.0), + ); + + stateSetter(() { + children.removeAt(0); + }); + + await tester.pump(); + expect(find.text('Index 0'), findsNothing); + + expect( + tester.getTopLeft(find.text('Index 8')), + const Offset(0.0, 90.0), + ); + expect( + tester.getTopLeft(find.text('Index 28')), + const Offset(150.0, 440.0), + ); + expect( + tester.getTopLeft(find.text('Index 33')), + const Offset(0.0, 540.0), + ); + }); + }); + group('DynamicGridView.builder', () { + testWidgets('DynamicGridView.builder works with a staggered layout', + (WidgetTester tester) async { + tester.binding.window.physicalSizeTestValue = const Size(400, 100); + tester.binding.window.devicePixelRatioTestValue = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DynamicGridView.builder( + gridDelegate: + const DynamicSliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + ), + itemBuilder: (BuildContext context, int index) => SizedBox( + height: index % 2 * 50 + 20, + child: Text('Index $index'), + ), + itemCount: 50, + ), + ), + ), + ); + + expect(find.text('Index 0'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 0')), Offset.zero); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 1')), const Offset(100.0, 0.0)); + expect(find.text('Index 2'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 2')), const Offset(200.0, 0.0)); + expect(find.text('Index 3'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 3')), const Offset(300.0, 0.0)); + expect(find.text('Index 4'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 4')), const Offset(0.0, 20.0)); + expect(find.text('Index 5'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Index 5')), + const Offset(200.0, 20.0), + ); + expect(find.text('Index 6'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 6')), const Offset(0.0, 40.0)); + expect(find.text('Index 7'), findsOneWidget); + expect(tester.getTopLeft(find.text('Index 7')), const Offset(0.0, 60.0)); + + expect(find.text('Index 12'), findsNothing); // 100 - 120 + expect(find.text('Index 47'), findsNothing); + expect(find.text('Index 48'), findsNothing); + expect(find.text('Index 49'), findsNothing); + }); + + testWidgets( + 'DynamicGridView.builder works with an infinite grid using a staggered layout', + (WidgetTester tester) async { + tester.binding.window.physicalSizeTestValue = const Size(400, 100); + tester.binding.window.devicePixelRatioTestValue = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DynamicGridView.builder( + gridDelegate: + const DynamicSliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + ), + itemBuilder: (BuildContext context, int index) => SizedBox( + height: index % 2 * 50 + 20, + child: Text('Index $index'), + ), + ), + ), + ), + ); + + expect(find.text('Index 0'), findsOneWidget); + expect(find.text('Index 1'), findsOneWidget); + expect(find.text('Index 2'), findsOneWidget); + await tester.scrollUntilVisible(find.text('Index 500'), 500.0); + await tester.pumpAndSettle(); + expect(find.text('Index 501'), findsOneWidget); + expect(find.text('Index 502'), findsOneWidget); + }); + }); +}