part of '../timeline_screen.dart'; class _BottomScrubber extends StatelessWidget { const _BottomScrubber(this.scroller, {super.key, required this.timelineMinSize, required this.size, required this.selectedWonder}); final ScrollController? scroller; final double timelineMinSize; final double size; final WonderType? selectedWonder; /// Calculate what fraction the scroller has travelled double _calculateScrollFraction(ScrollPosition? pos) { if (pos == null || pos.maxScrollExtent == 0) return 0; return pos.pixels / pos.maxScrollExtent; } /// Calculates what fraction of the scroller is current visible double _calculateViewPortFraction(ScrollPosition? pos) { if (pos == null) return 1; final viewportSize = pos.viewportDimension; final result = viewportSize / (pos.maxScrollExtent + viewportSize); return result.clamp(0, 1); } @override Widget build(BuildContext context) { final scroller = this.scroller; /// It might take a frame until we receive a valid scroller if (scroller == null) return SizedBox.shrink(); return LayoutBuilder(builder: (context, constraints) { void handleScrubberPan(DragUpdateDetails details) { final totalWidth = constraints.maxWidth; if (!scroller.hasClients) return; double dragMultiplier = (scroller.position.maxScrollExtent + timelineMinSize) / totalWidth; double newPos = scroller.position.pixels + details.delta.dx * dragMultiplier; scroller.position.jumpTo(newPos.clamp(0, scroller.position.maxScrollExtent)); } return SizedBox( height: size, child: Stack( children: [ /// Timeline background Container( padding: EdgeInsets.all($styles.insets.md), decoration: BoxDecoration( color: $styles.colors.greyStrong, borderRadius: BorderRadius.circular($styles.corners.md), ), child: WondersTimelineBuilder( crossAxisGap: 4, selectedWonders: selectedWonder != null ? [selectedWonder!] : [], ), ), /// Visible area, follows the position of scroller AnimatedBuilder( animation: scroller, builder: (_, __) { ScrollPosition? pos; if (scroller.hasClients) pos = scroller.position; // Get current scroll offset and move the viewport to match double scrollFraction = _calculateScrollFraction(pos); double viewPortFraction = _calculateViewPortFraction(pos); final scrubberAlign = Alignment(-1 + scrollFraction * 2, 0); return Positioned.fill( child: Semantics( container: true, slider: true, label: $strings.bottomScrubberSemanticTimeline, child: GestureDetector( behavior: HitTestBehavior.translucent, onPanUpdate: handleScrubberPan, /// Scrub area child: Align( alignment: scrubberAlign, child: FractionallySizedBox( widthFactor: viewPortFraction, heightFactor: 1, child: _buildOutlineBox(context, scrubberAlign), ), ), ), ), ); }, ) ], ), ); }); } Container _buildOutlineBox(BuildContext context, Alignment alignment) { final borderColor = $styles.colors.white; return Container( decoration: BoxDecoration(border: Border.all(color: borderColor)), child: Align(alignment: alignment, child: DashedLine(vertical: true)), ); } }