You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何实现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 }

位移计算规则

以文本视口容器高度为基准,根据当前滚动方向和动画进度计算偏移量:

  1. 从上到下滚动时:
    • 进入阶段:文本初始位置在视口上方(Y偏移为负的容器高度),随动画进度线性移动到视口内(Y偏移为0)
    • 停留阶段:保持Y偏移为0,文本静止展示
    • 移出阶段:随动画进度线性移动到视口下方(Y偏移为正的容器高度),完全离开视口
  2. 从下到上滚动时:
    • 进入阶段:文本初始位置在视口下方(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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 11:06:19