← Back to Blog

Flutter Animation Text and Loading Message Localization

flutteranimationloadinglocalizationtypewriterprogress

Flutter Animation Text and Loading Message Localization

Animations enhance user experience, but localized animations require special attention. From loading spinners with rotating messages to animated text reveals, this guide covers everything you need to know about localizing animated content in Flutter applications.

Understanding Animation Localization

Animated text and loading messages present unique localization challenges:

  1. Text length variation - Translations may be longer or shorter
  2. Reading direction - Animations must respect RTL languages
  3. Timing adjustments - Different text lengths need different display durations
  4. Cultural appropriateness - Animation styles may need locale adaptation
  5. Performance - Localized content shouldn't impact animation smoothness

Setting Up Animated Text Localization

ARB File Structure

{
  "@@locale": "en",

  "loadingMessage": "Loading...",
  "@loadingMessage": {
    "description": "Generic loading message"
  },

  "loadingWithProgress": "Loading {percent}%",
  "@loadingWithProgress": {
    "description": "Loading message with percentage",
    "placeholders": {
      "percent": {
        "type": "int"
      }
    }
  },

  "loadingSteps": "{step, select, fetching{Fetching data...} processing{Processing...} finalizing{Almost done...} other{Loading...}}",
  "@loadingSteps": {
    "description": "Loading steps messages",
    "placeholders": {
      "step": {
        "type": "String"
      }
    }
  },

  "rotatingTip1": "Did you know? You can swipe to navigate.",
  "@rotatingTip1": {
    "description": "First rotating tip during loading"
  },

  "rotatingTip2": "Pro tip: Double-tap to zoom in.",
  "@rotatingTip2": {
    "description": "Second rotating tip during loading"
  },

  "rotatingTip3": "Hint: Pull down to refresh the list.",
  "@rotatingTip3": {
    "description": "Third rotating tip during loading"
  },

  "welcomeAnimatedText": "Welcome to {appName}",
  "@welcomeAnimatedText": {
    "description": "Animated welcome message",
    "placeholders": {
      "appName": {
        "type": "String"
      }
    }
  },

  "typewriterIntro": "Let's get started with your journey...",
  "@typewriterIntro": {
    "description": "Typewriter effect introduction text"
  },

  "countdownReady": "Ready",
  "@countdownReady": {
    "description": "Countdown ready text"
  },

  "countdownSet": "Set",
  "@countdownSet": {
    "description": "Countdown set text"
  },

  "countdownGo": "Go!",
  "@countdownGo": {
    "description": "Countdown go text"
  },

  "savingProgress": "Saving your progress...",
  "@savingProgress": {
    "description": "Save progress message"
  },

  "syncingData": "Syncing with cloud...",
  "@syncingData": {
    "description": "Data sync message"
  },

  "uploadingFile": "Uploading {fileName}...",
  "@uploadingFile": {
    "description": "File upload message",
    "placeholders": {
      "fileName": {
        "type": "String"
      }
    }
  },

  "processingItems": "Processing {current} of {total}",
  "@processingItems": {
    "description": "Processing progress message",
    "placeholders": {
      "current": {
        "type": "int"
      },
      "total": {
        "type": "int"
      }
    }
  }
}

Japanese Translations

{
  "@@locale": "ja",

  "loadingMessage": "読み込み中...",
  "loadingWithProgress": "読み込み中 {percent}%",
  "loadingSteps": "{step, select, fetching{データを取得中...} processing{処理中...} finalizing{もうすぐ完了...} other{読み込み中...}}",
  "rotatingTip1": "ヒント:スワイプで移動できます。",
  "rotatingTip2": "プロのコツ:ダブルタップでズーム。",
  "rotatingTip3": "ヒント:下に引いてリストを更新。",
  "welcomeAnimatedText": "{appName}へようこそ",
  "typewriterIntro": "あなたの旅を始めましょう...",
  "countdownReady": "位置について",
  "countdownSet": "よーい",
  "countdownGo": "ドン!",
  "savingProgress": "進行状況を保存中...",
  "syncingData": "クラウドと同期中...",
  "uploadingFile": "{fileName}をアップロード中...",
  "processingItems": "{total}件中{current}件を処理中"
}

Arabic Translations (RTL)

