aves/lib/widgets/common/action_controls/quick_choosers/common/menu.dart
Thibault Deckers 6f9a581d99 minor
2025-05-26 23:39:43 +02:00

318 lines
12 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/action_controls/quick_choosers/common/quick_chooser.dart';
import 'package:aves_ui/aves_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart';
class MenuQuickChooser<T> extends StatefulWidget {
final ValueNotifier<T?> valueNotifier;
final List<T> options;
final bool autoReverse;
final bool blurred;
final PopupMenuPosition chooserPosition;
final Stream<Offset> pointerGlobalPosition;
final int maxTotalOptionCount;
final double itemHeight;
final double? Function(BuildContext context)? contentWidth;
final Widget Function(BuildContext context, T menuItem) itemBuilder;
final WidgetBuilder? emptyBuilder;
static const int maxVisibleOptionCount = 5;
MenuQuickChooser({
super.key,
required this.valueNotifier,
required List<T> options,
required this.autoReverse,
required this.blurred,
required this.chooserPosition,
required this.pointerGlobalPosition,
this.maxTotalOptionCount = maxVisibleOptionCount,
this.itemHeight = kMinInteractiveDimension,
this.contentWidth,
required this.itemBuilder,
this.emptyBuilder,
}) : options = options.take(maxTotalOptionCount).toList();
@override
State<MenuQuickChooser<T>> createState() => _MenuQuickChooserState<T>();
}
class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
final Set<StreamSubscription> _subscriptions = {};
final ValueNotifier<Rect> _selectedRowRect = ValueNotifier(Rect.zero);
final ScrollController _scrollController = ScrollController();
int _scrollDirection = 0;
Timer? _scrollUpdateTimer;
Offset _globalPosition = Offset.zero;
ValueNotifier<T?> get valueNotifier => widget.valueNotifier;
List<T> get options => widget.options;
bool get reversed => widget.autoReverse && widget.chooserPosition == PopupMenuPosition.over;
bool get scrollable => options.length > MenuQuickChooser.maxVisibleOptionCount;
int get visibleOptionCount => min(MenuQuickChooser.maxVisibleOptionCount, options.length);
double get itemHeight => widget.itemHeight;
double get contentHeight => max(0, itemHeight * visibleOptionCount + _intraPadding * (visibleOptionCount - 1));
static const double _selectorMargin = 24;
static const double _intraPadding = 8;
static const double _nonScrollablePaddingHeight = _intraPadding;
static const double _scrollerAreaHeight = kMinInteractiveDimension;
static const double scrollMaxPixelPerSecond = 600.0;
static const Duration scrollUpdateInterval = Duration(milliseconds: 100);
@override
void initState() {
super.initState();
_registerWidget(widget);
WidgetsBinding.instance.addPostFrameCallback((_) => setState(() {}));
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_selectedRowRect.value = Rect.fromLTWH(0, MediaQuery.sizeOf(context).height * (reversed ? 1 : -1), 0, 0);
}
@override
void didUpdateWidget(covariant MenuQuickChooser<T> oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_selectedRowRect.dispose();
_scrollController.dispose();
_scrollUpdateTimer?.cancel();
super.dispose();
}
void _registerWidget(MenuQuickChooser<T> widget) {
_subscriptions.add(widget.pointerGlobalPosition.listen(_onPointerMove));
}
void _unregisterWidget(MenuQuickChooser<T> widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override
Widget build(BuildContext context) {
return QuickChooser(
blurred: widget.blurred,
child: ValueListenableBuilder<T?>(
valueListenable: valueNotifier,
builder: (context, selectedValue, child) {
final durations = context.watch<DurationsData>();
if (options.isEmpty) {
return Padding(
padding: const EdgeInsets.all(16),
child: widget.emptyBuilder?.call(context) ?? const SizedBox(),
);
}
return Stack(
children: [
ValueListenableBuilder<Rect>(
valueListenable: _selectedRowRect,
builder: (context, selectedRowRect, child) {
Widget child = const Center(child: AvesDot());
child = AnimatedOpacity(
opacity: selectedValue != null ? 1 : 0,
curve: Curves.easeInOutCubic,
duration: const Duration(milliseconds: 200),
child: child,
);
child = AnimatedPositioned(
top: selectedRowRect.top,
height: selectedRowRect.height,
curve: Curves.easeInOutCubic,
duration: const Duration(milliseconds: 200),
child: child,
);
return child;
},
),
Container(
width: widget.contentWidth?.call(context),
margin: const EdgeInsetsDirectional.only(start: _selectorMargin),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
scrollable
? ListenableBuilder(
listenable: _scrollController,
builder: (context, child) => Opacity(
opacity: canGoUp ? 1 : .5,
child: child,
),
child: _buildScrollerArea(AIcons.up),
)
: const SizedBox(height: _nonScrollablePaddingHeight),
ConstrainedBox(
constraints: BoxConstraints.tightFor(height: contentHeight),
child: ListView.separated(
reverse: reversed,
controller: _scrollController,
shrinkWrap: true,
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
final child = Container(
alignment: AlignmentDirectional.centerStart,
constraints: BoxConstraints.tightFor(height: itemHeight),
child: widget.itemBuilder(context, options[index]),
);
if (index < MenuQuickChooser.maxVisibleOptionCount) {
// only animate items visible on first render
return AnimationConfiguration.staggeredList(
position: index,
duration: durations.staggeredAnimation * .5,
delay: durations.staggeredAnimationDelay * .5 * timeDilation,
child: SlideAnimation(
verticalOffset: 50.0 * (widget.chooserPosition == PopupMenuPosition.over ? 1 : -1),
child: FadeInAnimation(
child: child,
),
),
);
} else {
return child;
}
},
separatorBuilder: (context, index) => const SizedBox(height: _intraPadding),
itemCount: options.length,
),
),
scrollable
? ListenableBuilder(
listenable: _scrollController,
builder: (context, child) => Opacity(
opacity: canGoDown ? 1 : .5,
child: child,
),
child: _buildScrollerArea(AIcons.down),
)
: const SizedBox(height: _nonScrollablePaddingHeight),
],
),
),
],
);
},
),
);
}
bool get canGoUp {
if (!_scrollController.hasClients) return false;
final position = _scrollController.position;
if (!position.hasContentDimensions) return false;
return reversed ? position.pixels < position.maxScrollExtent : 0 < position.pixels;
}
bool get canGoDown {
if (!_scrollController.hasClients) return false;
final position = _scrollController.position;
if (!position.hasContentDimensions) return false;
return reversed ? 0 < position.pixels : position.pixels < position.maxScrollExtent;
}
Widget _buildScrollerArea(IconData icon) {
return Container(
alignment: Alignment.center,
height: _scrollerAreaHeight,
margin: const EdgeInsetsDirectional.only(end: _selectorMargin),
child: Icon(icon),
);
}
void _onPointerMove(Offset globalPosition) {
_globalPosition = globalPosition;
final chooserBox = context.findRenderObject() as RenderBox?;
if (chooserBox == null) return;
final chooserSize = chooserBox.size;
final contentWidth = chooserSize.width;
final chooserBoxEdgeHeight = (QuickChooser.margin.vertical + QuickChooser.padding.vertical) / 2;
final localPosition = chooserBox.globalToLocal(globalPosition);
final dx = localPosition.dx;
if (!(0 < dx && dx < contentWidth)) {
valueNotifier.value = null;
return;
}
double dy = localPosition.dy - chooserBoxEdgeHeight;
int scrollDirection = 0;
if (scrollable) {
dy -= _scrollerAreaHeight;
if (-_scrollerAreaHeight < dy && dy < 0) {
scrollDirection = reversed ? 1 : -1;
} else if (contentHeight < dy && dy < contentHeight + _scrollerAreaHeight) {
scrollDirection = reversed ? -1 : 1;
}
_scroll(scrollDirection);
} else {
dy -= _nonScrollablePaddingHeight;
}
T? selectedValue;
if (scrollDirection == 0 && 0 < dy && dy < contentHeight) {
final visibleOffset = reversed ? contentHeight - dy : dy;
final fullItemHeight = itemHeight + _intraPadding;
final scrollOffset = _scrollController.offset;
final index = (visibleOffset + _intraPadding + scrollOffset) ~/ (fullItemHeight);
if (0 <= index && index < options.length) {
selectedValue = options[index];
double fromEdge = fullItemHeight * index;
fromEdge += (scrollable ? _scrollerAreaHeight - scrollOffset : _nonScrollablePaddingHeight);
final top = reversed ? chooserSize.height - chooserBoxEdgeHeight - fromEdge - fullItemHeight : fromEdge;
_selectedRowRect.value = Rect.fromLTWH(0, top, contentWidth, itemHeight);
}
}
valueNotifier.value = selectedValue;
}
void _scroll(int scrollDirection) {
if (scrollDirection == _scrollDirection) return;
_scrollDirection = scrollDirection;
_scrollUpdateTimer?.cancel();
final current = _scrollController.offset;
if (scrollDirection == 0) {
_scrollController.jumpTo(current);
return;
}
final target = scrollDirection > 0 ? _scrollController.position.maxScrollExtent : .0;
if (target != current) {
final distance = target - current;
final millis = distance * 1000 / scrollMaxPixelPerSecond / scrollDirection;
_scrollController.animateTo(
target,
duration: Duration(milliseconds: millis.round()),
curve: Curves.linear,
);
// use a timer to update the selection, because `_onPointerMove`
// is not called when the pointer stays still while the view is scrolling
_scrollUpdateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onPointerMove(_globalPosition));
}
}
}