import "dart:ui" show lerpDouble; import "package:flutter/widgets.dart"; import "package:garage_ui/theme/garage_theme.dart"; // Rest -> hover geometry. A hairline: at rest its barely a mark on the edge // of the pane, and hover only just thickens it. The pill radius falls out of // the thickness so the two never drift. const double _restThickness = 2; const double _hoverThickness = 3.5; const double _restAlpha = 0.35; const double _hoverAlpha = 0.55; const double _dragAlpha = 0.7; // how long the fatten/brighten takes. short - this is a pointer response, not // a transition. const Duration _grow = Duration(milliseconds: 140); /// The Garage scroll thumb. /// /// Every scrollable in a [GarageApp] gets one of these through /// GarageScrollBehavior, so nothing has to wrap itself in a scrollbar by hand. /// Flutters own default is a [RawScrollbar] in stock grey - square ended, 8px, /// no relation to the scheme - which is what you were seeing before this /// existed. /// /// It floats OVER the content (no track, no gutter) so turning it on doesnt /// reflow anything, and it holds itself visible while the pointer is anywhere /// in the pane rather than only while youre actually scrolling. class GarageScrollbar extends StatefulWidget { const GarageScrollbar({super.key, required this.child, this.controller}); final Widget child; /// The controller of the scrollable underneath. Comes straight off /// ScrollableDetails when this is built by the scroll behaviour; null falls /// back to the PrimaryScrollController the same way [RawScrollbar] does. final ScrollController? controller; @override State createState() => _GarageScrollbarState(); } class _GarageScrollbarState extends State { bool _hovered = false; bool _dragging = false; ScrollController? get _controller => widget.controller ?? PrimaryScrollController.maybeOf(context); // RawScrollbar only tolerates thumbVisibility when its controller is wired // to exactly one live position - it asserts on everything else. We flip that // flag on hover rather than at construction, so the same checks have to // happen here first: a pane whose controller hasnt attached yet would // otherwise throw the moment the pointer crossed it, and one whose content // fits would paint a full height thumb over nothing. bool get _canHold { final c = _controller; if (c == null || !c.hasClients || c.positions.length != 1) return false; final p = c.position; if (!p.hasContentDimensions) return false; return p.maxScrollExtent > p.minScrollExtent; } void _setHovered(bool value) { if (_hovered == value) return; setState(() { _hovered = value; // a drag that ends outside the pane never reports back (the recognisers // cancel path doesnt route through handleThumbPressEnd), so dont let the // flag stick. if (!value) _dragging = false; }); } void _setDragging(bool value) { if (_dragging == value) return; setState(() => _dragging = value); } @override Widget build(BuildContext context) { final scheme = GarageTheme.maybeOf(context)?.colorScheme; // outside a GarageTheme (a bare test host, mostly) fall back to flutters // own grey rather than asserting - a scrollbar is never worth a red screen. final base = scheme?.mutedForeground ?? const Color(0xffbcbcbc); final lit = _hovered || _dragging; return MouseRegion( opaque: false, onEnter: (_) => _setHovered(true), onExit: (_) => _setHovered(false), child: Listener( onPointerUp: (_) => _setDragging(false), onPointerCancel: (_) => _setDragging(false), child: TweenAnimationBuilder( tween: Tween(end: lit ? 1 : 0), duration: _grow, curve: Curves.easeOut, builder: (context, t, child) { final thickness = lerpDouble(_restThickness, _hoverThickness, t)!; final alpha = _dragging ? _dragAlpha : lerpDouble(_restAlpha, _hoverAlpha, t)!; return _GarageRawScrollbar( controller: widget.controller, thumbColor: base.withValues(alpha: alpha), thickness: thickness, radius: Radius.circular(thickness / 2), // null, not false: false would mean "definitely hidden" and // kill the fade in on scroll. thumbVisibility: lit && _canHold ? true : null, crossAxisMargin: 2, mainAxisMargin: 2, minThumbLength: 24, onDragChanged: _setDragging, child: child!, ); }, child: widget.child, ), ), ); } } // The only reason this subclass exists: RawScrollbar keeps its drag state to // itself, and the thumb is meant to go full strength while youre holding it. class _GarageRawScrollbar extends RawScrollbar { const _GarageRawScrollbar({ required super.child, required this.onDragChanged, super.controller, super.thumbVisibility, super.thumbColor, super.thickness, super.radius, super.crossAxisMargin, super.mainAxisMargin, super.minThumbLength, }); final ValueChanged onDragChanged; @override RawScrollbarState<_GarageRawScrollbar> createState() => _GarageRawScrollbarState(); } class _GarageRawScrollbarState extends RawScrollbarState<_GarageRawScrollbar> { @override void handleThumbPressStart(Offset localPosition) { super.handleThumbPressStart(localPosition); widget.onDragChanged(true); } @override void handleThumbPressEnd(Offset localPosition, Velocity velocity) { super.handleThumbPressEnd(localPosition, velocity); widget.onDragChanged(false); } }