{
  "@@locale": "ar",

  "loadingMessage": "جارٍ التحميل...",
  "loadingWithProgress": "جارٍ التحميل {percent}%",
  "loadingSteps": "{step, select, fetching{جارٍ جلب البيانات...} processing{جارٍ المعالجة...} finalizing{اكتمل تقريباً...} other{جارٍ التحميل...}}",
  "rotatingTip1": "هل تعلم؟ يمكنك التمرير للتنقل.",
  "rotatingTip2": "نصيحة: انقر مرتين للتكبير.",
  "rotatingTip3": "تلميح: اسحب للأسفل لتحديث القائمة.",
  "welcomeAnimatedText": "مرحباً بك في {appName}",
  "typewriterIntro": "لنبدأ رحلتك...",
  "countdownReady": "استعد",
  "countdownSet": "تأهب",
  "countdownGo": "انطلق!",
  "savingProgress": "جارٍ حفظ تقدمك...",
  "syncingData": "جارٍ المزامنة مع السحابة...",
  "uploadingFile": "جارٍ رفع {fileName}...",
  "processingItems": "معالجة {current} من {total}"
}

Animated Loading Messages

Rotating Tips During Loading

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class RotatingTipsLoader extends StatefulWidget {
  final bool isLoading;
  final Widget child;
  final Duration tipDuration;

  const RotatingTipsLoader({
    super.key,
    required this.isLoading,
    required this.child,
    this.tipDuration = const Duration(seconds: 4),
  });

  @override
  State<RotatingTipsLoader> createState() => _RotatingTipsLoaderState();
}

class _RotatingTipsLoaderState extends State<RotatingTipsLoader>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _fadeAnimation;
  int _currentTipIndex = 0;
  late List<String> _tips;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 500),
      vsync: this,
    );
    _fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );
    _startTipRotation();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _loadLocalizedTips();
  }

  void _loadLocalizedTips() {
    final l10n = AppLocalizations.of(context)!;
    _tips = [
      l10n.rotatingTip1,
      l10n.rotatingTip2,
      l10n.rotatingTip3,
    ];
  }

  void _startTipRotation() {
    if (!widget.isLoading) return;

    _controller.forward().then((_) {
      Future.delayed(widget.tipDuration, () {
        if (mounted && widget.isLoading) {
          _controller.reverse().then((_) {
            if (mounted) {
              setState(() {
                _currentTipIndex = (_currentTipIndex + 1) % _tips.length;
              });
              _startTipRotation();
            }
          });
        }
      });
    });
  }

  @override
  void didUpdateWidget(RotatingTipsLoader oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.isLoading && !oldWidget.isLoading) {
      _startTipRotation();
    }
  }

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

  @override
  Widget build(BuildContext context) {
    if (!widget.isLoading) {
      return widget.child;
    }

    final l10n = AppLocalizations.of(context)!;

    return Stack(
      children: [
        widget.child,
        Container(
          color: Colors.black54,
          child: Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const CircularProgressIndicator(),
                const SizedBox(height: 24),
                Text(
                  l10n.loadingMessage,
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 18,
                  ),
                ),
                const SizedBox(height: 16),
                FadeTransition(
                  opacity: _fadeAnimation,
                  child: Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 32),
                    child: Text(
                      _tips.isNotEmpty ? _tips[_currentTipIndex] : '',
                      style: const TextStyle(
                        color: Colors.white70,
                        fontSize: 14,
                      ),
                      textAlign: TextAlign.center,
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

Step-Based Loading Animation

class StepLoadingIndicator extends StatefulWidget {
  final Stream<LoadingStep> stepStream;

  const StepLoadingIndicator({
    super.key,
    required this.stepStream,
  });

  @override
  State<StepLoadingIndicator> createState() => _StepLoadingIndicatorState();
}

enum LoadingStep { fetching, processing, finalizing, complete }

class _StepLoadingIndicatorState extends State<StepLoadingIndicator>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _slideAnimation;
  LoadingStep _currentStep = LoadingStep.fetching;
  LoadingStep? _previousStep;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );
    _slideAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeOut),
    );

    widget.stepStream.listen((step) {
      setState(() {
        _previousStep = _currentStep;
        _currentStep = step;
      });
      _controller.forward(from: 0);
    });
  }

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

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final isRtl = Directionality.of(context) == TextDirection.rtl;

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        _buildProgressIndicator(),
        const SizedBox(height: 16),
        AnimatedBuilder(
          animation: _slideAnimation,
          builder: (context, child) {
            final offset = isRtl
                ? Offset(-30 * (1 - _slideAnimation.value), 0)
                : Offset(30 * (1 - _slideAnimation.value), 0);

            return Transform.translate(
              offset: offset,
              child: Opacity(
                opacity: _slideAnimation.value,
                child: Text(
                  _getStepMessage(l10n, _currentStep),
                  style: Theme.of(context).textTheme.bodyLarge,
                ),
              ),
            );
          },
        ),
      ],
    );
  }

  Widget _buildProgressIndicator() {
    final steps = LoadingStep.values.where((s) => s != LoadingStep.complete);
    final currentIndex = steps.toList().indexOf(_currentStep);

    return Row(
      mainAxisSize: MainAxisSize.min,
      children: steps.map((step) {
        final stepIndex = steps.toList().indexOf(step);
        final isComplete = stepIndex < currentIndex;
        final isCurrent = step == _currentStep;

        return Container(
          margin: const EdgeInsets.symmetric(horizontal: 4),
          width: 12,
          height: 12,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: isComplete
                ? Colors.green
                : isCurrent
                    ? Colors.blue
                    : Colors.grey.shade300,
          ),
          child: isCurrent
              ? const Padding(
                  padding: EdgeInsets.all(2),
                  child: CircularProgressIndicator(
                    strokeWidth: 2,
                    valueColor: AlwaysStoppedAnimation(Colors.white),
                  ),
                )
              : null,
        );
      }).toList(),
    );
  }

  String _getStepMessage(AppLocalizations l10n, LoadingStep step) {
    return l10n.loadingSteps(step.name);
  }
}

