Serie List Scroll Bar
This commit is contained in:
@@ -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),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user