|
|
|
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,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} |
...
|
...
|
|