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

Flutter复刻Audible滚动文本功能:现有实现是否合理?求优化

改进Flutter滚动文本实现的方案

针对你复刻Audible长标题滚动时重置位置不准的问题,推荐用Flutter的AnimationController替代手动Ticker控制,这样能大幅提升动画的精准度和流畅度,同时简化状态管理。以下是具体改进思路和代码:

核心改进点

  • 用AnimationController管理滚动动画,精准控制时长与循环逻辑,避免手动计算偏移量的误差
  • 计算真实滚动总距离(单文本宽度+空格宽度),让动画结束时机完全匹配文本回到初始位置的时刻
  • 优化文本布局计算逻辑,避免重复初始化
  • 修复StreamBuilder中重复触发初始化的问题,防止状态混乱

完整改进代码

import 'package:flutter/material.dart';
import 'dart:async';
import 'package:the_audiobook_app/utils/audio_player_handler.dart';

class ScrollingText extends StatefulWidget {
  final String? text;
  final double speed;
  final double containerWidth;
  final double containerHeight;
  final int spaceCount;
  final Duration delay;

  const ScrollingText({
    super.key,
    this.text,
    this.speed = 1.0,
    this.containerWidth = 150.0,
    this.containerHeight = 50.0,
    this.spaceCount = 5,
    this.delay = const Duration(seconds: 2),
  });

  @override
  _ScrollingTextState createState() => _ScrollingTextState();
}

class _ScrollingTextState extends State<ScrollingText> with TickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _scrollAnimation;
  late TextPainter _singleTextPainter;
  late TextPainter _spacedTextPainter;
  bool _shouldScroll = false;
  String _currentTitle = 'Loading...';
  String _spacedText = '';
  double _scrollDistance = 0; // 单文本+空格的总宽度

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(vsync: this);
    _setupAnimationListener();
  }

  void _setupAnimationListener() {
    _animationController.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        // 动画结束后重置,等待延迟后再次启动
        _animationController.reset();
        Future.delayed(widget.delay, () {
          if (mounted && _shouldScroll) {
            _animationController.forward();
          }
        });
      }
    });
  }

  void _initializeText(String title) {
    if (_currentTitle == title) return; // 标题未变化时跳过初始化
    _currentTitle = title;

    // 计算单文本宽度
    _singleTextPainter = TextPainter(
      text: TextSpan(text: title, style: const TextStyle(fontSize: 20)),
      textDirection: TextDirection.ltr,
    )..layout();

    // 生成带空格的重复文本
    final spaces = ' ' * widget.spaceCount;
    _spacedText = '$title$spaces$title';

    // 计算带空格的文本总宽度
    _spacedTextPainter = TextPainter(
      text: TextSpan(text: _spacedText, style: const TextStyle(fontSize: 20)),
      textDirection: TextDirection.ltr,
    )..layout();

    // 判断是否需要滚动
    _shouldScroll = _singleTextPainter.width > widget.containerWidth;

    // 计算滚动总距离(需要滚动到第二个文本的开头,即单文本+空格的宽度)
    _scrollDistance = _singleTextPainter.width + (spaces.length * _singleTextPainter.preferredLineHeight / 2); // 估算空格宽度

    if (_shouldScroll) {
      // 根据速度计算动画时长:时长 = 距离 / 速度
      final duration = Duration(milliseconds: (_scrollDistance / widget.speed * 1000).toInt());
      _animationController.duration = duration;

      // 重置动画并启动延迟
      _animationController.reset();
      Future.delayed(widget.delay, () {
        if (mounted) {
          _animationController.forward();
        }
      });
    } else {
      _animationController.stop();
    }
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<int?>(
      stream: AudioPlayerHandler.instance.player.currentIndexStream,
      builder: (context, snapshot) {
        String title = 'Loading...';
        if (snapshot.connectionState == ConnectionState.active && snapshot.data != null) {
          title = AudioPlayerHandler.instance.getCurrentTrackTitle() ?? 'NULL VALUE';
        }
        _initializeText(title); // 仅在标题变化时初始化

        return Container(
          width: widget.containerWidth,
          height: widget.containerHeight,
          clipBehavior: Clip.hardEdge, // 裁剪超出容器的内容
          child: _shouldScroll
              ? AnimatedBuilder(
                  animation: _animationController,
                  builder: (context, child) {
                    return Transform.translate(
                      offset: Offset(-_animationController.value * _scrollDistance, 0),
                      child: child,
                    );
                  },
                  child: Text(
                    _spacedText,
                    style: const TextStyle(fontSize: 20),
                    softWrap: false,
                    overflow: TextOverflow.visible,
                  ),
                )
              : Center(
                  child: Text(
                    _currentTitle,
                    style: const TextStyle(fontSize: 20),
                    softWrap: true,
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
        );
      },
    );
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }
}

关键优化说明

  1. AnimationController替代Ticker:通过动画控制器精准控制滚动时长,动画结束时自动触发重置,完全避免手动计算偏移量的误差
  2. 滚动距离精准计算:基于文本实际宽度和空格宽度计算滚动总距离,确保动画结束时刚好回到初始位置
  3. 标题变化判断:仅在标题更新时重新初始化,避免StreamBuilder每次build都触发不必要的状态更新
  4. Transform.translate实现滚动:相比ListView滚动,Transform的方式更轻量,动画更平滑,且不会有滚动控件的额外状态问题
  5. 延迟逻辑整合:利用动画状态监听实现“滚动→等待→滚动”的循环,逻辑更清晰

内容的提问来源于stack exchange,提问作者Vltor

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.07 14:13:22