如何实现Text中List<String>上下往返滚动及图标联动动画
Text组件循环往返滚动动画实现方案
核心逻辑拆解:
- 方向自动反转:通过枚举标记当前滚动方向,初始值为
upToDown(从上到下),当滚动索引触达List<String>最后一位时自动切换为downToUp(从下到上),索引回到列表0位时再切回初始方向,形成往返循环 - 三阶段动画拆分:将单条文本的完整动画周期按比例拆分为「Text In 文本进入」「Text Halt 文本停留」「Text Out 文本移出」三个阶段,每个阶段独立计算文本Y轴位移,阶段切换时同步触发相邻icon的状态变更
- icon状态联动:将icon的动效开关和文本动画阶段做强绑定,文本处于进入/移出的滚动阶段时,icon保持循环动效;文本进入停留阶段时,立刻暂停icon动效,保持静止状态
核心状态定义
先定义两个枚举统一管理状态,避免硬编码导致的逻辑混乱:
// 滚动方向枚举 enum ScrollDirection { upToDown, downToUp } // 动画阶段枚举 enum AnimatePhase { textIn, textHalt, textOut }
位移计算规则
以文本视口容器高度为基准,根据当前滚动方向和动画进度计算偏移量:
- 从上到下滚动时:
- 进入阶段:文本初始位置在视口上方(Y偏移为负的容器高度),随动画进度线性移动到视口内(Y偏移为0)
- 停留阶段:保持Y偏移为0,文本静止展示
- 移出阶段:随动画进度线性移动到视口下方(Y偏移为正的容器高度),完全离开视口
- 从下到上滚动时:
- 进入阶段:文本初始位置在视口下方(Y偏移为正的容器高度),随动画进度线性移动到视口内(Y偏移为0)
- 停留阶段:保持Y偏移为0,文本静止展示
- 移出阶段:随动画进度线性移动到视口上方(Y偏移为负的容器高度),完全离开视口
完整可运行代码(Flutter实现)
import 'package:flutter/material.dart'; class LoopScrollText extends StatefulWidget { final List<String> textList; final Widget Function(bool isScrolling) iconBuilder; const LoopScrollText({ super.key, required this.textList, required this.iconBuilder, }); @override State<LoopScrollText> createState() => _LoopScrollTextState(); } class _LoopScrollTextState extends State<LoopScrollText> with TickerProviderStateMixin { late final AnimationController _textAnimCtrl; late final AnimationController _iconAnimCtrl; ScrollDirection _currentDirection = ScrollDirection.upToDown; AnimatePhase _currentPhase = AnimatePhase.textIn; int _currentTextIndex = 0; // 各阶段时长占比,可根据业务需求调整 final double inPhaseRatio = 0.3; final double haltPhaseRatio = 0.4; final double outPhaseRatio = 0.3; // 单条文本完整动画周期 final Duration singleCycleDuration = const Duration(seconds: 2); @override void initState() { super.initState(); _textAnimCtrl = AnimationController(vsync: this, duration: singleCycleDuration) ..addListener(_onTextAnimUpdate) ..forward(); _iconAnimCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 300) )..repeat(); } void _onTextAnimUpdate() { final progress = _textAnimCtrl.value; // 更新当前动画阶段 if (progress < inPhaseRatio) { _currentPhase = AnimatePhase.textIn; if (!_iconAnimCtrl.isAnimating) _iconAnimCtrl.repeat(); } else if (progress < inPhaseRatio + haltPhaseRatio) { _currentPhase = AnimatePhase.textHalt; _iconAnimCtrl.stop(); } else { _currentPhase = AnimatePhase.textOut; if (!_iconAnimCtrl.isAnimating) _iconAnimCtrl.repeat(); } // 单周期结束,更新索引和滚动方向 if (progress == 1.0) { _textAnimCtrl.reset(); final isReachEnd = _currentDirection == ScrollDirection.upToDown && _currentTextIndex == widget.textList.length - 1; final isReachStart = _currentDirection == ScrollDirection.downToUp && _currentTextIndex == 0; if (isReachEnd) { _currentDirection = ScrollDirection.downToUp; } else if (isReachStart) { _currentDirection = ScrollDirection.upToDown; } else { _currentTextIndex += _currentDirection == ScrollDirection.upToDown ? 1 : -1; } _textAnimCtrl.forward(); } setState(() {}); } double _calcOffset(double containerHeight) { final progress = _textAnimCtrl.value; if (_currentDirection == ScrollDirection.upToDown) { if (progress < inPhaseRatio) { return -containerHeight + containerHeight * (progress / inPhaseRatio); } else if (progress < inPhaseRatio + haltPhaseRatio) { return 0; } else { final outProgress = (progress - inPhaseRatio - haltPhaseRatio) / outPhaseRatio; return containerHeight * outProgress; } } else { if (progress < inPhaseRatio) { return containerHeight - containerHeight * (progress / inPhaseRatio); } else if (progress < inPhaseRatio + haltPhaseRatio) { return 0; } else { final outProgress = (progress - inPhaseRatio - haltPhaseRatio) / outPhaseRatio; return -containerHeight * outProgress; } } } @override void dispose() { _textAnimCtrl.dispose(); _iconAnimCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ AnimatedBuilder( animation: _iconAnimCtrl, builder: (context, _) => widget.iconBuilder(_currentPhase != AnimatePhase.textHalt), ), const SizedBox(width: 8), ClipRect( child: SizedBox( height: 24, width: 200, child: LayoutBuilder( builder: (context, constraints) { final offset = _calcOffset(constraints.maxHeight); return Transform.translate( offset: Offset(0, offset), child: Text( widget.textList[_currentTextIndex], style: const TextStyle(fontSize: 16, height: 1.5), maxLines: 1, overflow: TextOverflow.ellipsis, ), ); }, ), ), ), ], ); } }
组件调用示例
LoopScrollText( textList: const ["系统通知1", "系统通知2", "系统通知3", "系统通知4"], iconBuilder: (isScrolling) => Icon( isScrolling ? Icons.notifications_active : Icons.notifications, color: isScrolling ? Colors.blueAccent : Colors.grey, size: 20, ), )
自定义调整说明
- 可修改三个阶段的时长占比、单条动画总时长,匹配产品的展示节奏
- 位移计算逻辑可叠加动画曲线(如
Curves.easeInOut),让滚动效果更顺滑 - icon动效可自定义为帧切换、透明度变化、缩放等任意效果,只需根据
isScrolling参数控制动效开关即可
内容的提问来源于stack exchange,提问作者Ajit Sharma
相关产品推荐
相关产品推荐

