Search code examples
flutterdartanimationcontroller

How to animate a text widget with an AnimationController


I am trying to build a widget that can be shown or hidden, and when it is shown, will have is displayed text 'grow' over a certain duration. I've based it mostly on the typing indicator example.

The idea is to have the widget in e.g. a form with state and provide some pretty user feedback in certain circumstances, such as during and after a validation REST call.

I can't quite figure out how to 'plug in' the AnimationController, in order to grow substring of the input string to display.

i.e. in the form the widget will be something like

AnimatedText(
   textContent: stringFeedback,
   doShowMe: haveFeedback,
            ),

... and in my async input processing method i have a setState(() => haveFeedback = true); and a false etc.

I imagine I need to call a something like the updateText() method below from somewhere somehow linked the the value of the AnimationControler _appearanceController but how to have that be a loop escapes me - still being new to the flutter/Dart and for that matter OOP paradigm.

What I have so far is:

import 'package:flutter/material.dart';
import 'dart:developer' as developer;

class AnimatedText extends StatefulWidget {
  const AnimatedText({
    Key? key,
    this.doShowMe = false,
    this.textContent = '',
  }) : super(key: key);

  final bool doShowMe;
  final String textContent;

  @override
  State<AnimatedText> createState() => _AnimatedTextState();
}

class _AnimatedTextState extends State<AnimatedText>
    with SingleTickerProviderStateMixin {
  late AnimationController _appearanceController;
  late String displayText;

  @override
  void initState() {
    super.initState();
    developer.log('_AnimatedTextState init ');
    _appearanceController = AnimationController(vsync: this);
    displayText = '';
    if (widget.doShowMe) {
      _doShowMe();
    }
  }

  @override
  void didUpdateWidget(AnimatedText oldWidget) {
    super.didUpdateWidget(oldWidget);
    developer.log('_AnimatedTextState didUpdateWidget');
    if (widget.doShowMe != oldWidget.doShowMe) {
      if (widget.doShowMe) {
        developer.log('_AnimatedTextState didUpdateWidget show');
        _doShowMe();
      } else {
        developer.log('_AnimatedTextState didUpdateWidget hide');
        _hideIndicator();
      }
    }
  }

  @override
  void dispose() {
    developer.log('_AnimatedTextState dispose');
    _appearanceController.dispose();
    displayText = '';
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: _appearanceController,
        builder: (context, child) {
          return Container(
            child: Text(displayText),
          );
        });
  }

  void updateText() {
    //something like...
    String payload = widget.textContent;
    if (displayText != payload) {
      int numCharsToShow =
          (_appearanceController.value * widget.textContent.length).ceil();
      displayText = payload.substring(0, numCharsToShow);
      developer.log('updated displayText up to $numCharsToShow');
    }
  }

  void _doShowMe() {
    _appearanceController
      ..duration = const Duration(milliseconds: 750)
      ..forward();
  }

  void _hideIndicator() {
    _appearanceController
      ..duration = const Duration(milliseconds: 150)
      ..reverse();
  }
}

Any help much appreciated.


Solution

  • You can use the addListener method to execute some code whenever the value of the AnimationControler changes.

    Between that and the didUpdateWidget method one should be able to deal with most scenarios I think.

    In the below code the widget will grow with its payload text appearing as if typed character by character.

    When the Boolean variable controling whether it should be shown or hidden changes it will shrink or grow.

    If the payload variable changes while the text is being shown, the animation is reset and starts over.

    import 'package:flutter/material.dart';
    
    class AnimatedText extends StatefulWidget {
      const AnimatedText({
        Key? key,
        this.doShowMe = false,
        this.textContent = '',
      }) : super(key: key);
    
      final bool doShowMe;
      final String textContent;
    
      @override
      State<AnimatedText> createState() => _AnimatedTextState();
    }
    
    class _AnimatedTextState extends State<AnimatedText>
        with SingleTickerProviderStateMixin {
      late AnimationController _appearanceController;
      late String displayText;
      late String previousText;
    
      @override
      void initState() {
        super.initState();
        displayText = '';
        previousText = widget.textContent;
        _appearanceController = AnimationController(
          vsync: this,
          duration: const Duration(milliseconds: 1500),
        )..addListener(
            () => updateText(),
          );
        if (widget.doShowMe) {
          _doShowMe();
        }
      }
    
      void updateText() {
        String payload = widget.textContent;
        int numCharsToShow =
            (_appearanceController.value * widget.textContent.length).ceil();
        if (widget.doShowMe) {
          // make it grow
          displayText = payload.substring(0, numCharsToShow);
        } else {
          // make it shrink
          displayText =
              payload.substring(payload.length - numCharsToShow, payload.length);
        }
      }
    
      @override
      void didUpdateWidget(AnimatedText oldWidget) {
        super.didUpdateWidget(oldWidget);
        if ((widget.doShowMe != oldWidget.doShowMe) ||
            (widget.textContent != oldWidget.textContent)) {
          if (widget.doShowMe) {
            _doShowMe();
          } else {
            _doHideMe();
          }
        }
        if (widget.doShowMe && widget.textContent != previousText) {
          previousText = widget.textContent;
          _appearanceController
            ..reset()
            ..forward();
        }
      }
    
      @override
      void dispose() {
        _appearanceController.dispose();
        displayText = '';
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return AnimatedBuilder(
            animation: _appearanceController,
            builder: (context, child) {
              return Text(displayText);
            });
      }
    
      void _doShowMe() {
        _appearanceController
          ..duration = const Duration(milliseconds: 1500)
          ..forward();
      }
    
      void _doHideMe() {
        _appearanceController
          ..duration = const Duration(milliseconds: 500)
          ..reverse();
      }
    }
    
    

    Usage example:

    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: const MyHomePage(title: 'Flutter Demo Home Page'),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      const MyHomePage({Key? key, required this.title}) : super(key: key);
      final String title;
    
      @override
      State<MyHomePage> createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      bool doShow = true;
      String msg =
          "This is some longer text. Lorem ipsum swhatcha-methingy. "
          "We'll add a simple counter to see what happens when the payload changes. ";
      int count = 0;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: Center(
            //
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  'Here follows the animated text widget:',
                ),
                AnimatedText(
                  doShowMe: doShow,
                  textContent: msg,
                ),
                ElevatedButton(
                  onPressed: () {
                    setState(() {
                      doShow = !doShow;
                    });
                  },
                  child: Text('Toggle showing'),
                ),
                ElevatedButton(
                  onPressed: () {
                    count++;
                    setState(() {
                      msg = '$msg $count';
                    });
                  },
                  child: Text('Change payload'),
                ),
              ],
            ),
          ),
        );
      }
    }