Unverified Commit 7523ab5c authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add `SliverAnimatedGrid` and `AnimatedGrid` (#112982)

parent 78c23305
// 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),
),
),
),
),
),
);
}
}
// 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,
),
),
),
),
),
),
);
}
}
// 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);
});
}
// 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) {
......
This diff is collapsed.
......@@ -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';
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment