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
|
/// Obsolete property that was originally used as the foreground
|
||||||
/// color for widgets (knobs, text, overscroll edge effect, etc).
|
/// color for widgets (knobs, text, overscroll edge effect, etc).
|
||||||
///
|
///
|
||||||
/// The material library no longer uses this property. In most cases
|
/// The material library no longer uses this property. In most cases the
|
||||||
/// the theme's [colorScheme] [ColorScheme.secondary] property is now
|
/// [colorScheme]'s [ColorScheme.secondary] property is now used instead.
|
||||||
/// used instead.
|
|
||||||
///
|
///
|
||||||
/// Apps should migrate uses of this property to the theme's [colorScheme]
|
/// Apps should migrate uses of this property to the theme's [colorScheme]
|
||||||
/// [ColorScheme.secondary] color. In cases where a color is needed that
|
/// [ColorScheme.secondary] color. In cases where a color is needed that
|
||||||
|
@ -461,7 +461,10 @@ class SliverGridDelegateWithMaxCrossAxisExtent extends SliverGridDelegate {
|
|||||||
@override
|
@override
|
||||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||||
assert(_debugAssertIsValid(constraints.crossAxisExtent));
|
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(
|
final double usableCrossAxisExtent = math.max(
|
||||||
0.0,
|
0.0,
|
||||||
constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1),
|
constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1),
|
||||||
@ -584,8 +587,6 @@ class RenderSliverGrid extends RenderSliverMultiBoxAdaptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final SliverGridGeometry firstChildGridGeometry = layout.getGeometryForChildIndex(firstIndex);
|
final SliverGridGeometry firstChildGridGeometry = layout.getGeometryForChildIndex(firstIndex);
|
||||||
final double leadingScrollOffset = firstChildGridGeometry.scrollOffset;
|
|
||||||
double trailingScrollOffset = firstChildGridGeometry.trailingScrollOffset;
|
|
||||||
|
|
||||||
if (firstChild == null) {
|
if (firstChild == null) {
|
||||||
if (!addInitialChild(index: firstIndex, layoutOffset: firstChildGridGeometry.scrollOffset)) {
|
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;
|
RenderBox? trailingChildWithLayout;
|
||||||
|
|
||||||
for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) {
|
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
|
/// * [SliverAnimatedList], a sliver that animates items when they are inserted
|
||||||
/// or removed from a list.
|
/// 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 {
|
class AnimatedList extends StatefulWidget {
|
||||||
/// Creates a scrolling container that animates items when they are inserted
|
/// Creates a scrolling container that animates items when they are inserted
|
||||||
/// or removed.
|
/// or removed.
|
||||||
@ -349,6 +353,10 @@ class AnimatedListState extends State<AnimatedList> with TickerProviderStateMixi
|
|||||||
/// removed.
|
/// removed.
|
||||||
/// * [AnimatedList], a non-sliver scrolling container that animates items when
|
/// * [AnimatedList], a non-sliver scrolling container that animates items when
|
||||||
/// they are inserted or removed.
|
/// 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 {
|
class SliverAnimatedList extends StatefulWidget {
|
||||||
/// Creates a sliver that animates items when they are inserted or removed.
|
/// Creates a sliver that animates items when they are inserted or removed.
|
||||||
const SliverAnimatedList({
|
const SliverAnimatedList({
|
||||||
|
@ -1023,7 +1023,11 @@ abstract class SliverMultiBoxAdaptorWidget extends SliverWithKeepAliveWidget {
|
|||||||
/// * [SliverPrototypeExtentList], which is similar to [SliverFixedExtentList]
|
/// * [SliverPrototypeExtentList], which is similar to [SliverFixedExtentList]
|
||||||
/// except that it uses a prototype list item instead of a pixel value to define
|
/// except that it uses a prototype list item instead of a pixel value to define
|
||||||
/// the main axis extent of each item.
|
/// 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 {
|
class SliverList extends SliverMultiBoxAdaptorWidget {
|
||||||
/// Creates a sliver that places box children in a linear array.
|
/// Creates a sliver that places box children in a linear array.
|
||||||
const SliverList({
|
const SliverList({
|
||||||
|
@ -19,6 +19,7 @@ export 'foundation.dart' show UniqueKey;
|
|||||||
export 'rendering.dart' show TextSelectionHandleType;
|
export 'rendering.dart' show TextSelectionHandleType;
|
||||||
export 'src/widgets/actions.dart';
|
export 'src/widgets/actions.dart';
|
||||||
export 'src/widgets/animated_cross_fade.dart';
|
export 'src/widgets/animated_cross_fade.dart';
|
||||||
|
export 'src/widgets/animated_grid.dart';
|
||||||
export 'src/widgets/animated_list.dart';
|
export 'src/widgets/animated_list.dart';
|
||||||
export 'src/widgets/animated_size.dart';
|
export 'src/widgets/animated_size.dart';
|
||||||
export 'src/widgets/animated_switcher.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