Add SliverAnimatedGrid
and AnimatedGrid
(#112982)
This commit is contained in:
parent
78c233054c
commit
7523ab5cc1
231
examples/api/lib/widgets/animated_grid/animated_grid.0.dart
Normal file
231
examples/api/lib/widgets/animated_grid/animated_grid.0.dart
Normal file
@ -0,0 +1,231 @@
|
||||
// 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.
|
||||
|
||||
/// Flutter code sample for [AnimatedGrid].
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const AnimatedGridSample());
|
||||
}
|
||||
|
||||
class AnimatedGridSample extends StatefulWidget {
|
||||
const AnimatedGridSample({super.key});
|
||||
|
||||
@override
|
||||
State<AnimatedGridSample> createState() => _AnimatedGridSampleState();
|
||||
}
|
||||
|
||||
class _AnimatedGridSampleState extends State<AnimatedGridSample> {
|
||||
final GlobalKey<AnimatedGridState> _gridKey = GlobalKey<AnimatedGridState>();
|
||||
late ListModel<int> _list;
|
||||
int? _selectedItem;
|
||||
late int _nextItem; // The next item inserted when the user presses the '+' button.
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_list = ListModel<int>(
|
||||
listKey: _gridKey,
|
||||
initialItems: <int>[0, 1, 2, 3, 4, 5],
|
||||
removedItemBuilder: _buildRemovedItem,
|
||||
);
|
||||
_nextItem = 6;
|
||||
}
|
||||
|
||||
// Used to build list items that haven't been removed.
|
||||
Widget _buildItem(BuildContext context, int index, Animation<double> animation) {
|
||||
return CardItem(
|
||||
animation: animation,
|
||||
item: _list[index],
|
||||
selected: _selectedItem == _list[index],
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedItem = _selectedItem == _list[index] ? null : _list[index];
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Used to build an item after it has been removed from the list. This method
|
||||
// is needed because a removed item remains visible until its animation has
|
||||
// completed (even though it's gone as far as this ListModel is concerned).
|
||||
// The widget will be used by the [AnimatedGridState.removeItem] method's
|
||||
// [AnimatedGridRemovedItemBuilder] parameter.
|
||||
Widget _buildRemovedItem(int item, BuildContext context, Animation<double> animation) {
|
||||
return CardItem(
|
||||
animation: animation,
|
||||
item: item,
|
||||
removing: true,
|
||||
// No gesture detector here: we don't want removed items to be interactive.
|
||||
);
|
||||
}
|
||||
|
||||
// Insert the "next item" into the list model.
|
||||
void _insert() {
|
||||
final int index = _selectedItem == null ? _list.length : _list.indexOf(_selectedItem!);
|
||||
setState(() {
|
||||
_list.insert(index, _nextItem++);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the selected item from the list model.
|
||||
void _remove() {
|
||||
if (_selectedItem != null) {
|
||||
setState(() {
|
||||
_list.removeAt(_list.indexOf(_selectedItem!));
|
||||
_selectedItem = null;
|
||||
});
|
||||
} else if (_list.length > 0) {
|
||||
setState(() {
|
||||
_list.removeAt(_list.length - 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'AnimatedGrid',
|
||||
style: TextStyle(fontSize: 30),
|
||||
),
|
||||
centerTitle: true,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.remove_circle),
|
||||
iconSize: 32,
|
||||
onPressed: (_list.length > 0) ? _remove : null,
|
||||
tooltip: 'remove the selected item',
|
||||
),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle),
|
||||
iconSize: 32,
|
||||
onPressed: _insert,
|
||||
tooltip: 'insert a new item',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: AnimatedGrid(
|
||||
key: _gridKey,
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
),
|
||||
initialItemCount: _list.length,
|
||||
itemBuilder: _buildItem,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef RemovedItemBuilder<T> = Widget Function(T item, BuildContext context, Animation<double> animation);
|
||||
|
||||
/// Keeps a Dart [List] in sync with an [AnimatedGrid].
|
||||
///
|
||||
/// The [insert] and [removeAt] methods apply to both the internal list and
|
||||
/// the animated list that belongs to [listKey].
|
||||
///
|
||||
/// This class only exposes as much of the Dart List API as is needed by the
|
||||
/// sample app. More list methods are easily added, however methods that
|
||||
/// mutate the list must make the same changes to the animated list in terms
|
||||
/// of [AnimatedGridState.insertItem] and [AnimatedGrid.removeItem].
|
||||
class ListModel<E> {
|
||||
ListModel({
|
||||
required this.listKey,
|
||||
required this.removedItemBuilder,
|
||||
Iterable<E>? initialItems,
|
||||
}) : _items = List<E>.from(initialItems ?? <E>[]);
|
||||
|
||||
final GlobalKey<AnimatedGridState> listKey;
|
||||
final RemovedItemBuilder<E> removedItemBuilder;
|
||||
final List<E> _items;
|
||||
|
||||
AnimatedGridState? get _animatedGrid => listKey.currentState;
|
||||
|
||||
void insert(int index, E item) {
|
||||
_items.insert(index, item);
|
||||
_animatedGrid!.insertItem(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
);
|
||||
}
|
||||
|
||||
E removeAt(int index) {
|
||||
final E removedItem = _items.removeAt(index);
|
||||
if (removedItem != null) {
|
||||
_animatedGrid!.removeItem(
|
||||
index,
|
||||
(BuildContext context, Animation<double> animation) {
|
||||
return removedItemBuilder(removedItem, context, animation);
|
||||
},
|
||||
);
|
||||
}
|
||||
return removedItem;
|
||||
}
|
||||
|
||||
int get length => _items.length;
|
||||
|
||||
E operator [](int index) => _items[index];
|
||||
|
||||
int indexOf(E item) => _items.indexOf(item);
|
||||
}
|
||||
|
||||
/// Displays its integer item as 'item N' on a Card whose color is based on
|
||||
/// the item's value.
|
||||
///
|
||||
/// The text is displayed in bright green if [selected] is
|
||||
/// true. This widget's height is based on the [animation] parameter, it
|
||||
/// varies from 0 to 128 as the animation varies from 0.0 to 1.0.
|
||||
class CardItem extends StatelessWidget {
|
||||
const CardItem({
|
||||
super.key,
|
||||
this.onTap,
|
||||
this.selected = false,
|
||||
this.removing = false,
|
||||
required this.animation,
|
||||
required this.item,
|
||||
}) : assert(item >= 0);
|
||||
|
||||
final Animation<double> animation;
|
||||
final VoidCallback? onTap;
|
||||
final int item;
|
||||
final bool selected;
|
||||
final bool removing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
TextStyle textStyle = Theme.of(context).textTheme.headlineMedium!;
|
||||
if (selected) {
|
||||
textStyle = textStyle.copyWith(color: Colors.lightGreenAccent[400]);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: ScaleTransition(
|
||||
scale: CurvedAnimation(parent: animation, curve: removing ? Curves.easeInOut : Curves.bounceOut),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 80.0,
|
||||
child: Card(
|
||||
color: Colors.primaries[item % Colors.primaries.length],
|
||||
child: Center(
|
||||
child: Text('${item + 1}', style: textStyle),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,229 @@
|
||||
// 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.
|
||||
|
||||
/// Flutter code sample for [SliverAnimatedGrid].
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void main() => runApp(const SliverAnimatedGridSample());
|
||||
|
||||
class SliverAnimatedGridSample extends StatefulWidget {
|
||||
const SliverAnimatedGridSample({super.key});
|
||||
|
||||
@override
|
||||
State<SliverAnimatedGridSample> createState() => _SliverAnimatedGridSampleState();
|
||||
}
|
||||
|
||||
class _SliverAnimatedGridSampleState extends State<SliverAnimatedGridSample> {
|
||||
final GlobalKey<SliverAnimatedGridState> _listKey = GlobalKey<SliverAnimatedGridState>();
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
late ListModel<int> _list;
|
||||
int? _selectedItem;
|
||||
late int _nextItem; // The next item inserted when the user presses the '+' button.
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_list = ListModel<int>(
|
||||
listKey: _listKey,
|
||||
initialItems: <int>[0, 1, 2, 3, 4, 5],
|
||||
removedItemBuilder: _buildRemovedItem,
|
||||
);
|
||||
_nextItem = 6;
|
||||
}
|
||||
|
||||
// Used to build list items that haven't been removed.
|
||||
Widget _buildItem(BuildContext context, int index, Animation<double> animation) {
|
||||
return CardItem(
|
||||
animation: animation,
|
||||
item: _list[index],
|
||||
selected: _selectedItem == _list[index],
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedItem = _selectedItem == _list[index] ? null : _list[index];
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Used to build an item after it has been removed from the list. This
|
||||
// method is needed because a removed item remains visible until its
|
||||
// animation has completed (even though it's gone as far this ListModel is
|
||||
// concerned). The widget will be used by the
|
||||
// [AnimatedGridState.removeItem] method's
|
||||
// [AnimatedGridRemovedItemBuilder] parameter.
|
||||
Widget _buildRemovedItem(int item, BuildContext context, Animation<double> animation) {
|
||||
return CardItem(
|
||||
animation: animation,
|
||||
removing: true,
|
||||
item: item,
|
||||
);
|
||||
}
|
||||
|
||||
// Insert the "next item" into the list model.
|
||||
void _insert() {
|
||||
final int index = _selectedItem == null ? _list.length : _list.indexOf(_selectedItem!);
|
||||
_list.insert(index, _nextItem++);
|
||||
}
|
||||
|
||||
// Remove the selected item from the list model.
|
||||
void _remove() {
|
||||
if (_selectedItem != null) {
|
||||
_list.removeAt(_list.indexOf(_selectedItem!));
|
||||
} else {
|
||||
_list.removeAt(_list.length - 1);
|
||||
}
|
||||
setState(() {
|
||||
_selectedItem = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
key: _scaffoldKey,
|
||||
body: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
title: const Text(
|
||||
'SliverAnimatedGrid',
|
||||
style: TextStyle(fontSize: 30),
|
||||
),
|
||||
expandedHeight: 60,
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.amber[900],
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.remove_circle),
|
||||
onPressed: _remove,
|
||||
tooltip: 'Remove the selected item, or the last item if none selected.',
|
||||
iconSize: 32,
|
||||
),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle),
|
||||
onPressed: _insert,
|
||||
tooltip: 'Insert a new item.',
|
||||
iconSize: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverAnimatedGrid(
|
||||
key: _listKey,
|
||||
initialItemCount: _list.length,
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
),
|
||||
itemBuilder: _buildItem,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef RemovedItemBuilder = Widget Function(int item, BuildContext context, Animation<double> animation);
|
||||
|
||||
// Keeps a Dart [List] in sync with an [AnimatedGrid].
|
||||
//
|
||||
// The [insert] and [removeAt] methods apply to both the internal list and
|
||||
// the animated list that belongs to [listKey].
|
||||
//
|
||||
// This class only exposes as much of the Dart List API as is needed by the
|
||||
// sample app. More list methods are easily added, however methods that
|
||||
// mutate the list must make the same changes to the animated list in terms
|
||||
// of [AnimatedGridState.insertItem] and [AnimatedGrid.removeItem].
|
||||
class ListModel<E> {
|
||||
ListModel({
|
||||
required this.listKey,
|
||||
required this.removedItemBuilder,
|
||||
Iterable<E>? initialItems,
|
||||
}) : _items = List<E>.from(initialItems ?? <E>[]);
|
||||
|
||||
final GlobalKey<SliverAnimatedGridState> listKey;
|
||||
final RemovedItemBuilder removedItemBuilder;
|
||||
final List<E> _items;
|
||||
|
||||
SliverAnimatedGridState get _animatedGrid => listKey.currentState!;
|
||||
|
||||
void insert(int index, E item) {
|
||||
_items.insert(index, item);
|
||||
_animatedGrid.insertItem(index);
|
||||
}
|
||||
|
||||
E removeAt(int index) {
|
||||
final E removedItem = _items.removeAt(index);
|
||||
if (removedItem != null) {
|
||||
_animatedGrid.removeItem(
|
||||
index,
|
||||
(BuildContext context, Animation<double> animation) => removedItemBuilder(index, context, animation),
|
||||
);
|
||||
}
|
||||
return removedItem;
|
||||
}
|
||||
|
||||
int get length => _items.length;
|
||||
|
||||
E operator [](int index) => _items[index];
|
||||
|
||||
int indexOf(E item) => _items.indexOf(item);
|
||||
}
|
||||
|
||||
// Displays its integer item as 'Item N' on a Card whose color is based on
|
||||
// the item's value.
|
||||
//
|
||||
// The card turns gray when [selected] is true. This widget's height
|
||||
// is based on the [animation] parameter. It varies as the animation value
|
||||
// transitions from 0.0 to 1.0.
|
||||
class CardItem extends StatelessWidget {
|
||||
const CardItem({
|
||||
super.key,
|
||||
this.onTap,
|
||||
this.selected = false,
|
||||
this.removing = false,
|
||||
required this.animation,
|
||||
required this.item,
|
||||
}) : assert(item >= 0);
|
||||
|
||||
final Animation<double> animation;
|
||||
final VoidCallback? onTap;
|
||||
final int item;
|
||||
final bool selected;
|
||||
final bool removing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 2.0,
|
||||
right: 2.0,
|
||||
top: 2.0,
|
||||
),
|
||||
child: ScaleTransition(
|
||||
scale: CurvedAnimation(parent: animation, curve: removing ? Curves.easeInOut : Curves.bounceOut),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 80.0,
|
||||
child: Card(
|
||||
color: selected ? Colors.black12 : Colors.primaries[item % Colors.primaries.length],
|
||||
child: Center(
|
||||
child: Text(
|
||||
(item + 1).toString(),
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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_api_samples/widgets/animated_grid/animated_grid.0.dart'
|
||||
as example;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('AnimatedGrid example', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const example.AnimatedGridSample(),
|
||||
);
|
||||
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.text('4'), findsOneWidget);
|
||||
expect(find.text('5'), findsOneWidget);
|
||||
expect(find.text('6'), findsOneWidget);
|
||||
expect(find.text('7'), findsNothing);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add_circle));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('7'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.remove_circle));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('7'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('2'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.remove_circle));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2'), findsNothing);
|
||||
expect(find.text('6'), findsOneWidget);
|
||||
});
|
||||
}
|
@ -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_api_samples/widgets/animated_grid/sliver_animated_grid.0.dart'
|
||||
as example;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('SliverAnimatedGrid example', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const example.SliverAnimatedGridSample(),
|
||||
);
|
||||
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.text('4'), findsOneWidget);
|
||||
expect(find.text('5'), findsOneWidget);
|
||||
expect(find.text('6'), findsOneWidget);
|
||||
expect(find.text('7'), findsNothing);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add_circle));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('7'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.remove_circle));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('7'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('2'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.remove_circle));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2'), findsNothing);
|
||||
expect(find.text('6'), findsOneWidget);
|
||||
});
|
||||
}
|
@ -1609,9 +1609,8 @@ class ThemeData with Diagnosticable {
|
||||
/// Obsolete property that was originally used as the foreground
|
||||
/// color for widgets (knobs, text, overscroll edge effect, etc).
|
||||
///
|
||||
/// The material library no longer uses this property. In most cases
|
||||
/// the theme's [colorScheme] [ColorScheme.secondary] property is now
|
||||
/// used instead.
|
||||
/// The material library no longer uses this property. In most cases the
|
||||
/// [colorScheme]'s [ColorScheme.secondary] property is now used instead.
|
||||
///
|
||||
/// Apps should migrate uses of this property to the theme's [colorScheme]
|
||||
/// [ColorScheme.secondary] color. In cases where a color is needed that
|
||||
|
@ -461,7 +461,10 @@ class SliverGridDelegateWithMaxCrossAxisExtent extends SliverGridDelegate {
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
assert(_debugAssertIsValid(constraints.crossAxisExtent));
|
||||
final int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
|
||||
int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
|
||||
// TODO(gspencergoog): Figure out why we need this in release mode (and only
|
||||
// in release mode). https://github.com/flutter/flutter/issues/113109
|
||||
crossAxisCount = crossAxisCount < 1 ? 1 : crossAxisCount;
|
||||
final double usableCrossAxisExtent = math.max(
|
||||
0.0,
|
||||
constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1),
|
||||
@ -584,8 +587,6 @@ class RenderSliverGrid extends RenderSliverMultiBoxAdaptor {
|
||||
}
|
||||
|
||||
final SliverGridGeometry firstChildGridGeometry = layout.getGeometryForChildIndex(firstIndex);
|
||||
final double leadingScrollOffset = firstChildGridGeometry.scrollOffset;
|
||||
double trailingScrollOffset = firstChildGridGeometry.trailingScrollOffset;
|
||||
|
||||
if (firstChild == null) {
|
||||
if (!addInitialChild(index: firstIndex, layoutOffset: firstChildGridGeometry.scrollOffset)) {
|
||||
@ -600,6 +601,8 @@ class RenderSliverGrid extends RenderSliverMultiBoxAdaptor {
|
||||
}
|
||||
}
|
||||
|
||||
final double leadingScrollOffset = firstChildGridGeometry.scrollOffset;
|
||||
double trailingScrollOffset = firstChildGridGeometry.trailingScrollOffset;
|
||||
RenderBox? trailingChildWithLayout;
|
||||
|
||||
for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) {
|
||||
|
721
packages/flutter/lib/src/widgets/animated_grid.dart
Normal file
721
packages/flutter/lib/src/widgets/animated_grid.dart
Normal file
@ -0,0 +1,721 @@
|
||||
// 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/foundation.dart';
|
||||
|
||||
import 'basic.dart';
|
||||
import 'framework.dart';
|
||||
import 'scroll_controller.dart';
|
||||
import 'scroll_physics.dart';
|
||||
import 'scroll_view.dart';
|
||||
import 'sliver.dart';
|
||||
import 'ticker_provider.dart';
|
||||
|
||||
/// Signature for the builder callback used by widgets like [AnimatedGrid] to
|
||||
/// build their animated children.
|
||||
///
|
||||
/// The `context` argument is the build context where the widget will be
|
||||
/// created, the `index` is the index of the item to be built, and the
|
||||
/// `animation` is an [Animation] that should be used to animate an entry
|
||||
/// transition for the widget that is built.
|
||||
///
|
||||
/// - [AnimatedRemovedItemBuilder], a builder that is for removing items with
|
||||
/// animations instead of adding them.
|
||||
typedef AnimatedItemBuilder = Widget Function(BuildContext context, int index, Animation<double> animation);
|
||||
|
||||
/// Signature for the builder callback used by widgets like [AnimatedGrid] (in
|
||||
/// [AnimatedGridState.removeItem]) to animated their children after they have
|
||||
/// been removed.
|
||||
///
|
||||
/// The `context` argument is the build context where the widget will be
|
||||
/// created, and the `animation` is an [Animation] that should be used to
|
||||
/// animate an exit transition for the widget that is built.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// - [AnimatedItemBuilder], a builder that is for adding items with animations
|
||||
/// instead of removing them.
|
||||
typedef AnimatedRemovedItemBuilder = Widget Function(BuildContext context, Animation<double> animation);
|
||||
|
||||
// The default insert/remove animation duration.
|
||||
const Duration _kDuration = Duration(milliseconds: 300);
|
||||
|
||||
// Incoming and outgoing AnimatedGrid items.
|
||||
class _ActiveItem implements Comparable<_ActiveItem> {
|
||||
_ActiveItem.incoming(this.controller, this.itemIndex) : removedItemBuilder = null;
|
||||
_ActiveItem.outgoing(this.controller, this.itemIndex, this.removedItemBuilder);
|
||||
_ActiveItem.index(this.itemIndex)
|
||||
: controller = null,
|
||||
removedItemBuilder = null;
|
||||
|
||||
final AnimationController? controller;
|
||||
final AnimatedRemovedItemBuilder? removedItemBuilder;
|
||||
int itemIndex;
|
||||
|
||||
@override
|
||||
int compareTo(_ActiveItem other) => itemIndex - other.itemIndex;
|
||||
}
|
||||
|
||||
/// A scrolling container that animates items when they are inserted or removed
|
||||
/// in a grid.
|
||||
///
|
||||
/// This widget's [AnimatedGridState] can be used to dynamically insert or
|
||||
/// remove items. To refer to the [AnimatedGridState] either provide a
|
||||
/// [GlobalKey] or use the static [of] method from an item's input callback.
|
||||
///
|
||||
/// This widget is similar to one created by [GridView.builder].
|
||||
///
|
||||
/// {@tool dartpad}
|
||||
/// This sample application uses an [AnimatedGrid] to create an effect when
|
||||
/// items are removed or added to the grid.
|
||||
///
|
||||
/// ** See code in examples/api/lib/widgets/animated_grid/animated_grid.0.dart **
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SliverAnimatedGrid], a sliver which animates items when they are inserted
|
||||
/// into or removed from a grid.
|
||||
/// * [SliverAnimatedList], a sliver which animates items added and removed from
|
||||
/// a list instead of a grid.
|
||||
/// * [AnimatedList], which animates items added and removed from a list instead
|
||||
/// of a grid.
|
||||
class AnimatedGrid extends StatefulWidget {
|
||||
/// Creates a scrolling container that animates items when they are inserted
|
||||
/// or removed.
|
||||
const AnimatedGrid({
|
||||
super.key,
|
||||
required this.itemBuilder,
|
||||
required this.gridDelegate,
|
||||
this.initialItemCount = 0,
|
||||
this.scrollDirection = Axis.vertical,
|
||||
this.reverse = false,
|
||||
this.controller,
|
||||
this.primary,
|
||||
this.physics,
|
||||
this.padding,
|
||||
this.clipBehavior = Clip.hardEdge,
|
||||
}) : assert(itemBuilder != null),
|
||||
assert(initialItemCount != null && initialItemCount >= 0);
|
||||
|
||||
/// Called, as needed, to build grid item widgets.
|
||||
///
|
||||
/// Grid items are only built when they're scrolled into view.
|
||||
///
|
||||
/// The [AnimatedItemBuilder] index parameter indicates the item's position in
|
||||
/// the grid. The value of the index parameter will be between 0 and
|
||||
/// [initialItemCount] plus the total number of items that have been inserted
|
||||
/// with [AnimatedGridState.insertItem] and less the total number of items
|
||||
/// that have been removed with [AnimatedGridState.removeItem].
|
||||
///
|
||||
/// Implementations of this callback should assume that
|
||||
/// [AnimatedGridState.removeItem] removes an item immediately.
|
||||
final AnimatedItemBuilder itemBuilder;
|
||||
|
||||
/// A delegate that controls the layout of the children within the
|
||||
/// [AnimatedGrid].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SliverGridDelegateWithFixedCrossAxisCount], which creates a layout with
|
||||
/// a fixed number of tiles in the cross axis.
|
||||
/// * [SliverGridDelegateWithMaxCrossAxisExtent], which creates a layout with
|
||||
/// tiles that have a maximum cross-axis extent.
|
||||
final SliverGridDelegate gridDelegate;
|
||||
|
||||
/// {@template flutter.widgets.AnimatedGrid.initialItemCount}
|
||||
/// The number of items the grid will start with.
|
||||
///
|
||||
/// The appearance of the initial items is not animated. They
|
||||
/// are created, as needed, by [itemBuilder] with an animation parameter
|
||||
/// of [kAlwaysCompleteAnimation].
|
||||
/// {@endtemplate}
|
||||
final int initialItemCount;
|
||||
|
||||
/// The axis along which the scroll view scrolls.
|
||||
///
|
||||
/// Defaults to [Axis.vertical].
|
||||
final Axis scrollDirection;
|
||||
|
||||
/// Whether the scroll view scrolls in the reading direction.
|
||||
///
|
||||
/// For example, if the reading direction is left-to-right and
|
||||
/// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from
|
||||
/// left to right when [reverse] is false and from right to left when
|
||||
/// [reverse] is true.
|
||||
///
|
||||
/// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view
|
||||
/// scrolls from top to bottom when [reverse] is false and from bottom to top
|
||||
/// when [reverse] is true.
|
||||
///
|
||||
/// Defaults to false.
|
||||
final bool reverse;
|
||||
|
||||
/// An object that can be used to control the position to which this scroll
|
||||
/// view is scrolled.
|
||||
///
|
||||
/// Must be null if [primary] is true.
|
||||
///
|
||||
/// A [ScrollController] serves several purposes. It can be used to control
|
||||
/// the initial scroll position (see [ScrollController.initialScrollOffset]).
|
||||
/// It can be used to control whether the scroll view should automatically
|
||||
/// save and restore its scroll position in the [PageStorage] (see
|
||||
/// [ScrollController.keepScrollOffset]). It can be used to read the current
|
||||
/// scroll position (see [ScrollController.offset]), or change it (see
|
||||
/// [ScrollController.animateTo]).
|
||||
final ScrollController? controller;
|
||||
|
||||
/// Whether this is the primary scroll view associated with the parent
|
||||
/// [PrimaryScrollController].
|
||||
///
|
||||
/// On iOS, this identifies the scroll view that will scroll to top in
|
||||
/// response to a tap in the status bar.
|
||||
///
|
||||
/// Defaults to true when [scrollDirection] is [Axis.vertical] and
|
||||
/// [controller] is null.
|
||||
final bool? primary;
|
||||
|
||||
/// How the scroll view should respond to user input.
|
||||
///
|
||||
/// For example, determines how the scroll view continues to animate after the
|
||||
/// user stops dragging the scroll view.
|
||||
///
|
||||
/// Defaults to matching platform conventions.
|
||||
final ScrollPhysics? physics;
|
||||
|
||||
/// The amount of space by which to inset the children.
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// {@macro flutter.material.Material.clipBehavior}
|
||||
///
|
||||
/// Defaults to [Clip.hardEdge].
|
||||
final Clip clipBehavior;
|
||||
|
||||
/// The state from the closest instance of this class that encloses the given
|
||||
/// context.
|
||||
///
|
||||
/// This method is typically used by [AnimatedGrid] item widgets that insert
|
||||
/// or remove items in response to user input.
|
||||
///
|
||||
/// If no [AnimatedGrid] surrounds the context given, then this function will
|
||||
/// assert in debug mode and throw an exception in release mode.
|
||||
///
|
||||
/// This method can be expensive (it walks the element tree).
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [maybeOf], a similar function that will return null if no
|
||||
/// [AnimatedGrid] ancestor is found.
|
||||
static AnimatedGridState of(BuildContext context) {
|
||||
assert(context != null);
|
||||
final AnimatedGridState? result = context.findAncestorStateOfType<AnimatedGridState>();
|
||||
assert(() {
|
||||
if (result == null) {
|
||||
throw FlutterError.fromParts(<DiagnosticsNode>[
|
||||
ErrorSummary('AnimatedGrid.of() called with a context that does not contain an AnimatedGrid.'),
|
||||
ErrorDescription(
|
||||
'No AnimatedGrid ancestor could be found starting from the context that was passed to AnimatedGrid.of().',
|
||||
),
|
||||
ErrorHint(
|
||||
'This can happen when the context provided is from the same StatefulWidget that '
|
||||
'built the AnimatedGrid. Please see the AnimatedGrid documentation for examples '
|
||||
'of how to refer to an AnimatedGridState object:\n'
|
||||
' https://api.flutter.dev/flutter/widgets/AnimatedGridState-class.html',
|
||||
),
|
||||
context.describeElement('The context used was'),
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
return result!;
|
||||
}
|
||||
|
||||
/// The state from the closest instance of this class that encloses the given
|
||||
/// context.
|
||||
///
|
||||
/// This method is typically used by [AnimatedGrid] item widgets that insert
|
||||
/// or remove items in response to user input.
|
||||
///
|
||||
/// If no [AnimatedGrid] surrounds the context given, then this function will
|
||||
/// return null.
|
||||
///
|
||||
/// This method can be expensive (it walks the element tree).
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [of], a similar function that will throw if no [AnimatedGrid] ancestor
|
||||
/// is found.
|
||||
static AnimatedGridState? maybeOf(BuildContext context) {
|
||||
assert(context != null);
|
||||
return context.findAncestorStateOfType<AnimatedGridState>();
|
||||
}
|
||||
|
||||
@override
|
||||
AnimatedGridState createState() => AnimatedGridState();
|
||||
}
|
||||
|
||||
/// The state for a scrolling container that animates items when they are
|
||||
/// inserted or removed.
|
||||
///
|
||||
/// When an item is inserted with [insertItem] an animation begins running. The
|
||||
/// animation is passed to [AnimatedGrid.itemBuilder] whenever the item's widget
|
||||
/// is needed.
|
||||
///
|
||||
/// When an item is removed with [removeItem] its animation is reversed.
|
||||
/// The removed item's animation is passed to the [removeItem] builder
|
||||
/// parameter.
|
||||
///
|
||||
/// An app that needs to insert or remove items in response to an event
|
||||
/// can refer to the [AnimatedGrid]'s state with a global key:
|
||||
///
|
||||
/// ```dart
|
||||
/// // (e.g. in a stateful widget)
|
||||
/// GlobalKey<AnimatedGridState> gridKey = GlobalKey<AnimatedGridState>();
|
||||
///
|
||||
/// // ...
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return AnimatedGrid(
|
||||
/// key: gridKey,
|
||||
/// itemBuilder: (BuildContext context, int index, Animation<double> animation) {
|
||||
/// return const Placeholder();
|
||||
/// },
|
||||
/// gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 100.0),
|
||||
/// );
|
||||
/// }
|
||||
///
|
||||
/// // ...
|
||||
///
|
||||
/// void _updateGrid() {
|
||||
/// // adds "123" to the AnimatedGrid
|
||||
/// gridKey.currentState!.insertItem(123);
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// [AnimatedGrid] item input handlers can also refer to their [AnimatedGridState]
|
||||
/// with the static [AnimatedGrid.of] method.
|
||||
class AnimatedGridState extends State<AnimatedGrid> with TickerProviderStateMixin<AnimatedGrid> {
|
||||
final GlobalKey<SliverAnimatedGridState> _sliverAnimatedGridKey = GlobalKey();
|
||||
|
||||
/// Insert an item at [index] and start an animation that will be passed
|
||||
/// to [AnimatedGrid.itemBuilder] when the item is visible.
|
||||
///
|
||||
/// This method's semantics are the same as Dart's [List.insert] method: it
|
||||
/// increases the length of the list of items in the grid by one and shifts
|
||||
/// all items at or after [index] towards the end of the list of items in the
|
||||
/// grid.
|
||||
void insertItem(int index, {Duration duration = _kDuration}) {
|
||||
_sliverAnimatedGridKey.currentState!.insertItem(index, duration: duration);
|
||||
}
|
||||
|
||||
/// Remove the item at `index` and start an animation that will be passed to
|
||||
/// `builder` when the item is visible.
|
||||
///
|
||||
/// Items are removed immediately. After an item has been removed, its index
|
||||
/// will no longer be passed to the [AnimatedGrid.itemBuilder]. However, the
|
||||
/// item will still appear in the grid for `duration` and during that time
|
||||
/// `builder` must construct its widget as needed.
|
||||
///
|
||||
/// This method's semantics are the same as Dart's [List.remove] method: it
|
||||
/// decreases the length of the list of items in the grid by one and shifts
|
||||
/// all items at or before `index` towards the beginning of the list of items
|
||||
/// in the grid.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// - [AnimatedRemovedItemBuilder], which describes the arguments to the
|
||||
/// `builder` argument.
|
||||
void removeItem(int index, AnimatedRemovedItemBuilder builder, {Duration duration = _kDuration}) {
|
||||
_sliverAnimatedGridKey.currentState!.removeItem(index, builder, duration: duration);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
scrollDirection: widget.scrollDirection,
|
||||
reverse: widget.reverse,
|
||||
controller: widget.controller,
|
||||
primary: widget.primary,
|
||||
physics: widget.physics,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
slivers: <Widget>[
|
||||
SliverPadding(
|
||||
padding: widget.padding ?? EdgeInsets.zero,
|
||||
sliver: SliverAnimatedGrid(
|
||||
key: _sliverAnimatedGridKey,
|
||||
gridDelegate: widget.gridDelegate,
|
||||
itemBuilder: widget.itemBuilder,
|
||||
initialItemCount: widget.initialItemCount,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A sliver that animates items when they are inserted or removed in a grid.
|
||||
///
|
||||
/// This widget's [SliverAnimatedGridState] can be used to dynamically insert or
|
||||
/// remove items. To refer to the [SliverAnimatedGridState] either provide a
|
||||
/// [GlobalKey] or use the static [SliverAnimatedGrid.of] method from an item's
|
||||
/// input callback.
|
||||
///
|
||||
/// {@tool dartpad}
|
||||
/// This sample application uses a [SliverAnimatedGrid] to create an animated
|
||||
/// effect when items are removed or added to the grid.
|
||||
///
|
||||
/// ** See code in examples/api/lib/widgets/animated_grid/sliver_animated_grid.0.dart **
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AnimatedGrid], a non-sliver scrolling container that animates items when
|
||||
/// they are inserted into or removed from a grid.
|
||||
/// * [SliverGrid], which does not animate items when they are inserted or
|
||||
/// removed from a grid.
|
||||
/// * [SliverList], which displays a non-animated list of items.
|
||||
/// * [SliverAnimatedList], which animates items added and removed from a list
|
||||
/// instead of a grid.
|
||||
class SliverAnimatedGrid extends StatefulWidget {
|
||||
/// Creates a sliver that animates items when they are inserted or removed.
|
||||
const SliverAnimatedGrid({
|
||||
super.key,
|
||||
required this.itemBuilder,
|
||||
required this.gridDelegate,
|
||||
this.findChildIndexCallback,
|
||||
this.initialItemCount = 0,
|
||||
}) : assert(itemBuilder != null),
|
||||
assert(initialItemCount != null && initialItemCount >= 0);
|
||||
|
||||
/// Called, as needed, to build grid item widgets.
|
||||
///
|
||||
/// Grid items are only built when they're scrolled into view.
|
||||
///
|
||||
/// The [AnimatedItemBuilder] index parameter indicates the item's position in
|
||||
/// the grid. The value of the index parameter will be between 0 and
|
||||
/// [initialItemCount] plus the total number of items that have been inserted
|
||||
/// with [SliverAnimatedGridState.insertItem] and less the total number of
|
||||
/// items that have been removed with [SliverAnimatedGridState.removeItem].
|
||||
///
|
||||
/// Implementations of this callback should assume that
|
||||
/// [SliverAnimatedGridState.removeItem] removes an item immediately.
|
||||
final AnimatedItemBuilder itemBuilder;
|
||||
|
||||
/// A delegate that controls the layout of the children within the
|
||||
/// [SliverAnimatedGrid].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SliverGridDelegateWithFixedCrossAxisCount], which creates a layout with
|
||||
/// a fixed number of tiles in the cross axis.
|
||||
/// * [SliverGridDelegateWithMaxCrossAxisExtent], which creates a layout with
|
||||
/// tiles that have a maximum cross-axis extent.
|
||||
final SliverGridDelegate gridDelegate;
|
||||
|
||||
/// {@macro flutter.widgets.SliverChildBuilderDelegate.findChildIndexCallback}
|
||||
final ChildIndexGetter? findChildIndexCallback;
|
||||
|
||||
/// {@macro flutter.widgets.AnimatedGrid.initialItemCount}
|
||||
final int initialItemCount;
|
||||
|
||||
@override
|
||||
SliverAnimatedGridState createState() => SliverAnimatedGridState();
|
||||
|
||||
/// The state from the closest instance of this class that encloses the given
|
||||
/// context.
|
||||
///
|
||||
/// This method is typically used by [SliverAnimatedGrid] item widgets that
|
||||
/// insert or remove items in response to user input.
|
||||
///
|
||||
/// If no [SliverAnimatedGrid] surrounds the context given, then this function
|
||||
/// will assert in debug mode and throw an exception in release mode.
|
||||
///
|
||||
/// This method can be expensive (it walks the element tree).
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [maybeOf], a similar function that will return null if no
|
||||
/// [SliverAnimatedGrid] ancestor is found.
|
||||
static SliverAnimatedGridState of(BuildContext context) {
|
||||
assert(context != null);
|
||||
final SliverAnimatedGridState? result = context.findAncestorStateOfType<SliverAnimatedGridState>();
|
||||
assert(() {
|
||||
if (result == null) {
|
||||
throw FlutterError(
|
||||
'SliverAnimatedGrid.of() called with a context that does not contain a SliverAnimatedGrid.\n'
|
||||
'No SliverAnimatedGridState ancestor could be found starting from the '
|
||||
'context that was passed to SliverAnimatedGridState.of(). This can '
|
||||
'happen when the context provided is from the same StatefulWidget that '
|
||||
'built the AnimatedGrid. Please see the SliverAnimatedGrid documentation '
|
||||
'for examples of how to refer to an AnimatedGridState object: '
|
||||
'https://api.flutter.dev/flutter/widgets/SliverAnimatedGridState-class.html\n'
|
||||
'The context used was:\n'
|
||||
' $context',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
return result!;
|
||||
}
|
||||
|
||||
/// The state from the closest instance of this class that encloses the given
|
||||
/// context.
|
||||
///
|
||||
/// This method is typically used by [SliverAnimatedGrid] item widgets that
|
||||
/// insert or remove items in response to user input.
|
||||
///
|
||||
/// If no [SliverAnimatedGrid] surrounds the context given, then this function
|
||||
/// will return null.
|
||||
///
|
||||
/// This method can be expensive (it walks the element tree).
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [of], a similar function that will throw if no [SliverAnimatedGrid]
|
||||
/// ancestor is found.
|
||||
static SliverAnimatedGridState? maybeOf(BuildContext context) {
|
||||
assert(context != null);
|
||||
return context.findAncestorStateOfType<SliverAnimatedGridState>();
|
||||
}
|
||||
}
|
||||
|
||||
/// The state for a sliver that animates items when they are
|
||||
/// inserted or removed.
|
||||
///
|
||||
/// When an item is inserted with [insertItem] an animation begins running. The
|
||||
/// animation is passed to [SliverAnimatedGrid.itemBuilder] whenever the item's
|
||||
/// widget is needed.
|
||||
///
|
||||
/// When an item is removed with [removeItem] its animation is reversed.
|
||||
/// The removed item's animation is passed to the [removeItem] builder
|
||||
/// parameter.
|
||||
///
|
||||
/// An app that needs to insert or remove items in response to an event
|
||||
/// can refer to the [SliverAnimatedGrid]'s state with a global key:
|
||||
///
|
||||
/// ```dart
|
||||
/// // (e.g. in a stateful widget)
|
||||
/// GlobalKey<AnimatedGridState> gridKey = GlobalKey<AnimatedGridState>();
|
||||
///
|
||||
/// // ...
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return AnimatedGrid(
|
||||
/// key: gridKey,
|
||||
/// itemBuilder: (BuildContext context, int index, Animation<double> animation) {
|
||||
/// return const Placeholder();
|
||||
/// },
|
||||
/// gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 100.0),
|
||||
/// );
|
||||
/// }
|
||||
///
|
||||
/// // ...
|
||||
///
|
||||
/// void _updateGrid() {
|
||||
/// // adds "123" to the AnimatedGrid
|
||||
/// gridKey.currentState!.insertItem(123);
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// [SliverAnimatedGrid] item input handlers can also refer to their
|
||||
/// [SliverAnimatedGridState] with the static [SliverAnimatedGrid.of] method.
|
||||
class SliverAnimatedGridState extends State<SliverAnimatedGrid> with TickerProviderStateMixin {
|
||||
final List<_ActiveItem> _incomingItems = <_ActiveItem>[];
|
||||
final List<_ActiveItem> _outgoingItems = <_ActiveItem>[];
|
||||
int _itemsCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_itemsCount = widget.initialItemCount;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final _ActiveItem item in _incomingItems.followedBy(_outgoingItems)) {
|
||||
item.controller!.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
_ActiveItem? _removeActiveItemAt(List<_ActiveItem> items, int itemIndex) {
|
||||
final int i = binarySearch(items, _ActiveItem.index(itemIndex));
|
||||
return i == -1 ? null : items.removeAt(i);
|
||||
}
|
||||
|
||||
_ActiveItem? _activeItemAt(List<_ActiveItem> items, int itemIndex) {
|
||||
final int i = binarySearch(items, _ActiveItem.index(itemIndex));
|
||||
return i == -1 ? null : items[i];
|
||||
}
|
||||
|
||||
// The insertItem() and removeItem() index parameters are defined as if the
|
||||
// removeItem() operation removed the corresponding grid entry immediately.
|
||||
// The entry is only actually removed from the grid when the remove animation
|
||||
// finishes. The entry is added to _outgoingItems when removeItem is called
|
||||
// and removed from _outgoingItems when the remove animation finishes.
|
||||
|
||||
int _indexToItemIndex(int index) {
|
||||
int itemIndex = index;
|
||||
for (final _ActiveItem item in _outgoingItems) {
|
||||
if (item.itemIndex <= itemIndex) {
|
||||
itemIndex += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return itemIndex;
|
||||
}
|
||||
|
||||
int _itemIndexToIndex(int itemIndex) {
|
||||
int index = itemIndex;
|
||||
for (final _ActiveItem item in _outgoingItems) {
|
||||
assert(item.itemIndex != itemIndex);
|
||||
if (item.itemIndex < itemIndex) {
|
||||
index -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
SliverChildDelegate _createDelegate() {
|
||||
return SliverChildBuilderDelegate(
|
||||
_itemBuilder,
|
||||
childCount: _itemsCount,
|
||||
findChildIndexCallback: widget.findChildIndexCallback == null
|
||||
? null
|
||||
: (Key key) {
|
||||
final int? index = widget.findChildIndexCallback!(key);
|
||||
return index != null ? _indexToItemIndex(index) : null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Insert an item at [index] and start an animation that will be passed to
|
||||
/// [SliverAnimatedGrid.itemBuilder] when the item is visible.
|
||||
///
|
||||
/// This method's semantics are the same as Dart's [List.insert] method: it
|
||||
/// increases the length of the list of items in the grid by one and shifts
|
||||
/// all items at or after [index] towards the end of the list of items in the
|
||||
/// grid.
|
||||
void insertItem(int index, {Duration duration = _kDuration}) {
|
||||
assert(index != null && index >= 0);
|
||||
assert(duration != null);
|
||||
|
||||
final int itemIndex = _indexToItemIndex(index);
|
||||
assert(itemIndex >= 0 && itemIndex <= _itemsCount);
|
||||
|
||||
// Increment the incoming and outgoing item indices to account
|
||||
// for the insertion.
|
||||
for (final _ActiveItem item in _incomingItems) {
|
||||
if (item.itemIndex >= itemIndex) {
|
||||
item.itemIndex += 1;
|
||||
}
|
||||
}
|
||||
for (final _ActiveItem item in _outgoingItems) {
|
||||
if (item.itemIndex >= itemIndex) {
|
||||
item.itemIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
final AnimationController controller = AnimationController(
|
||||
duration: duration,
|
||||
vsync: this,
|
||||
);
|
||||
final _ActiveItem incomingItem = _ActiveItem.incoming(
|
||||
controller,
|
||||
itemIndex,
|
||||
);
|
||||
setState(() {
|
||||
_incomingItems
|
||||
..add(incomingItem)
|
||||
..sort();
|
||||
_itemsCount += 1;
|
||||
});
|
||||
|
||||
controller.forward().then<void>((_) {
|
||||
_removeActiveItemAt(_incomingItems, incomingItem.itemIndex)!.controller!.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove the item at [index] and start an animation that will be passed
|
||||
/// to [builder] when the item is visible.
|
||||
///
|
||||
/// Items are removed immediately. After an item has been removed, its index
|
||||
/// will no longer be passed to the [SliverAnimatedGrid.itemBuilder]. However
|
||||
/// the item will still appear in the grid for [duration] and during that time
|
||||
/// [builder] must construct its widget as needed.
|
||||
///
|
||||
/// This method's semantics are the same as Dart's [List.remove] method: it
|
||||
/// decreases the length of the list of items in the grid by one and shifts
|
||||
/// all items at or before [index] towards the beginning of the list of items
|
||||
/// in the grid.
|
||||
void removeItem(int index, AnimatedRemovedItemBuilder builder, {Duration duration = _kDuration}) {
|
||||
assert(index != null && index >= 0);
|
||||
assert(builder != null);
|
||||
assert(duration != null);
|
||||
|
||||
final int itemIndex = _indexToItemIndex(index);
|
||||
assert(itemIndex >= 0 && itemIndex < _itemsCount);
|
||||
assert(_activeItemAt(_outgoingItems, itemIndex) == null);
|
||||
|
||||
final _ActiveItem? incomingItem = _removeActiveItemAt(_incomingItems, itemIndex);
|
||||
final AnimationController controller =
|
||||
incomingItem?.controller ?? AnimationController(duration: duration, value: 1.0, vsync: this);
|
||||
final _ActiveItem outgoingItem = _ActiveItem.outgoing(controller, itemIndex, builder);
|
||||
setState(() {
|
||||
_outgoingItems
|
||||
..add(outgoingItem)
|
||||
..sort();
|
||||
});
|
||||
|
||||
controller.reverse().then<void>((void value) {
|
||||
_removeActiveItemAt(_outgoingItems, outgoingItem.itemIndex)!.controller!.dispose();
|
||||
|
||||
// Decrement the incoming and outgoing item indices to account
|
||||
// for the removal.
|
||||
for (final _ActiveItem item in _incomingItems) {
|
||||
if (item.itemIndex > outgoingItem.itemIndex) {
|
||||
item.itemIndex -= 1;
|
||||
}
|
||||
}
|
||||
for (final _ActiveItem item in _outgoingItems) {
|
||||
if (item.itemIndex > outgoingItem.itemIndex) {
|
||||
item.itemIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _itemsCount -= 1);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _itemBuilder(BuildContext context, int itemIndex) {
|
||||
final _ActiveItem? outgoingItem = _activeItemAt(_outgoingItems, itemIndex);
|
||||
if (outgoingItem != null) {
|
||||
return outgoingItem.removedItemBuilder!(
|
||||
context,
|
||||
outgoingItem.controller!.view,
|
||||
);
|
||||
}
|
||||
|
||||
final _ActiveItem? incomingItem = _activeItemAt(_incomingItems, itemIndex);
|
||||
final Animation<double> animation = incomingItem?.controller?.view ?? kAlwaysCompleteAnimation;
|
||||
return widget.itemBuilder(
|
||||
context,
|
||||
_itemIndexToIndex(itemIndex),
|
||||
animation,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverGrid(
|
||||
gridDelegate: widget.gridDelegate,
|
||||
delegate: _createDelegate(),
|
||||
);
|
||||
}
|
||||
}
|
@ -58,6 +58,10 @@ class _ActiveItem implements Comparable<_ActiveItem> {
|
||||
///
|
||||
/// * [SliverAnimatedList], a sliver that animates items when they are inserted
|
||||
/// or removed from a list.
|
||||
/// * [SliverAnimatedGrid], a sliver which animates items when they are
|
||||
/// inserted or removed from a grid.
|
||||
/// * [AnimatedGrid], a non-sliver scrolling container that animates items when
|
||||
/// they are inserted or removed in a grid.
|
||||
class AnimatedList extends StatefulWidget {
|
||||
/// Creates a scrolling container that animates items when they are inserted
|
||||
/// or removed.
|
||||
@ -349,6 +353,10 @@ class AnimatedListState extends State<AnimatedList> with TickerProviderStateMixi
|
||||
/// removed.
|
||||
/// * [AnimatedList], a non-sliver scrolling container that animates items when
|
||||
/// they are inserted or removed.
|
||||
/// * [SliverAnimatedGrid], a sliver which animates items when they are
|
||||
/// inserted into or removed from a grid.
|
||||
/// * [AnimatedGrid], a non-sliver scrolling container that animates items when
|
||||
/// they are inserted into or removed from a grid.
|
||||
class SliverAnimatedList extends StatefulWidget {
|
||||
/// Creates a sliver that animates items when they are inserted or removed.
|
||||
const SliverAnimatedList({
|
||||
|
@ -1023,7 +1023,11 @@ abstract class SliverMultiBoxAdaptorWidget extends SliverWithKeepAliveWidget {
|
||||
/// * [SliverPrototypeExtentList], which is similar to [SliverFixedExtentList]
|
||||
/// except that it uses a prototype list item instead of a pixel value to define
|
||||
/// the main axis extent of each item.
|
||||
/// * [SliverGrid], which places its children in arbitrary positions.
|
||||
/// * [SliverAnimatedList], which animates items added to or removed from a
|
||||
/// list.
|
||||
/// * [SliverGrid], which places multiple children in a two dimensional grid.
|
||||
/// * [SliverAnimatedGrid], a sliver which animates items when they are
|
||||
/// inserted into or removed from a grid.
|
||||
class SliverList extends SliverMultiBoxAdaptorWidget {
|
||||
/// Creates a sliver that places box children in a linear array.
|
||||
const SliverList({
|
||||
|
@ -19,6 +19,7 @@ export 'foundation.dart' show UniqueKey;
|
||||
export 'rendering.dart' show TextSelectionHandleType;
|
||||
export 'src/widgets/actions.dart';
|
||||
export 'src/widgets/animated_cross_fade.dart';
|
||||
export 'src/widgets/animated_grid.dart';
|
||||
export 'src/widgets/animated_list.dart';
|
||||
export 'src/widgets/animated_size.dart';
|
||||
export 'src/widgets/animated_switcher.dart';
|
||||
|
536
packages/flutter/test/widgets/animated_grid_test.dart
Normal file
536
packages/flutter/test/widgets/animated_grid_test.dart
Normal file
@ -0,0 +1,536 @@
|
||||
// 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/src/foundation/diagnostics.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/100451
|
||||
testWidgets('SliverAnimatedGrid.builder respects findChildIndexCallback', (WidgetTester tester) async {
|
||||
bool finderCalled = false;
|
||||
int itemCount = 7;
|
||||
late StateSetter stateSetter;
|
||||
|
||||
await tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: StatefulBuilder(
|
||||
builder: (BuildContext context, StateSetter setState) {
|
||||
stateSetter = setState;
|
||||
return CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAnimatedGrid(
|
||||
initialItemCount: itemCount,
|
||||
itemBuilder: (BuildContext context, int index, Animation<double> animation) => Container(
|
||||
key: Key('$index'),
|
||||
height: 2000.0,
|
||||
),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
),
|
||||
findChildIndexCallback: (Key key) {
|
||||
finderCalled = true;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
));
|
||||
expect(finderCalled, false);
|
||||
|
||||
// Trigger update.
|
||||
stateSetter(() => itemCount = 77);
|
||||
await tester.pump();
|
||||
|
||||
expect(finderCalled, true);
|
||||
});
|
||||
|
||||
testWidgets('AnimatedGrid', (WidgetTester tester) async {
|
||||
Widget builder(BuildContext context, int index, Animation<double> animation) {
|
||||
return SizedBox(
|
||||
height: 100.0,
|
||||
child: Center(
|
||||
child: Text('item $index'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final GlobalKey<AnimatedGridState> listKey = GlobalKey<AnimatedGridState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: AnimatedGrid(
|
||||
key: listKey,
|
||||
initialItemCount: 2,
|
||||
itemBuilder: builder,
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byWidgetPredicate((Widget widget) {
|
||||
return widget is SliverAnimatedGrid && widget.initialItemCount == 2 && widget.itemBuilder == builder;
|
||||
}), findsOneWidget);
|
||||
|
||||
listKey.currentState!.insertItem(0);
|
||||
await tester.pump();
|
||||
expect(find.text('item 2'), findsOneWidget);
|
||||
|
||||
listKey.currentState!.removeItem(
|
||||
2,
|
||||
(BuildContext context, Animation<double> animation) {
|
||||
return const SizedBox(
|
||||
height: 100.0,
|
||||
child: Center(child: Text('removing item')),
|
||||
);
|
||||
},
|
||||
duration: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
expect(find.text('removing item'), findsOneWidget);
|
||||
expect(find.text('item 2'), findsNothing);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('removing item'), findsNothing);
|
||||
});
|
||||
|
||||
group('SliverAnimatedGrid', () {
|
||||
testWidgets('initialItemCount', (WidgetTester tester) async {
|
||||
final Map<int, Animation<double>> animations = <int, Animation<double>>{};
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAnimatedGrid(
|
||||
initialItemCount: 2,
|
||||
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
|
||||
animations[index] = animation;
|
||||
return SizedBox(
|
||||
height: 100.0,
|
||||
child: Center(
|
||||
child: Text('item $index'),
|
||||
),
|
||||
);
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('item 0'), findsOneWidget);
|
||||
expect(find.text('item 1'), findsOneWidget);
|
||||
expect(animations.containsKey(0), true);
|
||||
expect(animations.containsKey(1), true);
|
||||
expect(animations[0]!.value, 1.0);
|
||||
expect(animations[1]!.value, 1.0);
|
||||
});
|
||||
|
||||
testWidgets('insert', (WidgetTester tester) async {
|
||||
final GlobalKey<SliverAnimatedGridState> listKey = GlobalKey<SliverAnimatedGridState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAnimatedGrid(
|
||||
key: listKey,
|
||||
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
|
||||
return ScaleTransition(
|
||||
key: ValueKey<int>(index),
|
||||
scale: animation,
|
||||
child: SizedBox(
|
||||
height: 100.0,
|
||||
child: Center(child: Text('item $index')),
|
||||
),
|
||||
);
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
double itemScale(int index) =>
|
||||
tester.widget<ScaleTransition>(find.byKey(ValueKey<int>(index), skipOffstage: false)).scale.value;
|
||||
double itemLeft(int index) => tester.getTopLeft(find.byKey(ValueKey<int>(index), skipOffstage: false)).dx;
|
||||
double itemRight(int index) => tester.getTopRight(find.byKey(ValueKey<int>(index), skipOffstage: false)).dx;
|
||||
|
||||
listKey.currentState!.insertItem(0, duration: const Duration(milliseconds: 100));
|
||||
await tester.pump();
|
||||
|
||||
// Newly inserted item 0's scale should animate from 0 to 1
|
||||
expect(itemScale(0), 0.0);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(itemScale(0), 0.5);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(itemScale(0), 1.0);
|
||||
|
||||
// The list now contains one fully expanded item at the top:
|
||||
expect(find.text('item 0'), findsOneWidget);
|
||||
expect(itemLeft(0), 0.0);
|
||||
expect(itemRight(0), 100.0);
|
||||
|
||||
listKey.currentState!.insertItem(0, duration: const Duration(milliseconds: 100));
|
||||
listKey.currentState!.insertItem(0, duration: const Duration(milliseconds: 100));
|
||||
await tester.pump();
|
||||
|
||||
// The scale of the newly inserted items at index 0 and 1 should animate
|
||||
// from 0 to 1.
|
||||
// The scale of the original item, now at index 2, should remain 1.
|
||||
expect(itemScale(0), 0.0);
|
||||
expect(itemScale(1), 0.0);
|
||||
expect(itemScale(2), 1.0);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(itemScale(0), 0.5);
|
||||
expect(itemScale(1), 0.5);
|
||||
expect(itemScale(2), 1.0);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(itemScale(0), 1.0);
|
||||
expect(itemScale(1), 1.0);
|
||||
expect(itemScale(2), 1.0);
|
||||
|
||||
// The newly inserted "item 1" and "item 2" appear above "item 0"
|
||||
expect(find.text('item 0'), findsOneWidget);
|
||||
expect(find.text('item 1'), findsOneWidget);
|
||||
expect(find.text('item 2'), findsOneWidget);
|
||||
expect(itemLeft(0), 0.0);
|
||||
expect(itemRight(0), 100.0);
|
||||
expect(itemLeft(1), 100.0);
|
||||
expect(itemRight(1), 200.0);
|
||||
expect(itemLeft(2), 200.0);
|
||||
expect(itemRight(2), 300.0);
|
||||
});
|
||||
|
||||
testWidgets('remove', (WidgetTester tester) async {
|
||||
final GlobalKey<SliverAnimatedGridState> listKey = GlobalKey<SliverAnimatedGridState>();
|
||||
final List<int> items = <int>[0, 1, 2];
|
||||
|
||||
Widget buildItem(BuildContext context, int item, Animation<double> animation) {
|
||||
return ScaleTransition(
|
||||
key: ValueKey<int>(item),
|
||||
scale: animation,
|
||||
child: SizedBox(
|
||||
height: 100.0,
|
||||
child: Center(
|
||||
child: Text('item $item', textDirection: TextDirection.ltr),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAnimatedGrid(
|
||||
key: listKey,
|
||||
initialItemCount: 3,
|
||||
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
|
||||
return buildItem(context, items[index], animation);
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
double itemScale(int index) =>
|
||||
tester.widget<ScaleTransition>(find.byKey(ValueKey<int>(index), skipOffstage: false)).scale.value;
|
||||
double itemLeft(int index) => tester.getTopLeft(find.byKey(ValueKey<int>(index), skipOffstage: false)).dx;
|
||||
double itemRight(int index) => tester.getTopRight(find.byKey(ValueKey<int>(index), skipOffstage: false)).dx;
|
||||
|
||||
expect(find.text('item 0'), findsOneWidget);
|
||||
expect(find.text('item 1'), findsOneWidget);
|
||||
expect(find.text('item 2'), findsOneWidget);
|
||||
|
||||
items.removeAt(0);
|
||||
listKey.currentState!.removeItem(
|
||||
0,
|
||||
(BuildContext context, Animation<double> animation) => buildItem(context, 0, animation),
|
||||
duration: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
// Items 0, 1, 2 at 0, 100, 200. All heights 100.
|
||||
expect(itemLeft(0), 0.0);
|
||||
expect(itemRight(0), 100.0);
|
||||
expect(itemLeft(1), 100.0);
|
||||
expect(itemRight(1), 200.0);
|
||||
expect(itemLeft(2), 200.0);
|
||||
expect(itemRight(2), 300.0);
|
||||
|
||||
// Newly removed item 0's height should animate from 100 to 0 over 100ms
|
||||
|
||||
// Items 0, 1, 2 at 0, 50, 150. Item 0's height is 50.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(itemScale(0), 0.5);
|
||||
expect(itemScale(1), 1.0);
|
||||
expect(itemScale(2), 1.0);
|
||||
|
||||
// Items 1, 2 at 0, 100.
|
||||
await tester.pumpAndSettle();
|
||||
expect(itemLeft(1), 0.0);
|
||||
expect(itemRight(1), 100.0);
|
||||
expect(itemLeft(2), 100.0);
|
||||
expect(itemRight(2), 200.0);
|
||||
});
|
||||
|
||||
testWidgets('works in combination with other slivers', (WidgetTester tester) async {
|
||||
final GlobalKey<SliverAnimatedGridState> listKey = GlobalKey<SliverAnimatedGridState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate(<Widget>[
|
||||
const SizedBox(height: 100),
|
||||
const SizedBox(height: 100),
|
||||
]),
|
||||
),
|
||||
SliverAnimatedGrid(
|
||||
key: listKey,
|
||||
initialItemCount: 3,
|
||||
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
|
||||
return SizedBox(
|
||||
height: 100,
|
||||
child: Text('item $index'),
|
||||
);
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.getTopLeft(find.text('item 0')).dx, 0);
|
||||
expect(tester.getTopLeft(find.text('item 1')).dx, 100);
|
||||
|
||||
listKey.currentState!.insertItem(3);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getTopLeft(find.text('item 3')).dx, 300);
|
||||
|
||||
listKey.currentState!.removeItem(
|
||||
0,
|
||||
(BuildContext context, Animation<double> animation) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
key: const ObjectKey('removing'),
|
||||
child: const SizedBox(
|
||||
height: 100,
|
||||
child: Text('removing'),
|
||||
),
|
||||
);
|
||||
},
|
||||
duration: const Duration(seconds: 1),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
expect(find.text('item 3'), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(
|
||||
tester.widget<ScaleTransition>(find.byKey(const ObjectKey('removing'), skipOffstage: false)).scale.value,
|
||||
0.5,
|
||||
);
|
||||
expect(tester.getTopLeft(find.text('item 0')).dx, 100);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('removing'), findsNothing);
|
||||
expect(tester.getTopLeft(find.text('item 0')).dx, 0);
|
||||
});
|
||||
|
||||
testWidgets('passes correctly derived index of findChildIndexCallback to the inner SliverChildBuilderDelegate',
|
||||
(WidgetTester tester) async {
|
||||
final List<int> items = <int>[0, 1, 2, 3];
|
||||
final GlobalKey<SliverAnimatedGridState> listKey = GlobalKey<SliverAnimatedGridState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAnimatedGrid(
|
||||
key: listKey,
|
||||
initialItemCount: items.length,
|
||||
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
|
||||
return _StatefulListItem(
|
||||
key: ValueKey<int>(items[index]),
|
||||
index: index,
|
||||
);
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
),
|
||||
findChildIndexCallback: (Key key) {
|
||||
final int index = items.indexOf((key as ValueKey<int>).value);
|
||||
return index == -1 ? null : index;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// get all list entries in order
|
||||
final List<Text> listEntries = find.byType(Text).evaluate().map((Element e) => e.widget as Text).toList();
|
||||
|
||||
// check that the list is rendered in the correct order
|
||||
expect(listEntries[0].data, equals('item 0'));
|
||||
expect(listEntries[1].data, equals('item 1'));
|
||||
expect(listEntries[2].data, equals('item 2'));
|
||||
expect(listEntries[3].data, equals('item 3'));
|
||||
|
||||
// delete one item
|
||||
listKey.currentState?.removeItem(0, (BuildContext context, Animation<double> animation) {
|
||||
return Container();
|
||||
});
|
||||
|
||||
// delete from list
|
||||
items.removeAt(0);
|
||||
|
||||
// reorder list
|
||||
items.insert(0, items.removeLast());
|
||||
|
||||
// render with new list order
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// get all list entries in order
|
||||
final List<Text> reorderedListEntries =
|
||||
find.byType(Text).evaluate().map((Element e) => e.widget as Text).toList();
|
||||
|
||||
// check that the stateful items of the list are rendered in the order provided by findChildIndexCallback
|
||||
expect(reorderedListEntries[0].data, equals('item 3'));
|
||||
expect(reorderedListEntries[1].data, equals('item 1'));
|
||||
expect(reorderedListEntries[2].data, equals('item 2'));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'AnimatedGrid.of() and maybeOf called with a context that does not contain AnimatedGrid',
|
||||
(WidgetTester tester) async {
|
||||
final GlobalKey key = GlobalKey();
|
||||
await tester.pumpWidget(Container(key: key));
|
||||
late FlutterError error;
|
||||
expect(AnimatedGrid.maybeOf(key.currentContext!), isNull);
|
||||
try {
|
||||
AnimatedGrid.of(key.currentContext!);
|
||||
} on FlutterError catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error.diagnostics.length, 4);
|
||||
expect(error.diagnostics[2].level, DiagnosticLevel.hint);
|
||||
expect(
|
||||
error.diagnostics[2].toStringDeep(),
|
||||
equalsIgnoringHashCodes(
|
||||
'This can happen when the context provided is from the same\n'
|
||||
'StatefulWidget that built the AnimatedGrid. Please see the\n'
|
||||
'AnimatedGrid documentation for examples of how to refer to an\n'
|
||||
'AnimatedGridState object:\n'
|
||||
' https://api.flutter.dev/flutter/widgets/AnimatedGridState-class.html\n',
|
||||
),
|
||||
);
|
||||
expect(error.diagnostics[3], isA<DiagnosticsProperty<Element>>());
|
||||
expect(
|
||||
error.toStringDeep(),
|
||||
equalsIgnoringHashCodes(
|
||||
'FlutterError\n'
|
||||
' AnimatedGrid.of() called with a context that does not contain an\n'
|
||||
' AnimatedGrid.\n'
|
||||
' No AnimatedGrid ancestor could be found starting from the context\n'
|
||||
' that was passed to AnimatedGrid.of().\n'
|
||||
' This can happen when the context provided is from the same\n'
|
||||
' StatefulWidget that built the AnimatedGrid. Please see the\n'
|
||||
' AnimatedGrid documentation for examples of how to refer to an\n'
|
||||
' AnimatedGridState object:\n'
|
||||
' https://api.flutter.dev/flutter/widgets/AnimatedGridState-class.html\n'
|
||||
' The context used was:\n'
|
||||
' Container-[GlobalKey#32cc6]\n',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('AnimatedGrid.clipBehavior is forwarded to its inner CustomScrollView', (WidgetTester tester) async {
|
||||
const Clip clipBehavior = Clip.none;
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: AnimatedGrid(
|
||||
initialItemCount: 2,
|
||||
clipBehavior: clipBehavior,
|
||||
itemBuilder: (BuildContext context, int index, Animation<double> _) {
|
||||
return SizedBox(
|
||||
height: 100.0,
|
||||
child: Center(
|
||||
child: Text('item $index'),
|
||||
),
|
||||
);
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.widget<CustomScrollView>(find.byType(CustomScrollView)).clipBehavior, clipBehavior);
|
||||
});
|
||||
}
|
||||
|
||||
class _StatefulListItem extends StatefulWidget {
|
||||
const _StatefulListItem({
|
||||
super.key,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
final int index;
|
||||
|
||||
@override
|
||||
_StatefulListItemState createState() => _StatefulListItemState();
|
||||
}
|
||||
|
||||
class _StatefulListItemState extends State<_StatefulListItem> {
|
||||
late final int number = widget.index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text('item $number');
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user