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: [
Text(
package.name,
style: TextStyle(fontWeight: FontWeight.bold),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Icon(

View file

@ -93,7 +93,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
leading: _buildAppBarLeading(),
title: _buildAppBarTitle(),
actions: _buildActions(),
bottom: hasFilters ? FilterBar() : null,
bottom: hasFilters
? FilterBar(
filters: collection.filters,
onPressed: collection.removeFilter,
)
: null,
floating: true,
),
);
@ -141,8 +146,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
child: child,
sizeFactor: animation,
child: child,
),
),
child: sourceState == SourceState.ready
@ -337,12 +342,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
}
Future<void> _goToSearch() async {
final filter = await showSearch(
void _goToSearch() {
showSearch(
context: context,
delegate: ImageSearchDelegate(collection.source),
delegate: ImageSearchDelegate(collection.source, collection.addFilter),
);
collection.addFilter(filter);
}
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: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 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
final Size preferredSize = const Size.fromHeight(preferredHeight);
@override
Widget build(BuildContext context) {
final collection = Provider.of<CollectionLens>(context);
final filters = collection.filters.toList()..sort();
_FilterBarState createState() => _FilterBarState();
}
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(
// specify transparent as a workaround to prevent
// chip border clipping when the floating app bar is fading
color: Colors.transparent,
height: preferredSize.height,
height: FilterBar.preferredHeight,
child: NotificationListener<ScrollNotification>(
// cancel notification bubbling so that the draggable scrollbar
// does not misinterpret filter bar scrolling for collection scrolling
onNotification: (notification) => true,
child: ListView.separated(
child: AnimatedList(
key: _animatedListKey,
initialItemCount: widget.filters.length,
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
if (index >= filters.length) return null;
final filter = filters[index];
return Center(
child: AvesFilterChip(
key: ValueKey(filter),
filter: filter,
removable: true,
heroType: HeroType.always,
onPressed: collection.removeFilter,
),
);
padding: const EdgeInsets.only(left: 8),
itemBuilder: (context, index, animation) {
if (index >= widget.filters.length) return null;
return _buildChip(widget.filters.toList()[index]);
},
),
),
);
}
Padding _buildChip(CollectionFilter filter) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Center(
child: AvesFilterChip(
key: ValueKey(filter),
filter: filter,
removable: true,
heroType: HeroType.always,
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,
switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition(
child: child,
scale: animation,
child: child,
),
child: child,
);
@ -232,16 +232,16 @@ class SectionSelectableLeading extends StatelessWidget {
switchOutCurve: Curves.easeInOut,
transitionBuilder: (child, animation) {
Widget transition = ScaleTransition(
child: child,
scale: animation,
child: child,
);
if (browsingBuilder == null) {
// when switching with a header that has no icon,
// we also transition the size for a smooth push to the text
transition = SizeTransition(
axis: Axis.horizontal,
child: transition,
sizeFactor: animation,
child: 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/mime_types.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:flutter/material.dart';
class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
final CollectionSource source;
final ValueNotifier<String> expandedSectionNotifier = ValueNotifier(null);
final FilterCallback onSelection;
ImageSearchDelegate(this.source);
ImageSearchDelegate(this.source, this.onSelection);
@override
ThemeData appBarTheme(BuildContext context) {
@ -29,7 +31,7 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
icon: AnimatedIcons.menu_arrow,
progress: transitionAnimation,
),
onPressed: () => close(context, null),
onPressed: () => _select(context, null),
tooltip: 'Back',
);
}
@ -118,7 +120,7 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
title: title,
filters: filters,
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,
// so we post the call that will filter the collection
// and possibly trigger a rebuild here
close(context, _buildQueryFilter(true));
_select(context, _buildQueryFilter(true));
});
return const SizedBox.shrink();
}
@ -137,4 +139,16 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
final cleanQuery = query.trim();
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,
switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition(
child: child,
scale: animation,
child: child,
),
child: child,
);