Typewriter Text Effect

Localized Typewriter Animation

class TypewriterText extends StatefulWidget {
  final String text;
  final TextStyle? style;
  final Duration charDuration;
  final VoidCallback? onComplete;
  final bool startOnBuild;

  const TypewriterText({
    super.key,
    required this.text,
    this.style,
    this.charDuration = const Duration(milliseconds: 50),
    this.onComplete,
    this.startOnBuild = true,
  });

  @override
  State<TypewriterText> createState() => TypewriterTextState();
}

class TypewriterTextState extends State<TypewriterText> {
  String _displayedText = '';
  int _currentIndex = 0;
  Timer? _timer;
  bool _isComplete = false;

  @override
  void initState() {
    super.initState();
    if (widget.startOnBuild) {
      _startAnimation();
    }
  }

  @override
  void didUpdateWidget(TypewriterText oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.text != oldWidget.text) {
      reset();
      if (widget.startOnBuild) {
        _startAnimation();
      }
    }
  }

  void _startAnimation() {
    _timer?.cancel();
    _timer = Timer.periodic(widget.charDuration, (timer) {
      if (_currentIndex < widget.text.length) {
        setState(() {
          _displayedText = widget.text.substring(0, _currentIndex + 1);
          _currentIndex++;
        });
      } else {
        timer.cancel();
        setState(() => _isComplete = true);
        widget.onComplete?.call();
      }
    });
  }

  void start() {
    if (!_isComplete) {
      _startAnimation();
    }
  }

  void reset() {
    _timer?.cancel();
    setState(() {
      _displayedText = '';
      _currentIndex = 0;
      _isComplete = false;
    });
  }

  void skipToEnd() {
    _timer?.cancel();
    setState(() {
      _displayedText = widget.text;
      _currentIndex = widget.text.length;
      _isComplete = true;
    });
    widget.onComplete?.call();
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Semantics(
      label: widget.text, // Full text for screen readers
      child: ExcludeSemantics(
        child: Text.rich(
          TextSpan(
            children: [
              TextSpan(text: _displayedText),
              if (!_isComplete)
                TextSpan(
                  text: '|',
                  style: widget.style?.copyWith(
                    color: (widget.style?.color ?? Colors.black).withOpacity(0.5),
                  ),
                ),
            ],
          ),
          style: widget.style,
        ),
      ),
    );
  }
}

