Serie List Scroll Bar

This commit is contained in:
2025-11-10 16:04:59 +01:00
parent d3a2618dd9
commit 74b84d1a4a
2 changed files with 206 additions and 53 deletions
@@ -15,6 +15,7 @@ import 'widgets/season_status_bar.dart';
import '../../../core/io_open.dart'; import '../../../core/io_open.dart';
import '../../../core/config.dart'; import '../../../core/config.dart';
import '../../../core/async_utils.dart'; import '../../../core/async_utils.dart';
import 'widgets/overlay_scrollbar.dart';
class SeriesListScreen extends ConsumerStatefulWidget { class SeriesListScreen extends ConsumerStatefulWidget {
const SeriesListScreen({super.key}); const SeriesListScreen({super.key});
@@ -26,11 +27,15 @@ class SeriesListScreen extends ConsumerStatefulWidget {
class _SeriesListScreenState extends ConsumerState<SeriesListScreen> { class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
final ScrollController _groupedVController = ScrollController(); final ScrollController _groupedVController = ScrollController();
final ScrollController _summaryVController = ScrollController(); final ScrollController _summaryVController = ScrollController();
final ScrollController _groupedHController = ScrollController();
final ScrollController _summaryHController = ScrollController();
@override @override
void dispose() { void dispose() {
_groupedVController.dispose(); _groupedVController.dispose();
_summaryVController.dispose(); _summaryVController.dispose();
_groupedHController.dispose();
_summaryHController.dispose();
super.dispose(); super.dispose();
} }
@@ -809,23 +814,36 @@ class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
showCheckboxColumn: false, showCheckboxColumn: false,
); );
return SingleChildScrollView( return Stack(
scrollDirection: Axis.horizontal, children: [
child: RawScrollbar( Scrollbar(
controller: _groupedVController, controller: _groupedHController,
thumbVisibility: true, child: SingleChildScrollView(
trackVisibility: true, controller: _groupedHController,
interactive: true, scrollDirection: Axis.horizontal,
thickness: 10, child: Scrollbar(
radius: const Radius.circular(6), controller: _groupedVController,
thumbColor: Colors.black54, child: SingleChildScrollView(
trackColor: Colors.black12, controller: _groupedVController,
trackBorderColor: Colors.black26, child: table,
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<SeriesListScreen> {
const headerHeight = 32.0; const headerHeight = 32.0;
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return SingleChildScrollView( return Stack(
scrollDirection: Axis.horizontal, children: [
child: widgets.SizedBox( Scrollbar(
width: tableWidth, controller: _summaryHController,
height: constraints.maxHeight, child: SingleChildScrollView(
child: Stack( controller: _summaryHController,
children: [ scrollDirection: Axis.horizontal,
// Sticky header at the top; scrolls horizontally with content child: widgets.SizedBox(
Positioned( width: tableWidth,
top: 0, height: constraints.maxHeight,
left: 0, child: Column(
right: 0, children: [
child:
widgets.SizedBox(height: headerHeight, child: buildHeader()), widgets.SizedBox(height: headerHeight, child: buildHeader()),
), Expanded(
// Vertical list below the header with its own scrollbar child: Scrollbar(
Positioned.fill( controller: _summaryVController,
top: headerHeight, child: ListView.separated(
child: RawScrollbar( controller: _summaryVController,
controller: _summaryVController, itemCount: keys.length,
thumbVisibility: true, separatorBuilder: (_, __) => const Divider(height: 1),
trackVisibility: true, itemBuilder: (_, i) => widgets.SizedBox(
interactive: true, height: 88,
thickness: 10, child: buildRow(keys[i]),
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]),
), ),
), ],
), ),
), ),
], ),
), ),
), Positioned(
right: 0,
top: 0,
bottom: 0,
child: SizedBox(
width: 10,
child: AlwaysOnScrollbar(
controller: _summaryVController,
axis: Axis.vertical,
thickness: 10,
),
),
),
],
); );
}, },
); );
@@ -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<AlwaysOnScrollbar> createState() => _AlwaysOnScrollbarState();
}
class _AlwaysOnScrollbarState extends State<AlwaysOnScrollbar> {
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),
]),
);
},
),
);
}
}