marquee_text.dart 4.6 KB
import 'dart:async';

import 'package:flutter/material.dart';

/// 无缝首尾相接的跑马灯文字组件:
/// - 当文字宽度 <= 容器宽度时:正常居中/按 textAlign 静止展示
/// - 当文字宽度 > 容器宽度时:文字向左平滑连续滚动,尾部自带留白并首尾无缝衔接循环
class MarqueeText extends StatefulWidget {
  const MarqueeText({
    super.key,
    required this.text,
    this.style,
    this.textAlign = TextAlign.center,
    this.velocity = 30.0,
    this.blankSpace = 48.0,
    this.startDelay = const Duration(seconds: 1),
  });

  final String text;
  final TextStyle? style;
  final TextAlign textAlign;

  /// 滚动速度(像素/秒),数值越大滚动越快,建议 25~40
  final double velocity;

  /// 首尾循环之间的间隔空白宽度(像素)
  final double blankSpace;

  /// 页面加载后首次滚动的初始延迟
  final Duration startDelay;

  @override
  State<MarqueeText> createState() => _MarqueeTextState();
}

class _MarqueeTextState extends State<MarqueeText>
    with SingleTickerProviderStateMixin {
  AnimationController? _controller;
  double _lastTextWidth = 0.0;
  Timer? _delayTimer;

  @override
  void dispose() {
    _delayTimer?.cancel();
    _controller?.dispose();
    super.dispose();
  }

  void _setupAnimation(double textWidth, double containerWidth) {
    if (textWidth <= containerWidth || containerWidth <= 0) {
      _delayTimer?.cancel();
      _controller?.stop();
      _controller?.dispose();
      _controller = null;
      _lastTextWidth = 0;
      return;
    }

    final cycleDistance = textWidth + widget.blankSpace;
    if (_controller != null && (_lastTextWidth - textWidth).abs() < 1.0) {
      return;
    }

    _lastTextWidth = textWidth;
    _delayTimer?.cancel();
    _controller?.dispose();

    final durationSeconds = cycleDistance / widget.velocity;
    final totalDuration = Duration(
      milliseconds: (durationSeconds * 1000).toInt().clamp(1000, 60000),
    );

    final controller = AnimationController(
      vsync: this,
      duration: totalDuration,
    );
    _controller = controller;

    _delayTimer = Timer(widget.startDelay, () {
      if (mounted && _controller == controller) {
        controller.repeat();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final effectiveStyle =
        DefaultTextStyle.of(context).style.merge(widget.style);

    return LayoutBuilder(
      builder: (context, constraints) {
        final textPainter = TextPainter(
          text: TextSpan(text: widget.text, style: effectiveStyle),
          textDirection: Directionality.of(context),
          maxLines: 1,
        )..layout();

        final textWidth = textPainter.width;
        final maxWidth = constraints.maxWidth;

        // 文本未超出容器宽度:普通渲染
        if (textWidth <= maxWidth || maxWidth.isInfinite) {
          _setupAnimation(0, 0);
          return Text(
            widget.text,
            style: widget.style,
            textAlign: widget.textAlign,
            maxLines: 1,
          );
        }

        // 文本超出容器宽度:开启首尾无缝循环跑马灯
        _setupAnimation(textWidth, maxWidth);
        final cycleDistance = textWidth + widget.blankSpace;
        final repeatCount = (maxWidth / cycleDistance).ceil() + 2;

        final controller = _controller;
        if (controller == null) {
          return Text(
            widget.text,
            style: widget.style,
            maxLines: 1,
          );
        }

        return ClipRect(
          child: AnimatedBuilder(
            animation: controller,
            builder: (context, child) {
              final offset = controller.value * cycleDistance;
              return Transform.translate(
                offset: Offset(-offset, 0),
                child: child,
              );
            },
            child: OverflowBox(
              alignment: Alignment.centerLeft,
              minWidth: 0.0,
              maxWidth: double.infinity,
              minHeight: 0.0,
              maxHeight: constraints.hasBoundedHeight ? constraints.maxHeight : null,
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: List.generate(
                  repeatCount,
                  (_) => Padding(
                    padding: EdgeInsets.only(right: widget.blankSpace),
                    child: Text(
                      widget.text,
                      style: widget.style,
                      maxLines: 1,
                    ),
                  ),
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}