// Usage with localization
class WelcomeScreen extends StatelessWidget {
  const WelcomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Center(
      child: TypewriterText(
        text: l10n.typewriterIntro,
        style: Theme.of(context).textTheme.headlineMedium,
        charDuration: const Duration(milliseconds: 60),
        onComplete: () {
          // Navigate or show next element
        },
      ),
    );
  }
}

RTL-Aware Typewriter

class RtlAwareTypewriterText extends StatefulWidget {
  final String text;
  final TextStyle? style;
  final Duration charDuration;

  const RtlAwareTypewriterText({
    super.key,
    required this.text,
    this.style,
    this.charDuration = const Duration(milliseconds: 50),
  });

  @override
  State<RtlAwareTypewriterText> createState() => _RtlAwareTypewriterTextState();
}

class _RtlAwareTypewriterTextState extends State<RtlAwareTypewriterText> {
  String _displayedText = '';
  int _currentIndex = 0;
  Timer? _timer;

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

  void _startAnimation() {
    final graphemeClusters = widget.text.characters.toList();

    _timer = Timer.periodic(widget.charDuration, (timer) {
      if (_currentIndex < graphemeClusters.length) {
        setState(() {
          _displayedText = graphemeClusters.sublist(0, _currentIndex + 1).join();
          _currentIndex++;
        });
      } else {
        timer.cancel();
      }
    });
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final isRtl = Directionality.of(context) == TextDirection.rtl;

    return Directionality(
      textDirection: isRtl ? TextDirection.rtl : TextDirection.ltr,
      child: Text(
        _displayedText,
        style: widget.style,
        textAlign: isRtl ? TextAlign.right : TextAlign.left,
      ),
    );
  }
}

Animated Counter

Localized Number Counter Animation

class AnimatedLocalizedCounter extends StatefulWidget {
  final int value;
  final Duration duration;
  final TextStyle? style;
  final String? prefix;
  final String? suffix;

  const AnimatedLocalizedCounter({
    super.key,
    required this.value,
    this.duration = const Duration(milliseconds: 1500),
    this.style,
    this.prefix,
    this.suffix,
  });

  @override
  State<AnimatedLocalizedCounter> createState() => _AnimatedLocalizedCounterState();
}

class _AnimatedLocalizedCounterState extends State<AnimatedLocalizedCounter>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  int _previousValue = 0;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: widget.duration,
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOutCubic,
    );
    _controller.forward();
  }

  @override
  void didUpdateWidget(AnimatedLocalizedCounter oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.value != oldWidget.value) {
      _previousValue = oldWidget.value;
      _controller.forward(from: 0);
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context);
    final formatter = NumberFormat.decimalPattern(locale.toString());

    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        final currentValue = (_previousValue +
                (_animation.value * (widget.value - _previousValue)))
            .round();

        final formattedValue = formatter.format(currentValue);
        final displayText = '${widget.prefix ?? ''}$formattedValue${widget.suffix ?? ''}';

        return Text(
          displayText,
          style: widget.style,
          semanticsLabel: formatter.format(widget.value),
        );
      },
    );
  }
}

// Usage
AnimatedLocalizedCounter(
  value: 12500,
  prefix: '\$',
  style: Theme.of(context).textTheme.displayLarge,
);

Currency Counter with Locale Formatting

class AnimatedCurrencyCounter extends StatefulWidget {
  final double value;
  final String currencyCode;
  final Duration duration;
  final TextStyle? style;

  const AnimatedCurrencyCounter({
    super.key,
    required this.value,
    required this.currencyCode,
    this.duration = const Duration(milliseconds: 1500),
    this.style,
  });

  @override
  State<AnimatedCurrencyCounter> createState() => _AnimatedCurrencyCounterState();
}

class _AnimatedCurrencyCounterState extends State<AnimatedCurrencyCounter>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  double _previousValue = 0;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: widget.duration,
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOutCubic,
    );
    _controller.forward();
  }

  @override
  void didUpdateWidget(AnimatedCurrencyCounter oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.value != oldWidget.value) {
      _previousValue = oldWidget.value;
      _controller.forward(from: 0);
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context);
    final formatter = NumberFormat.currency(
      locale: locale.toString(),
      symbol: widget.currencyCode,
    );

    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        final currentValue = _previousValue +
            (_animation.value * (widget.value - _previousValue));

        return Text(
          formatter.format(currentValue),
          style: widget.style,
        );
      },
    );
  }
}

