diff --git a/lib/features/series/presentation/series_list_screen.dart b/lib/features/series/presentation/series_list_screen.dart index b9abff0..2dd6d94 100644 --- a/lib/features/series/presentation/series_list_screen.dart +++ b/lib/features/series/presentation/series_list_screen.dart @@ -15,6 +15,7 @@ import 'widgets/season_status_bar.dart'; import '../../../core/io_open.dart'; import '../../../core/config.dart'; import '../../../core/async_utils.dart'; +import 'widgets/overlay_scrollbar.dart'; class SeriesListScreen extends ConsumerStatefulWidget { const SeriesListScreen({super.key}); @@ -26,11 +27,15 @@ class SeriesListScreen extends ConsumerStatefulWidget { class _SeriesListScreenState extends ConsumerState { final ScrollController _groupedVController = ScrollController(); final ScrollController _summaryVController = ScrollController(); + final ScrollController _groupedHController = ScrollController(); + final ScrollController _summaryHController = ScrollController(); @override void dispose() { _groupedVController.dispose(); _summaryVController.dispose(); + _groupedHController.dispose(); + _summaryHController.dispose(); super.dispose(); } @@ -809,23 +814,36 @@ class _SeriesListScreenState extends ConsumerState { showCheckboxColumn: false, ); - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: RawScrollbar( - controller: _groupedVController, - thumbVisibility: true, - trackVisibility: true, - interactive: true, - thickness: 10, - radius: const Radius.circular(6), - thumbColor: Colors.black54, - trackColor: Colors.black12, - trackBorderColor: Colors.black26, - child: SingleChildScrollView( - controller: _groupedVController, - child: table, + return Stack( + children: [ + Scrollbar( + controller: _groupedHController, + child: SingleChildScrollView( + controller: _groupedHController, + scrollDirection: Axis.horizontal, + child: Scrollbar( + controller: _groupedVController, + child: SingleChildScrollView( + controller: _groupedVController, + child: table, + ), + ), + ), ), - ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: SizedBox( + width: 10, + child: AlwaysOnScrollbar( + controller: _groupedVController, + axis: Axis.vertical, + thickness: 10, + ), + ), + ), + ], ); }, ); @@ -1335,48 +1353,52 @@ class _SeriesListScreenState extends ConsumerState { const headerHeight = 32.0; return LayoutBuilder( builder: (context, constraints) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: widgets.SizedBox( - width: tableWidth, - height: constraints.maxHeight, - child: Stack( - children: [ - // Sticky header at the top; scrolls horizontally with content - Positioned( - top: 0, - left: 0, - right: 0, - child: + return Stack( + children: [ + Scrollbar( + controller: _summaryHController, + child: SingleChildScrollView( + controller: _summaryHController, + scrollDirection: Axis.horizontal, + child: widgets.SizedBox( + width: tableWidth, + height: constraints.maxHeight, + child: Column( + children: [ widgets.SizedBox(height: headerHeight, child: buildHeader()), - ), - // Vertical list below the header with its own scrollbar - Positioned.fill( - top: headerHeight, - child: RawScrollbar( - controller: _summaryVController, - thumbVisibility: true, - trackVisibility: true, - interactive: true, - thickness: 10, - radius: const Radius.circular(6), - thumbColor: Colors.black54, - trackColor: Colors.black12, - trackBorderColor: Colors.black26, - child: ListView.separated( - controller: _summaryVController, - itemCount: keys.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (_, i) => widgets.SizedBox( - height: 88, - child: buildRow(keys[i]), + Expanded( + child: Scrollbar( + controller: _summaryVController, + child: ListView.separated( + controller: _summaryVController, + itemCount: keys.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => widgets.SizedBox( + height: 88, + child: buildRow(keys[i]), + ), + ), + ), ), - ), + ], ), ), - ], + ), ), - ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: SizedBox( + width: 10, + child: AlwaysOnScrollbar( + controller: _summaryVController, + axis: Axis.vertical, + thickness: 10, + ), + ), + ), + ], ); }, ); diff --git a/lib/features/series/presentation/widgets/overlay_scrollbar.dart b/lib/features/series/presentation/widgets/overlay_scrollbar.dart new file mode 100644 index 0000000..16830cd --- /dev/null +++ b/lib/features/series/presentation/widgets/overlay_scrollbar.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; + +class AlwaysOnScrollbar extends StatefulWidget { + final ScrollController controller; + final Axis axis; + final double thickness; + final Color thumbColor; + final Color trackColor; + final EdgeInsets padding; + final bool interactive; + + const AlwaysOnScrollbar({ + super.key, + required this.controller, + required this.axis, + this.thickness = 8, + this.thumbColor = const Color(0x88000000), + this.trackColor = const Color(0x22000000), + this.padding = EdgeInsets.zero, + this.interactive = true, + }); + + @override + State createState() => _AlwaysOnScrollbarState(); +} + +class _AlwaysOnScrollbarState extends State { + void _onChanged() { + if (mounted) setState(() {}); + } + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onChanged); + } + + @override + void didUpdateWidget(covariant AlwaysOnScrollbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onChanged); + widget.controller.addListener(_onChanged); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: widget.padding, + child: LayoutBuilder( + builder: (context, constraints) { + final has = widget.controller.hasClients; + final position = has ? widget.controller.position : null; + final isVertical = widget.axis == Axis.vertical; + final mainExtent = isVertical ? constraints.maxHeight : constraints.maxWidth; + final crossExtent = isVertical ? constraints.maxWidth : constraints.maxHeight; + + double thumbLen = mainExtent * 0.25; // default when no metrics + double thumbOffset = 0; + if (has && position!.maxScrollExtent > 0 && position.viewportDimension > 0) { + final total = position.maxScrollExtent + position.viewportDimension; + final ratio = (position.viewportDimension / total).clamp(0.05, 1.0); + thumbLen = mainExtent * ratio; + final offRatio = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.0); + thumbOffset = (mainExtent - thumbLen) * offRatio; + } else if (has && position!.maxScrollExtent == 0) { + // Not scrollable: show full track and a centered thumb + thumbLen = mainExtent * 0.5; + thumbOffset = (mainExtent - thumbLen) / 2; + } + + final track = Container( + color: widget.trackColor, + width: isVertical ? widget.thickness : crossExtent, + height: isVertical ? mainExtent : widget.thickness, + ); + final thumb = Container( + decoration: BoxDecoration( + color: widget.thumbColor, + borderRadius: BorderRadius.circular(widget.thickness / 2), + ), + width: isVertical ? widget.thickness : thumbLen, + height: isVertical ? thumbLen : widget.thickness, + ); + + void jumpToLocal(Offset local) { + if (!widget.interactive) return; + if (!(widget.controller.hasClients)) return; + final pos = widget.controller.position; + if (pos.maxScrollExtent <= 0) return; + final trackLen = (mainExtent - thumbLen); + if (trackLen <= 0) return; + final along = isVertical ? local.dy : local.dx; + final desired = (along - thumbLen / 2).clamp(0.0, trackLen); + final ratio = (desired / trackLen).clamp(0.0, 1.0); + final target = pos.maxScrollExtent * ratio; + try { + widget.controller.jumpTo(target); + } catch (_) {} + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => jumpToLocal(d.localPosition), + onPanDown: (d) => jumpToLocal(d.localPosition), + onPanUpdate: (d) => jumpToLocal(d.localPosition), + child: Stack(children: [ + // Track + if (isVertical) + Positioned(right: 0, top: 0, bottom: 0, width: widget.thickness, child: track) + else + Positioned(left: 0, right: 0, bottom: 0, height: widget.thickness, child: track), + // Thumb + if (isVertical) + Positioned(right: 0, top: thumbOffset, width: widget.thickness, height: thumbLen, child: thumb) + else + Positioned(left: thumbOffset, bottom: 0, height: widget.thickness, width: thumbLen, child: thumb), + ]), + ); + }, + ), + ); + } +}