filter bar: animate chip removal

This commit is contained in:
Thibault Deckers 2020-06-09 12:49:47 +09:00
parent ff9420fce7
commit e26f2b4fb6
6 changed files with 121 additions and 38 deletions

View file

@ -136,7 +136,7 @@ class LicenseRow extends StatelessWidget {
children: [ children: [
Text( Text(
package.name, package.name,
style: TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Icon( Icon(

View file

@ -93,7 +93,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
leading: _buildAppBarLeading(), leading: _buildAppBarLeading(),
title: _buildAppBarTitle(), title: _buildAppBarTitle(),
actions: _buildActions(), actions: _buildActions(),
bottom: hasFilters ? FilterBar() : null, bottom: hasFilters
? FilterBar(
filters: collection.filters,
onPressed: collection.removeFilter,
)
: null,
floating: true, floating: true,
), ),
); );
@ -141,8 +146,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
transitionBuilder: (child, animation) => FadeTransition( transitionBuilder: (child, animation) => FadeTransition(
opacity: animation, opacity: animation,
child: SizeTransition( child: SizeTransition(
child: child,
sizeFactor: animation, sizeFactor: animation,
child: child,
), ),
), ),
child: sourceState == SourceState.ready child: sourceState == SourceState.ready
@ -337,12 +342,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
} }
Future<void> _goToSearch() async { void _goToSearch() {
final filter = await showSearch( showSearch(
context: context, context: context,
delegate: ImageSearchDelegate(collection.source), delegate: ImageSearchDelegate(collection.source, collection.addFilter),
); );
collection.addFilter(filter);
} }
Future<void> _goToStats() { Future<void> _goToStats() {

View file

@ -1,48 +1,113 @@
import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class FilterBar extends StatelessWidget implements PreferredSizeWidget { class FilterBar extends StatefulWidget implements PreferredSizeWidget {
static const double verticalPadding = 16; static const double verticalPadding = 16;
static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding; static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding;
final List<CollectionFilter> filters;
final FilterCallback onPressed;
FilterBar({
Key key,
@required Set<CollectionFilter> filters,
@required this.onPressed,
}) : filters = List.from(filters)..sort(),
super(key: key);
@override @override
final Size preferredSize = const Size.fromHeight(preferredHeight); final Size preferredSize = const Size.fromHeight(preferredHeight);
@override @override
Widget build(BuildContext context) { _FilterBarState createState() => _FilterBarState();
final collection = Provider.of<CollectionLens>(context); }
final filters = collection.filters.toList()..sort();
class _FilterBarState extends State<FilterBar> {
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey();
CollectionFilter _userRemovedFilter;
@override
void didUpdateWidget(FilterBar oldWidget) {
super.didUpdateWidget(oldWidget);
final current = widget.filters;
final existing = oldWidget.filters;
final removed = existing.where((filter) => !current.contains(filter)).toList();
final added = current.where((filter) => !existing.contains(filter)).toList();
final listState = _animatedListKey.currentState;
removed.forEach((filter) {
final index = existing.indexOf(filter);
existing.removeAt(index);
// only animate item removal when triggered by a user interaction with the chip,
// not from automatic chip replacement following chip selection
final animate = _userRemovedFilter == filter;
listState.removeItem(
index,
animate
? (context, animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
axis: Axis.horizontal,
sizeFactor: animation,
child: ScaleTransition(
scale: animation,
child: _buildChip(filter),
),
),
)
: (context, animation) => _buildChip(filter),
duration: animate ? const Duration(milliseconds: 200) : Duration.zero,
);
});
added.forEach((filter) {
final index = current.indexOf(filter);
listState.insertItem(
index,
duration: Duration.zero,
);
});
_userRemovedFilter = null;
}
@override
Widget build(BuildContext context) {
return Container( return Container(
// specify transparent as a workaround to prevent // specify transparent as a workaround to prevent
// chip border clipping when the floating app bar is fading // chip border clipping when the floating app bar is fading
color: Colors.transparent, color: Colors.transparent,
height: preferredSize.height, height: FilterBar.preferredHeight,
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
// cancel notification bubbling so that the draggable scrollbar // cancel notification bubbling so that the draggable scrollbar
// does not misinterpret filter bar scrolling for collection scrolling // does not misinterpret filter bar scrolling for collection scrolling
onNotification: (notification) => true, onNotification: (notification) => true,
child: ListView.separated( child: AnimatedList(
key: _animatedListKey,
initialItemCount: widget.filters.length,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.only(left: 8),
itemBuilder: (context, index) { itemBuilder: (context, index, animation) {
if (index >= filters.length) return null; if (index >= widget.filters.length) return null;
final filter = filters[index]; return _buildChip(widget.filters.toList()[index]);
return Center( },
),
),
);
}
Padding _buildChip(CollectionFilter filter) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Center(
child: AvesFilterChip( child: AvesFilterChip(
key: ValueKey(filter), key: ValueKey(filter),
filter: filter, filter: filter,
removable: true, removable: true,
heroType: HeroType.always, heroType: HeroType.always,
onPressed: collection.removeFilter, onPressed: (filter) {
), _userRemovedFilter = filter;
); widget.onPressed(filter);
}, },
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemCount: filters.length,
), ),
), ),
); );

View file

@ -218,8 +218,8 @@ class SectionSelectableLeading extends StatelessWidget {
switchInCurve: Curves.easeOutBack, switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeOutBack, switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition( transitionBuilder: (child, animation) => ScaleTransition(
child: child,
scale: animation, scale: animation,
child: child,
), ),
child: child, child: child,
); );
@ -232,16 +232,16 @@ class SectionSelectableLeading extends StatelessWidget {
switchOutCurve: Curves.easeInOut, switchOutCurve: Curves.easeInOut,
transitionBuilder: (child, animation) { transitionBuilder: (child, animation) {
Widget transition = ScaleTransition( Widget transition = ScaleTransition(
child: child,
scale: animation, scale: animation,
child: child,
); );
if (browsingBuilder == null) { if (browsingBuilder == null) {
// when switching with a header that has no icon, // when switching with a header that has no icon,
// we also transition the size for a smooth push to the text // we also transition the size for a smooth push to the text
transition = SizeTransition( transition = SizeTransition(
axis: Axis.horizontal, axis: Axis.horizontal,
child: transition,
sizeFactor: animation, sizeFactor: animation,
child: transition,
); );
} }
return transition; return transition;

View file

@ -8,14 +8,16 @@ import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/mime_types.dart'; import 'package:aves/model/mime_types.dart';
import 'package:aves/widgets/album/search/expandable_filter_row.dart'; import 'package:aves/widgets/album/search/expandable_filter_row.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ImageSearchDelegate extends SearchDelegate<CollectionFilter> { class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
final CollectionSource source; final CollectionSource source;
final ValueNotifier<String> expandedSectionNotifier = ValueNotifier(null); final ValueNotifier<String> expandedSectionNotifier = ValueNotifier(null);
final FilterCallback onSelection;
ImageSearchDelegate(this.source); ImageSearchDelegate(this.source, this.onSelection);
@override @override
ThemeData appBarTheme(BuildContext context) { ThemeData appBarTheme(BuildContext context) {
@ -29,7 +31,7 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
icon: AnimatedIcons.menu_arrow, icon: AnimatedIcons.menu_arrow,
progress: transitionAnimation, progress: transitionAnimation,
), ),
onPressed: () => close(context, null), onPressed: () => _select(context, null),
tooltip: 'Back', tooltip: 'Back',
); );
} }
@ -118,7 +120,7 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
title: title, title: title,
filters: filters, filters: filters,
expandedNotifier: expandedSectionNotifier, expandedNotifier: expandedSectionNotifier,
onPressed: (filter) => close(context, filter is QueryFilter ? QueryFilter(filter.query) : filter), onPressed: (filter) => _select(context, filter is QueryFilter ? QueryFilter(filter.query) : filter),
); );
} }
@ -128,7 +130,7 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
// `buildResults` is called in the build phase, // `buildResults` is called in the build phase,
// so we post the call that will filter the collection // so we post the call that will filter the collection
// and possibly trigger a rebuild here // and possibly trigger a rebuild here
close(context, _buildQueryFilter(true)); _select(context, _buildQueryFilter(true));
}); });
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@ -137,4 +139,16 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
final cleanQuery = query.trim(); final cleanQuery = query.trim();
return cleanQuery.isNotEmpty ? QueryFilter(cleanQuery, colorful: colorful) : null; return cleanQuery.isNotEmpty ? QueryFilter(cleanQuery, colorful: colorful) : null;
} }
void _select(BuildContext context, CollectionFilter filter) {
if (filter != null) {
onSelection(filter);
}
// we post closing the search page after applying the filter selection
// so that hero animation target is ready in the `FilterBar`,
// even when the target is a child of an `AnimatedList`
WidgetsBinding.instance.addPostFrameCallback((_) {
close(context, null);
});
}
} }

View file

@ -81,8 +81,8 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
switchInCurve: Curves.easeOutBack, switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeOutBack, switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition( transitionBuilder: (child, animation) => ScaleTransition(
child: child,
scale: animation, scale: animation,
child: child,
), ),
child: child, child: child,
); );