| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- 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<MarqueeText> createState() => _MarqueeTextState();
- }
- class _MarqueeTextState extends State<MarqueeText>
- 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,
- ),
- ],
- ),
- ),
- ),
- );
- },
- ),
- );
- },
- );
- }
- }
|