import 'package:flutter/material.dart'; /// One-line text that scrolls only while [animate] is true. /// /// Long titles loop with a small gap; short titles (including "Nothing /// playing") stay static. Pausing stops the animation at its current offset, /// and resuming continues from there. class MarqueeText extends StatefulWidget { const MarqueeText({ super.key, required this.text, this.style, this.animate = false, this.gap = 48, }); final String text; final TextStyle? style; final bool animate; final double gap; @override State createState() => _MarqueeTextState(); } class _MarqueeTextState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; bool _needsScroll = false; double _textWidth = 0; bool _syncScheduled = false; bool get _shouldAnimate => widget.animate && _needsScroll; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(seconds: 10), )..addStatusListener(_onStatus); } void _onStatus(AnimationStatus status) { if (status == AnimationStatus.completed && _shouldAnimate) { _controller.value = 0; _controller.forward(); } } @override void didUpdateWidget(covariant MarqueeText oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.text != widget.text || oldWidget.style != widget.style) { _controller.value = 0; _needsScroll = false; } _scheduleSync(); } @override void dispose() { _controller.dispose(); super.dispose(); } void _scheduleSync() { if (_syncScheduled) return; _syncScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _syncScheduled = false; if (mounted) _sync(); }); } void _sync() { if (_shouldAnimate) { if (!_controller.isAnimating) { if (_controller.value >= 1) _controller.value = 0; _controller.forward(); } } else { _controller.stop(); } } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final style = widget.style ?? DefaultTextStyle.of(context).style; final painter = TextPainter( text: TextSpan(text: widget.text, style: style), maxLines: 1, textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), )..layout(); final width = painter.width; final needsScroll = constraints.hasBoundedWidth && width > constraints.maxWidth; if (width != _textWidth || needsScroll != _needsScroll) { _textWidth = width; _needsScroll = needsScroll; _scheduleSync(); } if (!needsScroll) { return Text( widget.text, maxLines: 1, overflow: TextOverflow.ellipsis, style: style, ); } final duration = Duration( milliseconds: ((width / 28) * 1000).clamp(6000, 18000).round(), ); if (_controller.duration != duration) { _controller.duration = duration; } return ClipRect( child: AnimatedBuilder( animation: _controller, builder: (context, _) { final offset = _controller.value * (width + widget.gap); return OverflowBox( alignment: Alignment.centerLeft, maxWidth: double.infinity, maxHeight: constraints.maxHeight, child: Transform.translate( offset: Offset(-offset, 0), child: SizedBox( width: width * 2 + widget.gap, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( widget.text, maxLines: 1, softWrap: false, style: style, ), SizedBox(width: widget.gap), Text( widget.text, maxLines: 1, softWrap: false, style: style, ), ], ), ), ), ); }, ), ); }, ); } }