marquee_text.dart
4.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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,
),
),
),
),
),
),
);
},
);
}
}