Countdown Animation

Localized Countdown

class LocalizedCountdown extends StatefulWidget {
  final int seconds;
  final VoidCallback? onComplete;
  final TextStyle? style;

  const LocalizedCountdown({
    super.key,
    required this.seconds,
    this.onComplete,
    this.style,
  });

  @override
  State<LocalizedCountdown> createState() => _LocalizedCountdownState();
}

class _LocalizedCountdownState extends State<LocalizedCountdown>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;
  int _currentCount = 0;
  Timer? _timer;

  @override
  void initState() {
    super.initState();
    _currentCount = widget.seconds;
    _controller = AnimationController(
      duration: const Duration(milliseconds: 500),
      vsync: this,
    );
    _scaleAnimation = TweenSequence<double>([
      TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.5), weight: 30),
      TweenSequenceItem(tween: Tween(begin: 1.5, end: 1.0), weight: 70),
    ]).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOut,
    ));
    _startCountdown();
  }

  void _startCountdown() {
    _controller.forward(from: 0);
    _announceCount();

    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      if (_currentCount > 0) {
        setState(() => _currentCount--);
        _controller.forward(from: 0);
        _announceCount();
      } else {
        timer.cancel();
        widget.onComplete?.call();
      }
    });
  }

  void _announceCount() {
    final l10n = AppLocalizations.of(context)!;
    final announcement = _currentCount > 0
        ? _currentCount.toString()
        : l10n.countdownGo;

    SemanticsService.announce(
      announcement,
      Directionality.of(context),
    );
  }

  @override
  void dispose() {
    _timer?.cancel();
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);
    final formatter = NumberFormat.decimalPattern(locale.toString());

    String displayText;
    if (_currentCount > 0) {
      displayText = formatter.format(_currentCount);
    } else {
      displayText = l10n.countdownGo;
    }

    return ScaleTransition(
      scale: _scaleAnimation,
      child: Text(
        displayText,
        style: widget.style ?? Theme.of(context).textTheme.displayLarge,
      ),
    );
  }
}

Ready-Set-Go Countdown

class ReadySetGoCountdown extends StatefulWidget {
  final VoidCallback onComplete;
  final Duration phaseDuration;

  const ReadySetGoCountdown({
    super.key,
    required this.onComplete,
    this.phaseDuration = const Duration(milliseconds: 1000),
  });

  @override
  State<ReadySetGoCountdown> createState() => _ReadySetGoCountdownState();
}

enum CountdownPhase { ready, set, go }

class _ReadySetGoCountdownState extends State<ReadySetGoCountdown>
    with SingleTickerProviderStateMixin {
  CountdownPhase _phase = CountdownPhase.ready;
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: widget.phaseDuration,
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.elasticOut,
    );
    _startSequence();
  }

  void _startSequence() async {
    for (final phase in CountdownPhase.values) {
      if (!mounted) return;
      setState(() => _phase = phase);
      _controller.forward(from: 0);
      _announcePhase();

      await Future.delayed(widget.phaseDuration);
    }

    widget.onComplete();
  }

  void _announcePhase() {
    final l10n = AppLocalizations.of(context)!;
    final text = _getPhaseText(l10n, _phase);
    SemanticsService.announce(text, Directionality.of(context));
  }

  String _getPhaseText(AppLocalizations l10n, CountdownPhase phase) {
    switch (phase) {
      case CountdownPhase.ready:
        return l10n.countdownReady;
      case CountdownPhase.set:
        return l10n.countdownSet;
      case CountdownPhase.go:
        return l10n.countdownGo;
    }
  }

  Color _getPhaseColor() {
    switch (_phase) {
      case CountdownPhase.ready:
        return Colors.red;
      case CountdownPhase.set:
        return Colors.yellow;
      case CountdownPhase.go:
        return Colors.green;
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return ScaleTransition(
      scale: _animation,
      child: Text(
        _getPhaseText(l10n, _phase),
        style: TextStyle(
          fontSize: 72,
          fontWeight: FontWeight.bold,
          color: _getPhaseColor(),
        ),
      ),
    );
  }
}

