Initial commit
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user