From e26f2b4fb6150fd4c197299bf94ffd6e87b09821 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 9 Jun 2020 12:49:47 +0900 Subject: [PATCH] filter bar: animate chip removal --- lib/widgets/about/licenses.dart | 2 +- lib/widgets/album/app_bar.dart | 16 ++- lib/widgets/album/filter_bar.dart | 111 ++++++++++++++---- lib/widgets/album/grid/header_generic.dart | 6 +- lib/widgets/album/search/search_delegate.dart | 22 +++- lib/widgets/album/thumbnail/overlay.dart | 2 +- 6 files changed, 121 insertions(+), 38 deletions(-) diff --git a/lib/widgets/about/licenses.dart b/lib/widgets/about/licenses.dart index 78d900266..1660ba210 100644 --- a/lib/widgets/about/licenses.dart +++ b/lib/widgets/about/licenses.dart @@ -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( diff --git a/lib/widgets/album/app_bar.dart b/lib/widgets/album/app_bar.dart index 62d868de3..ea5241da6 100644 --- a/lib/widgets/album/app_bar.dart +++ b/lib/widgets/album/app_bar.dart @@ -93,7 +93,12 @@ class _CollectionAppBarState extends State 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 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 with SingleTickerPr } } - Future _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 _goToStats() { diff --git a/lib/widgets/album/filter_bar.dart b/lib/widgets/album/filter_bar.dart index d2d539a23..40c7f854a 100644 --- a/lib/widgets/album/filter_bar.dart +++ b/lib/widgets/album/filter_bar.dart @@ -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 filters; + final FilterCallback onPressed; + + FilterBar({ + Key key, + @required Set 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(context); - final filters = collection.filters.toList()..sort(); + _FilterBarState createState() => _FilterBarState(); +} +class _FilterBarState extends State { + final GlobalKey _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( // 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, ), ), ); diff --git a/lib/widgets/album/grid/header_generic.dart b/lib/widgets/album/grid/header_generic.dart index 3b76accbc..4301056ba 100644 --- a/lib/widgets/album/grid/header_generic.dart +++ b/lib/widgets/album/grid/header_generic.dart @@ -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; diff --git a/lib/widgets/album/search/search_delegate.dart b/lib/widgets/album/search/search_delegate.dart index b4cd54cdf..7c3fb7b18 100644 --- a/lib/widgets/album/search/search_delegate.dart +++ b/lib/widgets/album/search/search_delegate.dart @@ -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 { final CollectionSource source; final ValueNotifier 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 { icon: AnimatedIcons.menu_arrow, progress: transitionAnimation, ), - onPressed: () => close(context, null), + onPressed: () => _select(context, null), tooltip: 'Back', ); } @@ -118,7 +120,7 @@ class ImageSearchDelegate extends SearchDelegate { 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 { // `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 { 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); + }); + } } diff --git a/lib/widgets/album/thumbnail/overlay.dart b/lib/widgets/album/thumbnail/overlay.dart index 4261211c1..58853ab94 100644 --- a/lib/widgets/album/thumbnail/overlay.dart +++ b/lib/widgets/album/thumbnail/overlay.dart @@ -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, );