Progress Animation with Messages

Animated Progress with Localized Status

class ProgressWithStatus extends StatefulWidget {
  final Stream<ProgressUpdate> progressStream;

  const ProgressWithStatus({
    super.key,
    required this.progressStream,
  });

  @override
  State<ProgressWithStatus> createState() => _ProgressWithStatusState();
}

class ProgressUpdate {
  final double progress;
  final String status;
  final int? current;
  final int? total;

  ProgressUpdate({
    required this.progress,
    required this.status,
    this.current,
    this.total,
  });
}

class _ProgressWithStatusState extends State<ProgressWithStatus>
    with SingleTickerProviderStateMixin {
  late AnimationController _progressController;
  double _currentProgress = 0;
  String _currentStatus = '';
  int? _current;
  int? _total;

  @override
  void initState() {
    super.initState();
    _progressController = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );

    widget.progressStream.listen((update) {
      setState(() {
        _currentProgress = update.progress;
        _currentStatus = update.status;
        _current = update.current;
        _total = update.total;
      });
    });
  }

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

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);
    final percentFormatter = NumberFormat.percentPattern(locale.toString());

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        TweenAnimationBuilder<double>(
          tween: Tween(begin: 0, end: _currentProgress),
          duration: const Duration(milliseconds: 300),
          builder: (context, value, child) {
            return Column(
              children: [
                LinearProgressIndicator(
                  value: value,
                  minHeight: 8,
                  borderRadius: BorderRadius.circular(4),
                ),
                const SizedBox(height: 8),
                Text(
                  percentFormatter.format(value),
                  style: Theme.of(context).textTheme.bodySmall,
                ),
              ],
            );
          },
        ),
        const SizedBox(height: 16),
        AnimatedSwitcher(
          duration: const Duration(milliseconds: 200),
          child: Text(
            _current != null && _total != null
                ? l10n.processingItems(_current!, _total!)
                : _currentStatus,
            key: ValueKey(_currentStatus),
            style: Theme.of(context).textTheme.bodyMedium,
          ),
        ),
      ],
    );
  }
}

Text Reveal Animation

Character-by-Character Reveal

class TextRevealAnimation extends StatefulWidget {
  final String text;
  final TextStyle? style;
  final Duration totalDuration;
  final Curve curve;

  const TextRevealAnimation({
    super.key,
    required this.text,
    this.style,
    this.totalDuration = const Duration(milliseconds: 1500),
    this.curve = Curves.easeInOut,
  });

  @override
  State<TextRevealAnimation> createState() => _TextRevealAnimationState();
}

class _TextRevealAnimationState extends State<TextRevealAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: widget.totalDuration,
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: widget.curve,
    );
    _controller.forward();
  }

  @override
  void didUpdateWidget(TextRevealAnimation oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.text != oldWidget.text) {
      _controller.forward(from: 0);
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final characters = widget.text.characters.toList();

    return Semantics(
      label: widget.text,
      child: ExcludeSemantics(
        child: AnimatedBuilder(
          animation: _animation,
          builder: (context, child) {
            return Wrap(
              children: List.generate(characters.length, (index) {
                final charProgress = (index / characters.length);
                final opacity = (_animation.value - charProgress)
                    .clamp(0.0, 1.0 / characters.length) *
                    characters.length;

                return Opacity(
                  opacity: opacity.clamp(0.0, 1.0),
                  child: Text(
                    characters[index],
                    style: widget.style,
                  ),
                );
              }),
            );
          },
        ),
      ),
    );
  }
}

Word-by-Word Animation

class WordByWordAnimation extends StatefulWidget {
  final String text;
  final TextStyle? style;
  final Duration wordDelay;

  const WordByWordAnimation({
    super.key,
    required this.text,
    this.style,
    this.wordDelay = const Duration(milliseconds: 200),
  });

  @override
  State<WordByWordAnimation> createState() => _WordByWordAnimationState();
}

class _WordByWordAnimationState extends State<WordByWordAnimation> {
  final List<String> _visibleWords = [];
  Timer? _timer;
  late List<String> _words;

  @override
  void initState() {
    super.initState();
    _words = widget.text.split(' ');
    _startAnimation();
  }

