192 lines
5.6 KiB
Dart
192 lines
5.6 KiB
Dart
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'circle_nav_icon_button.dart';
|
|
|
|
typedef HoverScrollableRowBuilder =
|
|
Widget Function(BuildContext context, ScrollController controller);
|
|
|
|
|
|
class _MouseDragScrollBehavior extends MaterialScrollBehavior {
|
|
const _MouseDragScrollBehavior();
|
|
|
|
@override
|
|
Set<PointerDeviceKind> get dragDevices => const {
|
|
PointerDeviceKind.touch,
|
|
PointerDeviceKind.mouse,
|
|
PointerDeviceKind.trackpad,
|
|
PointerDeviceKind.stylus,
|
|
};
|
|
}
|
|
|
|
class HoverScrollableRow extends StatefulWidget {
|
|
final HoverScrollableRowBuilder builder;
|
|
final ScrollController? controller;
|
|
final double scrollFraction;
|
|
final double buttonInset;
|
|
final double iconSize;
|
|
|
|
const HoverScrollableRow({
|
|
super.key,
|
|
required this.builder,
|
|
this.controller,
|
|
this.scrollFraction = 0.6,
|
|
this.buttonInset = 10,
|
|
this.iconSize = 26,
|
|
});
|
|
|
|
@override
|
|
State<HoverScrollableRow> createState() => _HoverScrollableRowState();
|
|
}
|
|
|
|
class _HoverScrollableRowState extends State<HoverScrollableRow> {
|
|
late ScrollController _controller;
|
|
bool _ownsController = false;
|
|
bool _hovered = false;
|
|
bool _canScrollLeft = false;
|
|
bool _canScrollRight = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_attachController(widget.controller);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState());
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant HoverScrollableRow oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.controller != widget.controller) {
|
|
_detachController();
|
|
_attachController(widget.controller);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState());
|
|
}
|
|
}
|
|
|
|
void _attachController(ScrollController? external) {
|
|
if (external != null) {
|
|
_controller = external;
|
|
_ownsController = false;
|
|
} else {
|
|
_controller = ScrollController();
|
|
_ownsController = true;
|
|
}
|
|
_controller.addListener(_updateScrollState);
|
|
}
|
|
|
|
void _detachController() {
|
|
_controller.removeListener(_updateScrollState);
|
|
if (_ownsController) {
|
|
_controller.dispose();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_detachController();
|
|
super.dispose();
|
|
}
|
|
|
|
void _updateScrollState() {
|
|
if (!mounted || !_controller.hasClients) return;
|
|
final maxScroll = _controller.position.maxScrollExtent;
|
|
final offset = _controller.offset;
|
|
final canLeft = offset > 1;
|
|
final canRight = maxScroll - offset > 1;
|
|
if (canLeft != _canScrollLeft || canRight != _canScrollRight) {
|
|
setState(() {
|
|
_canScrollLeft = canLeft;
|
|
_canScrollRight = canRight;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _scrollByPage({required bool left}) {
|
|
if (!_controller.hasClients) return;
|
|
final viewport = _controller.position.viewportDimension;
|
|
final delta = viewport * widget.scrollFraction;
|
|
final target = (_controller.offset + (left ? -delta : delta)).clamp(
|
|
0.0,
|
|
_controller.position.maxScrollExtent,
|
|
);
|
|
_controller.animateTo(
|
|
target,
|
|
duration: const Duration(milliseconds: 320),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hasOverflow = _canScrollLeft || _canScrollRight;
|
|
final showPager = _hovered && hasOverflow;
|
|
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: NotificationListener<ScrollMetricsNotification>(
|
|
onNotification: (_) {
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => _updateScrollState(),
|
|
);
|
|
return false;
|
|
},
|
|
child: Stack(
|
|
children: [
|
|
ScrollConfiguration(
|
|
behavior: const _MouseDragScrollBehavior(),
|
|
child: widget.builder(context, _controller),
|
|
),
|
|
if (hasOverflow)
|
|
Positioned(
|
|
left: widget.buttonInset,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: AnimatedOpacity(
|
|
opacity: showPager ? 1 : 0,
|
|
duration: const Duration(milliseconds: 140),
|
|
child: IgnorePointer(
|
|
ignoring: !showPager || !_canScrollLeft,
|
|
child: CircleNavIconButton(
|
|
icon: Icons.chevron_left_rounded,
|
|
iconSize: widget.iconSize,
|
|
enabled: _canScrollLeft,
|
|
onPressed: _canScrollLeft
|
|
? () => _scrollByPage(left: true)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (hasOverflow)
|
|
Positioned(
|
|
right: widget.buttonInset,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: AnimatedOpacity(
|
|
opacity: showPager ? 1 : 0,
|
|
duration: const Duration(milliseconds: 140),
|
|
child: IgnorePointer(
|
|
ignoring: !showPager || !_canScrollRight,
|
|
child: CircleNavIconButton(
|
|
icon: Icons.chevron_right_rounded,
|
|
iconSize: widget.iconSize,
|
|
enabled: _canScrollRight,
|
|
onPressed: _canScrollRight
|
|
? () => _scrollByPage(left: false)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|