Need help replicating Scrolling Text functionality from Audible in Flutter - am I doing it the right way?

86 views Asked by At

I'm pretty new to flutter and I'm working on replicating the text scrolling feature for long track titles from the Audible app, (see attached). Here's the behavior I'm trying to achieve:

• Display the long title initially - a portion of this won't be visible because it's too long.

• Pause for a predefined number of seconds, the text should wait a few seconds in this paused state before starting scrolling.

• Start scrolling the text.

• Ensure the entire text is shown via the scroll.

• After the end of the text, insert a specific number of spaces.

• As the spaces conclude, I want the beginning of the text to reappear and continue scrolling.

• The text should scroll until it seems like it's back to its original position.

• Restart the loop (wait -> scroll -> loop).

I've managed to implement most of this. However, my reset point isn't perfect (see attached). I've duplicated the text to make it scroll twice, then I reset the animation when the second set reaches the first text's start position. The timing isn't precise, causing imperfect resets.

It just feels that there's probably a better way to go about this than I have done, but I'm so new to Flutter I don't know what that way might be.

Is there a more efficient way to achieve this, or does somebody have suggestions to improve my existing code?

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.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;

  ScrollingText({
    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 ScrollController _controller;
  late Ticker _ticker;
  late TextPainter textPainter;
  bool shouldScroll = false;
  late String spacedText;  // Added this to ensure spaceCount is applied consistently
  double singleTextWidth = 0;

  @override
  void initState() {
    super.initState();

    _controller = ScrollController();
    _ticker = Ticker(_onTick);
    //spacedText = widget.text + ' ' * widget.spaceCount + widget.text;
    //spacedText = 'Loading...${' ' * widget.spaceCount}Loading...';  // The fact this is being set here is probably not a good thing
  }

  void initializeScrollingText(String title){
    spacedText = title + ' ' * widget.spaceCount + title;

    textPainter = TextPainter(
      text: TextSpan(text: title, style: TextStyle(fontSize: 20)),
      textDirection: TextDirection.ltr,
    )..layout();

    singleTextWidth = textPainter.width;

    if (textPainter.width >= widget.containerWidth) {
      textPainter.text = TextSpan(text: spacedText, style: TextStyle(fontSize: 20));
      textPainter.layout();
    }

    shouldScroll = textPainter.width > widget.containerWidth;

    if (shouldScroll && !_ticker.isActive) {
      _startScrollingWithDelay();
    }
  }

  void _startScrollingWithDelay() {
    Future.delayed(widget.delay, () {
      if (mounted) {
        _ticker.start();
      }
    });
  }

  void _onTick(Duration elapsed) {
    double current = _controller.offset + widget.speed;

    if (current >= singleTextWidth) { // When the second instance is at the beginning.
      _ticker.stop();
      _controller.jumpTo(0);

      Future.delayed(widget.delay, () {
        if (mounted) {
          _ticker.start();
        }
      });
    } else {
      _controller.jumpTo(current);
    }
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<int?>(
      stream: AudioPlayerHandler.instance.player.currentIndexStream,
      builder: (context, snapshot) {
        String title = 'Loading...';  // default text
        if (snapshot.connectionState == ConnectionState.active && snapshot.data != null) {
          title = AudioPlayerHandler.instance.getCurrentTrackTitle() ?? 'NULL VALUE';
        }
        initializeScrollingText(title);  // Initialize scrolling for the new title

        return Container(
          width: widget.containerWidth,
          height: widget.containerHeight,
          child: shouldScroll
              ? ListView.builder(
            itemCount: 1,
            controller: _controller,
            scrollDirection: Axis.horizontal,
            itemBuilder: (BuildContext context, int index) {
              return Text(spacedText,
                  style: TextStyle(fontSize: 20),
                  softWrap: false,
                  overflow: TextOverflow.visible);
            },
          )
              : Center(
            child: Text(spacedText.split(' ')[0], // Since spacedText has repetitions, we only take the first occurrence
                style: TextStyle(fontSize: 20),
                softWrap: true,
                overflow: TextOverflow.ellipsis),
          ),
        );
      },
    );
  }

  @override
  void dispose() {
    _ticker.dispose();
    _controller.dispose();
    super.dispose();
  }
}
2

There are 2 answers

0
k.s poyraz On

This kind of scroll animation is called "marquee". You need exactly this extension: https://pub.dev/packages/text_scroll

I have written one example for your case;

 Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Row(
        children: [
          const Expanded(flex: 2, child: Text('Static text')),
          Flexible(
            flex: 1,
            child: Container(
              color: Colors.grey,
              padding: const EdgeInsets.symmetric(vertical: 5),
              child: const TextScroll(
                'This is the sample text for Flutter TextScroll widget. ',
                velocity: Velocity(pixelsPerSecond: Offset(50, 0)),
                pauseBetween: Duration(milliseconds: 2000), // This will pause animation after it ends. You need this for your case
                mode: TextScrollMode.endless, 
              ),
            ),
          ),
        ],
      ),
    ],
  )

Good Luck

0
Rahul On

I think you could use something like this...

import 'package:flutter/material.dart';

void main() {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: MarqueeText(
          velocity: 50,
          trailPadding: 100,
          text:
              'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incidi',
        ),
      ),
    );
  }
}

class MarqueeText extends StatefulWidget {
  final String text;
  final TextStyle? textStyle;
  final double velocity; // pixel/second
  final Duration initialDelay;
  final int trailPadding;
  const MarqueeText(
      {super.key,
      required this.text,
      this.textStyle,
      this.velocity = 150,
      this.initialDelay = const Duration(milliseconds: 200),
      this.trailPadding = 0});

  @override
  State<MarqueeText> createState() => _MarqueeTextState();
}

class _MarqueeTextState extends State<MarqueeText>
    with SingleTickerProviderStateMixin {
  late final Size size;
  bool _scrollBarDetached = false;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final textPainter = TextPainter(
      text: TextSpan(children: [
        TextSpan(text: widget.text, style: widget.textStyle),
      ]),
      maxLines: 1,
      textDirection: TextDirection.ltr,
    )..layout(minWidth: 0, maxWidth: double.infinity);

    size = Size(
        textPainter.size.width + widget.trailPadding, textPainter.size.height);
  }

  late final ScrollController _scrollController = ScrollController(
    onAttach: (position) {
      Future.delayed(widget.initialDelay, _animate);
    },
    onDetach: (position) {
      _scrollBarDetached = true;
    },
  );

  Future<void> _animate() async {
    final duration = Duration(seconds: size.width ~/ widget.velocity);

    int i = 1;
    while (true) {
      await _scrollController.animateTo(i * size.width,
          duration: duration, curve: Curves.linear);
      await Future.delayed(const Duration(seconds: 3), () {});
      if (_scrollBarDetached || !mounted) {
        break;
      }
      i++;
    }
  }

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

  @override
  Widget build(BuildContext context) {
    Widget child = Text(
      widget.text,
      maxLines: 1,
      overflow: TextOverflow.ellipsis,
    );

    child = Row(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [child, SizedBox(width: widget.trailPadding.toDouble())],
    );

    return IgnorePointer(
      child: SizedBox(
        height: size.height,
        child: ListView.builder(
          controller: _scrollController,
          scrollDirection: Axis.horizontal,
          physics: const NeverScrollableScrollPhysics(),
          addRepaintBoundaries: true,
          prototypeItem: child,
          itemBuilder: (_, __) => child,
        ),
      ),
    );
  }
}

The basic idea is ListView keeps on repeating child when itemCount is not given.