  void _startAnimation() {
    int index = 0;
    _timer = Timer.periodic(widget.wordDelay, (timer) {
      if (index < _words.length) {
        setState(() {
          _visibleWords.add(_words[index]);
        });
        index++;
      } else {
        timer.cancel();
      }
    });
  }

  @override
  void didUpdateWidget(WordByWordAnimation oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.text != oldWidget.text) {
      _timer?.cancel();
      _visibleWords.clear();
      _words = widget.text.split(' ');
      _startAnimation();
    }
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Semantics(
      label: widget.text,
      child: ExcludeSemantics(
        child: Wrap(
          children: _visibleWords.map((word) {
            return TweenAnimationBuilder<double>(
              tween: Tween(begin: 0, end: 1),
              duration: const Duration(milliseconds: 300),
              builder: (context, value, child) {
                return Opacity(
                  opacity: value,
                  child: Transform.translate(
                    offset: Offset(0, 10 * (1 - value)),
                    child: Text(
                      '$word ',
                      style: widget.style,
                    ),
                  ),
                );
              },
            );
          }).toList(),
        ),
      ),
    );
  }
}

Shimmer Loading Effect

Localized Shimmer Placeholder

class LocalizedShimmerPlaceholder extends StatefulWidget {
  final String placeholderText;
  final TextStyle? style;
  final Duration shimmerDuration;

  const LocalizedShimmerPlaceholder({
    super.key,
    required this.placeholderText,
    this.style,
    this.shimmerDuration = const Duration(milliseconds: 1500),
  });

  @override
  State<LocalizedShimmerPlaceholder> createState() =>
      _LocalizedShimmerPlaceholderState();
}

class _LocalizedShimmerPlaceholderState
    extends State<LocalizedShimmerPlaceholder>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: widget.shimmerDuration,
      vsync: this,
    )..repeat();
  }

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

  @override
  Widget build(BuildContext context) {
    final isRtl = Directionality.of(context) == TextDirection.rtl;

    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return ShaderMask(
          shaderCallback: (bounds) {
            final shimmerPosition = _controller.value * 2 - 1;
            final gradientStops = isRtl
                ? [1 - shimmerPosition - 0.3, 1 - shimmerPosition, 1 - shimmerPosition + 0.3]
                    .map((v) => v.clamp(0.0, 1.0))
                    .toList()
                : [shimmerPosition - 0.3, shimmerPosition, shimmerPosition + 0.3]
                    .map((v) => v.clamp(0.0, 1.0))
                    .toList();

            return LinearGradient(
              begin: isRtl ? Alignment.centerRight : Alignment.centerLeft,
              end: isRtl ? Alignment.centerLeft : Alignment.centerRight,
              colors: const [
                Colors.grey,
                Colors.white,
                Colors.grey,
              ],
              stops: gradientStops.cast<double>(),
            ).createShader(bounds);
          },
          blendMode: BlendMode.srcIn,
          child: Text(
            widget.placeholderText,
            style: widget.style ?? const TextStyle(fontSize: 16),
          ),
        );
      },
    );
  }
}

Testing Animation Localization

Widget Tests

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

void main() {
  group('TypewriterText', () {
    testWidgets('displays localized text progressively', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('ja'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Builder(
            builder: (context) {
              final l10n = AppLocalizations.of(context)!;
              return Scaffold(
                body: TypewriterText(text: l10n.typewriterIntro),
              );
            },
          ),
        ),
      );

      // Initially empty
      expect(find.text(''), findsNothing);

      // After some time, partial text
      await tester.pump(const Duration(milliseconds: 200));
      // Text should be appearing

      // After full animation, complete text
      await tester.pumpAndSettle();
      expect(
        find.textContaining('あなた'),
        findsOneWidget,
      );
    });

    testWidgets('handles RTL text correctly', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('ar'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Builder(
            builder: (context) {
              final l10n = AppLocalizations.of(context)!;
              return Scaffold(
                body: RtlAwareTypewriterText(text: l10n.typewriterIntro),
              );
            },
          ),
        ),
      );

      await tester.pumpAndSettle();

      final text = tester.widget<Text>(find.byType(Text));
      expect(text.textAlign, TextAlign.right);
    });
  });

  group('AnimatedLocalizedCounter', () {
    testWidgets('formats numbers according to locale', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('de'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: const Scaffold(
            body: AnimatedLocalizedCounter(value: 12345),
          ),
        ),
      );

      await tester.pumpAndSettle();

      // German uses periods for thousands
      expect(find.textContaining('12.345'), findsOneWidget);
    });
  });

  group('LocalizedCountdown', () {
    testWidgets('uses localized number format', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('ar'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Scaffold(
            body: LocalizedCountdown(
              seconds: 3,
              onComplete: () {},
            ),
          ),
        ),
      );

      // Countdown should use Arabic numerals or localized format
      await tester.pump();
      // Verify countdown is displayed
    });
  });
}

