filter bar: animate chip removal
This commit is contained in:
parent
ff9420fce7
commit
e26f2b4fb6
6 changed files with 121 additions and 38 deletions
|
@ -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(
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue