#226 collection: fast-scrolling shows breadcrumbs from groups

This commit is contained in:
Thibault Deckers 2022-05-11 11:35:16 +09:00
parent 839f19a141
commit 987a7dfe70
7 changed files with 250 additions and 54 deletions

View file

@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
- Bottom navigation bar - Bottom navigation bar
- Collection: thumbnail overlay tag icon - Collection: thumbnail overlay tag icon
- Collection: fast-scrolling shows breadcrumbs from groups
- Settings: search - Settings: search
- `huawei` app flavor (Petal Maps, no Crashlytics) - `huawei` app flavor (Petal Maps, no Crashlytics)

View file

@ -8,6 +8,7 @@ import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
@ -21,8 +22,10 @@ import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/draggable_thumb_label.dart';
import 'package:aves/widgets/common/grid/item_tracker.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart';
import 'package:aves/widgets/common/grid/scaling.dart'; import 'package:aves/widgets/common/grid/scaling.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:aves/widgets/common/grid/selector.dart'; import 'package:aves/widgets/common/grid/selector.dart';
import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/grid/theme.dart';
@ -34,6 +37,7 @@ import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart'; import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -347,26 +351,34 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
valueListenable: widget.appBarHeightNotifier, valueListenable: widget.appBarHeightNotifier,
builder: (context, appBarHeight, child) => Selector<MediaQueryData, double>( builder: (context, appBarHeight, child) => Selector<MediaQueryData, double>(
selector: (context, mq) => mq.effectiveBottomPadding, selector: (context, mq) => mq.effectiveBottomPadding,
builder: (context, mqPaddingBottom, child) => DraggableScrollbar( builder: (context, mqPaddingBottom, child) {
backgroundColor: Colors.white, return Selector<SectionedListLayout<AvesEntry>, List<SectionLayout>>(
scrollThumbHeight: avesScrollThumbHeight, selector: (context, layout) => layout.sectionLayouts,
scrollThumbBuilder: avesScrollThumbBuilder( builder: (context, sectionLayouts, child) {
height: avesScrollThumbHeight, return DraggableScrollbar(
backgroundColor: Colors.white, backgroundColor: Colors.white,
), scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight),
controller: widget.scrollController, scrollThumbBuilder: avesScrollThumbBuilder(
padding: EdgeInsets.only( height: avesScrollThumbHeight,
// padding to keep scroll thumb between app bar above and nav bar below backgroundColor: Colors.white,
top: appBarHeight, ),
bottom: mqPaddingBottom, controller: widget.scrollController,
), crumbsBuilder: () => _getCrumbs(sectionLayouts),
labelTextBuilder: (offsetY) => CollectionDraggableThumbLabel( padding: EdgeInsets.only(
collection: collection, // padding to keep scroll thumb between app bar above and nav bar below
offsetY: offsetY, top: appBarHeight,
), bottom: mqPaddingBottom,
child: scrollView, ),
), labelTextBuilder: (offsetY) => CollectionDraggableThumbLabel(
child: child, collection: collection,
offsetY: offsetY,
),
crumbTextBuilder: (label) => DraggableCrumbLabel(label: label),
child: scrollView,
);
},
);
},
), ),
); );
} }
@ -433,4 +445,64 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
void _stopScrollMonitoringTimer() { void _stopScrollMonitoringTimer() {
_scrollMonitoringTimer?.cancel(); _scrollMonitoringTimer?.cancel();
} }
Map<double, String> _getCrumbs(List<SectionLayout> sectionLayouts) {
final crumbs = <double, String>{};
if (sectionLayouts.length <= 1) return crumbs;
void addAlbums(CollectionLens collection, List<SectionLayout> sectionLayouts, Map<double, String> crumbs) {
final source = collection.source;
sectionLayouts.forEach((section) {
final directory = (section.sectionKey as EntryAlbumSectionKey).directory;
if (directory != null) {
final label = source.getAlbumDisplayName(context, directory);
crumbs[section.minOffset] = label;
}
});
}
final collection = widget.collection;
switch (collection.sortFactor) {
case EntrySortFactor.date:
switch (collection.sectionFactor) {
case EntryGroupFactor.album:
addAlbums(collection, sectionLayouts, crumbs);
break;
case EntryGroupFactor.month:
case EntryGroupFactor.day:
final firstKey = sectionLayouts.first.sectionKey;
final lastKey = sectionLayouts.last.sectionKey;
if (firstKey is EntryDateSectionKey && lastKey is EntryDateSectionKey) {
final newest = firstKey.date;
final oldest = lastKey.date;
if (newest != null && oldest != null) {
final localeName = context.l10n.localeName;
final dateFormat = newest.difference(oldest).inDays > 365 ? DateFormat.y(localeName) : DateFormat.MMM(localeName);
String? lastLabel;
sectionLayouts.forEach((section) {
final date = (section.sectionKey as EntryDateSectionKey).date;
if (date != null) {
final label = dateFormat.format(date);
if (label != lastLabel) {
crumbs[section.minOffset] = label;
lastLabel = label;
}
}
});
}
}
break;
case EntryGroupFactor.none:
break;
}
break;
case EntrySortFactor.name:
addAlbums(collection, sectionLayouts, crumbs);
break;
case EntrySortFactor.rating:
case EntrySortFactor.size:
break;
}
return crumbs;
}
} }

View file

@ -24,7 +24,8 @@ typedef ScrollThumbBuilder = Widget Function(
}); });
/// Build a Text widget using the current scroll offset /// Build a Text widget using the current scroll offset
typedef LabelTextBuilder = Widget Function(double offsetY); typedef OffsetLabelBuilder = Widget Function(double offsetY);
typedef TextLabelBuilder = Widget Function(String label);
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged /// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView. /// for quick navigation of the BoxScrollView.
@ -32,14 +33,15 @@ class DraggableScrollbar extends StatefulWidget {
/// The background color of the label and thumb /// The background color of the label and thumb
final Color backgroundColor; final Color backgroundColor;
/// The height of the scroll thumb final Map<double, String> Function()? crumbsBuilder;
final double scrollThumbHeight;
final Size scrollThumbSize;
/// A function that builds a thumb using the current configuration /// A function that builds a thumb using the current configuration
final ScrollThumbBuilder scrollThumbBuilder; final ScrollThumbBuilder scrollThumbBuilder;
/// The amount of padding that should surround the thumb /// The amount of padding that should surround the thumb
final EdgeInsets? padding; final EdgeInsets padding;
/// Determines how quickly the scrollbar will animate in and out /// Determines how quickly the scrollbar will animate in and out
final Duration scrollbarAnimationDuration; final Duration scrollbarAnimationDuration;
@ -48,7 +50,9 @@ class DraggableScrollbar extends StatefulWidget {
final Duration scrollbarTimeToFade; final Duration scrollbarTimeToFade;
/// Build a Text widget from the current offset in the BoxScrollView /// Build a Text widget from the current offset in the BoxScrollView
final LabelTextBuilder? labelTextBuilder; final OffsetLabelBuilder labelTextBuilder;
final TextLabelBuilder crumbTextBuilder;
/// The ScrollController for the BoxScrollView /// The ScrollController for the BoxScrollView
final ScrollController controller; final ScrollController controller;
@ -59,13 +63,15 @@ class DraggableScrollbar extends StatefulWidget {
DraggableScrollbar({ DraggableScrollbar({
Key? key, Key? key,
required this.backgroundColor, required this.backgroundColor,
required this.scrollThumbHeight, required this.scrollThumbSize,
required this.scrollThumbBuilder, required this.scrollThumbBuilder,
required this.controller, required this.controller,
this.padding, this.crumbsBuilder,
this.padding = EdgeInsets.zero,
this.scrollbarAnimationDuration = const Duration(milliseconds: 300), this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
this.scrollbarTimeToFade = const Duration(milliseconds: 1000), this.scrollbarTimeToFade = const Duration(milliseconds: 1000),
this.labelTextBuilder, required this.labelTextBuilder,
required this.crumbTextBuilder,
required this.child, required this.child,
}) : assert(child.scrollDirection == Axis.vertical), }) : assert(child.scrollDirection == Axis.vertical),
super(key: key); super(key: key);
@ -73,6 +79,8 @@ class DraggableScrollbar extends StatefulWidget {
@override @override
State<DraggableScrollbar> createState() => _DraggableScrollbarState(); State<DraggableScrollbar> createState() => _DraggableScrollbarState();
static const double labelThumbPadding = 16;
static Widget buildScrollThumbAndLabel({ static Widget buildScrollThumbAndLabel({
required Widget scrollThumb, required Widget scrollThumb,
required Color backgroundColor, required Color backgroundColor,
@ -91,7 +99,7 @@ class DraggableScrollbar extends StatefulWidget {
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
child: labelText, child: labelText,
), ),
const SizedBox(width: 24), const SizedBox(width: labelThumbPadding),
scrollThumb, scrollThumb,
], ],
); );
@ -141,6 +149,11 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
late AnimationController _labelAnimationController; late AnimationController _labelAnimationController;
late Animation<double> _labelAnimation; late Animation<double> _labelAnimation;
Timer? _fadeoutTimer; Timer? _fadeoutTimer;
Map<double, String>? _modelCrumbs;
final List<_Crumb> _viewportCrumbs = [];
static const crumbPadding = 30.0;
static const crumbOffsetRatioThreshold = 10;
@override @override
void initState() { void initState() {
@ -167,6 +180,15 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
); );
} }
@override
void didUpdateWidget(covariant DraggableScrollbar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.crumbsBuilder != widget.crumbsBuilder) {
_modelCrumbs = null;
}
}
@override @override
void dispose() { void dispose() {
_thumbAnimationController.dispose(); _thumbAnimationController.dispose();
@ -177,7 +199,9 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
ScrollController get controller => widget.controller; ScrollController get controller => widget.controller;
double get thumbMaxScrollExtent => context.size!.height - widget.scrollThumbHeight - (widget.padding?.vertical ?? 0.0); double get scrollBarHeight => context.size!.height - widget.padding.vertical;
double get thumbMaxScrollExtent => scrollBarHeight - widget.scrollThumbSize.height;
double get thumbMinScrollExtent => 0.0; double get thumbMinScrollExtent => 0.0;
@ -193,6 +217,27 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
RepaintBoundary( RepaintBoundary(
child: widget.child, child: widget.child,
), ),
if (_isDragInProcess)
..._viewportCrumbs.map((crumb) {
return Positioned.directional(
textDirection: Directionality.of(context),
top: crumb.labelOffset,
end: DraggableScrollbar.labelThumbPadding + widget.scrollThumbSize.width,
child: Padding(
padding: widget.padding,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: widget.scrollThumbSize.height),
child: Center(
child: ScrollLabel(
animation: kAlwaysCompleteAnimation,
backgroundColor: widget.backgroundColor,
child: widget.crumbTextBuilder(crumb.label),
),
),
),
),
);
}),
RepaintBoundary( RepaintBoundary(
child: GestureDetector( child: GestureDetector(
onLongPressStart: (details) { onLongPressStart: (details) {
@ -212,16 +257,16 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
valueListenable: _thumbOffsetNotifier, valueListenable: _thumbOffsetNotifier,
builder: (context, thumbOffset, child) => Container( builder: (context, thumbOffset, child) => Container(
alignment: AlignmentDirectional.topEnd, alignment: AlignmentDirectional.topEnd,
padding: EdgeInsets.only(top: thumbOffset) + (widget.padding ?? EdgeInsets.zero), padding: EdgeInsets.only(top: thumbOffset) + widget.padding,
child: widget.scrollThumbBuilder( child: widget.scrollThumbBuilder(
widget.backgroundColor, widget.backgroundColor,
_thumbAnimation, _thumbAnimation,
_labelAnimation, _labelAnimation,
widget.scrollThumbHeight, widget.scrollThumbSize.height,
labelText: (widget.labelTextBuilder != null && _isDragInProcess) labelText: _isDragInProcess
? ValueListenableBuilder<double>( ? ValueListenableBuilder<double>(
valueListenable: _viewOffsetNotifier, valueListenable: _viewOffsetNotifier,
builder: (context, viewOffset, child) => widget.labelTextBuilder!.call(viewOffset + thumbOffset), builder: (context, viewOffset, child) => widget.labelTextBuilder(viewOffset + thumbOffset),
) )
: null, : null,
), ),
@ -261,6 +306,7 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
_labelAnimationController.forward(); _labelAnimationController.forward();
_fadeoutTimer?.cancel(); _fadeoutTimer?.cancel();
_showThumb(); _showThumb();
_updateViewportCrumbs();
setState(() => _isDragInProcess = true); setState(() => _isDragInProcess = true);
} }
@ -296,6 +342,50 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
_fadeoutTimer = null; _fadeoutTimer = null;
}); });
} }
void _updateViewportCrumbs() {
_viewportCrumbs.clear();
final crumbsBuilder = widget.crumbsBuilder;
if (crumbsBuilder != null) {
final position = controller.position;
final contentHeight = position.maxScrollExtent + thumbMaxScrollExtent + position.viewportDimension;
final ratio = contentHeight / scrollBarHeight;
if (ratio > crumbOffsetRatioThreshold) {
final maxLabelOffset = scrollBarHeight - widget.scrollThumbSize.height;
double lastLabelOffset = -crumbPadding;
_modelCrumbs ??= crumbsBuilder();
_modelCrumbs!.entries.forEach((kv) {
final viewOffset = kv.key;
final label = kv.value;
final labelOffset = (viewOffset / ratio).roundToDouble();
if (labelOffset >= lastLabelOffset + crumbPadding && labelOffset < maxLabelOffset) {
lastLabelOffset = labelOffset;
_viewportCrumbs.add(_Crumb(
viewOffset: viewOffset,
labelOffset: labelOffset,
label: label,
));
}
});
// hide lonesome crumb, whether it is because of a single section,
// or because multiple sections collapsed to a single crumb
if (_viewportCrumbs.length == 1) {
_viewportCrumbs.clear();
}
}
}
}
}
class _Crumb {
final double viewOffset, labelOffset;
final String label;
const _Crumb({
required this.viewOffset,
required this.labelOffset,
required this.label,
});
} }
///This cut 2 lines in arrow shape ///This cut 2 lines in arrow shape

View file

@ -5,6 +5,26 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class DraggableCrumbLabel extends StatelessWidget {
final String label;
const DraggableCrumbLabel({
Key? key,
required this.label,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _crumbLabelMaxWidth),
child: Padding(
padding: _padding,
child: _buildText(label, isCrumb: true),
),
);
}
}
class DraggableThumbLabel<T> extends StatelessWidget { class DraggableThumbLabel<T> extends StatelessWidget {
final double offsetY; final double offsetY;
final List<String> Function(BuildContext context, T item) lineBuilder; final List<String> Function(BuildContext context, T item) lineBuilder;
@ -30,30 +50,20 @@ class DraggableThumbLabel<T> extends StatelessWidget {
if (lines.isEmpty) return const SizedBox(); if (lines.isEmpty) return const SizedBox();
return ConstrainedBox( return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140), constraints: const BoxConstraints(maxWidth: _thumbLabelMaxWidth),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: _padding,
child: lines.length > 1 child: lines.length > 1
? Column( ? Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: lines.map(_buildText).toList(), children: lines.map((v) => _buildText(v, isCrumb: false)).toList(),
) )
: _buildText(lines.first), : _buildText(lines.first, isCrumb: false),
), ),
); );
} }
Widget _buildText(String text) => Text(
text,
style: const TextStyle(
color: Colors.black,
),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
);
static String formatMonthThumbLabel(BuildContext context, DateTime? date) { static String formatMonthThumbLabel(BuildContext context, DateTime? date) {
final l10n = context.l10n; final l10n = context.l10n;
if (date == null) return l10n.sectionUnknown; if (date == null) return l10n.sectionUnknown;
@ -66,3 +76,18 @@ class DraggableThumbLabel<T> extends StatelessWidget {
return formatDay(date, l10n.localeName); return formatDay(date, l10n.localeName);
} }
} }
const double _crumbLabelMaxWidth = 96;
const double _thumbLabelMaxWidth = 144;
const EdgeInsets _padding = EdgeInsets.symmetric(vertical: 4, horizontal: 8);
Widget _buildText(String text, {required bool isCrumb}) => Text(
text,
style: TextStyle(
color: Colors.black,
fontSize: isCrumb ? 10 : 14,
),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
);

View file

@ -15,12 +15,12 @@ ScrollThumbBuilder avesScrollThumbBuilder({
borderRadius: BorderRadius.all(Radius.circular(12)), borderRadius: BorderRadius.all(Radius.circular(12)),
), ),
height: height, height: height,
margin: const EdgeInsetsDirectional.only(end: 1), margin: _margin,
padding: const EdgeInsets.all(2), padding: _padding,
child: ClipPath( child: ClipPath(
clipper: ArrowClipper(), clipper: ArrowClipper(),
child: Container( child: Container(
width: 20.0, width: _width,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(12)), borderRadius: const BorderRadius.all(Radius.circular(12)),
@ -38,3 +38,9 @@ ScrollThumbBuilder avesScrollThumbBuilder({
); );
}; };
} }
const _margin = EdgeInsetsDirectional.only(end: 1);
const _padding = EdgeInsets.all(2);
const _width = 20.0;
double get avesScrollThumbWidth => _width + _padding.horizontal + _margin.horizontal;

View file

@ -197,11 +197,12 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
} }
void onTap(TapUpDetails details) { void onTap(TapUpDetails details) {
if (widget.onTap == null) return; final onTap = widget.onTap;
if (onTap == null) return;
final viewportTapPosition = details.localPosition; final viewportTapPosition = details.localPosition;
final childTapPosition = scaleBoundaries.viewportToChildPosition(controller, viewportTapPosition); final childTapPosition = scaleBoundaries.viewportToChildPosition(controller, viewportTapPosition);
widget.onTap!.call(context, details, controller.currentState, childTapPosition); onTap(context, details, controller.currentState, childTapPosition);
} }
void onDoubleTap(TapDownDetails details) { void onDoubleTap(TapDownDetails details) {

View file

@ -503,7 +503,7 @@ class _FilterScrollView<T extends CollectionFilter> extends StatelessWidget {
selector: (context, mq) => mq.effectiveBottomPadding, selector: (context, mq) => mq.effectiveBottomPadding,
builder: (context, mqPaddingBottom, child) => DraggableScrollbar( builder: (context, mqPaddingBottom, child) => DraggableScrollbar(
backgroundColor: Colors.white, backgroundColor: Colors.white,
scrollThumbHeight: avesScrollThumbHeight, scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight),
scrollThumbBuilder: avesScrollThumbBuilder( scrollThumbBuilder: avesScrollThumbBuilder(
height: avesScrollThumbHeight, height: avesScrollThumbHeight,
backgroundColor: Colors.white, backgroundColor: Colors.white,
@ -518,6 +518,7 @@ class _FilterScrollView<T extends CollectionFilter> extends StatelessWidget {
sortFactor: sortFactor, sortFactor: sortFactor,
offsetY: offsetY, offsetY: offsetY,
), ),
crumbTextBuilder: (offsetY) => const SizedBox(),
child: scrollView, child: scrollView,
), ),
); );