Golden Tests for Animations

import 'package:golden_toolkit/golden_toolkit.dart';

void main() {
  group('Animation Golden Tests', () {
    testGoldens('loading animation states', (tester) async {
      await loadAppFonts();

      final builder = DeviceBuilder()
        ..overrideDevicesForAllScenarios(devices: [Device.phone])
        ..addScenario(
          name: 'Loading English',
          widget: Builder(
            builder: (context) => MaterialApp(
              locale: const Locale('en'),
              localizationsDelegates: AppLocalizations.localizationsDelegates,
              supportedLocales: AppLocalizations.supportedLocales,
              home: const Scaffold(
                body: RotatingTipsLoader(
                  isLoading: true,
                  child: SizedBox(),
                ),
              ),
            ),
          ),
        )
        ..addScenario(
          name: 'Loading Arabic',
          widget: Builder(
            builder: (context) => MaterialApp(
              locale: const Locale('ar'),
              localizationsDelegates: AppLocalizations.localizationsDelegates,
              supportedLocales: AppLocalizations.supportedLocales,
              home: const Scaffold(
                body: RotatingTipsLoader(
                  isLoading: true,
                  child: SizedBox(),
                ),
              ),
            ),
          ),
        );

      await tester.pumpDeviceBuilder(builder);
      await screenMatchesGolden(tester, 'loading_animation_locales');
    });
  });
}

Best Practices

1. Adaptive Animation Timing

class AdaptiveAnimationDuration {
  static Duration calculateForText(String text, {double msPerChar = 50}) {
    // Adjust duration based on text length
    final baseMs = text.length * msPerChar;
    // Minimum 500ms, maximum 5000ms
    return Duration(milliseconds: baseMs.clamp(500, 5000).toInt());
  }

  static Duration calculateForLocale(Locale locale, Duration baseDuration) {
    // Some languages may need more reading time
    final multipliers = {
      'ja': 1.2, // Japanese needs more time
      'zh': 1.2, // Chinese needs more time
      'ar': 1.1, // Arabic needs slightly more time
      'en': 1.0,
    };

    final multiplier = multipliers[locale.languageCode] ?? 1.0;
    return Duration(
      milliseconds: (baseDuration.inMilliseconds * multiplier).toInt(),
    );
  }
}

2. Accessibility-First Animations

class AccessibleAnimatedText extends StatelessWidget {
  final String text;
  final Widget Function(BuildContext, String) animatedBuilder;

  const AccessibleAnimatedText({
    super.key,
    required this.text,
    required this.animatedBuilder,
  });

  @override
  Widget build(BuildContext context) {
    final reduceMotion = MediaQuery.of(context).disableAnimations;

    if (reduceMotion) {
      // Skip animation for users who prefer reduced motion
      return Text(text);
    }

    return Semantics(
      label: text,
      child: ExcludeSemantics(
        child: animatedBuilder(context, text),
      ),
    );
  }
}

Conclusion

Localizing animated text and loading messages enhances the user experience globally. Key takeaways:

  1. Adjust timing for text length - Longer translations need more display time
  2. Respect RTL directionality - Animations should flow correctly
  3. Provide accessibility labels - Screen readers need full text immediately
  4. Use locale-aware number formatting - Counters and percentages vary
  5. Test across locales - Animation timing may need locale-specific adjustments
  6. Honor reduced motion preferences - Provide static alternatives

By implementing these patterns, your Flutter animations will feel natural and polished for users worldwide.

Additional Resources