// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/material.dart'; /// An example of [AnimationController] and [SlideTransition]. // Occupies the same width as the widest single digit used by AnimatedDigit. // // By stacking this widget behind AnimatedDigit's visible digit, we // ensure that AnimatedWidget's width will not change when its value // changes. Typically digits like '8' or '9' are wider than '1'. If // an app arranges several AnimatedDigits in a centered Row, we don't // want the Row to wiggle when the digits change because the overall // width of the Row changes. class _PlaceholderDigit extends StatelessWidget { const _PlaceholderDigit(); @override Widget build(BuildContext context) { final TextStyle textStyle = Theme.of(context).textTheme.displayLarge!.copyWith( fontWeight: FontWeight.w500, ); final Iterable<Widget> placeholderDigits = <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map<Widget>( (int n) { return Text('$n', style: textStyle); }, ); return Opacity( opacity: 0, child: Stack(children: placeholderDigits.toList()), ); } } // Displays a single digit [value]. // // When the value changes the old value slides upwards and out of sight // at the same as the new value slides into view. class AnimatedDigit extends StatefulWidget { const AnimatedDigit({ super.key, required this.value }); final int value; @override State<AnimatedDigit> createState() => _AnimatedDigitState(); } class _AnimatedDigitState extends State<AnimatedDigit> with SingleTickerProviderStateMixin { static const Duration defaultDuration = Duration(milliseconds: 300); late final AnimationController controller; late int incomingValue; late int outgoingValue; List<int> pendingValues = <int>[]; // widget.value updates that occurred while the animation is underway Duration duration = defaultDuration; @override void initState() { super.initState(); controller = AnimationController( duration: duration, vsync: this, ); controller.addStatusListener(handleAnimationCompleted); incomingValue = widget.value; outgoingValue = widget.value; } @override void dispose() { controller.dispose(); super.dispose(); } void handleAnimationCompleted(AnimationStatus status) { if (status == AnimationStatus.completed) { if (pendingValues.isNotEmpty) { // Display the next pending value. The duration was scaled down // in didUpdateWidget by the total number of pending values so // that all of the pending changes are shown within // defaultDuration of the last one (the past pending change). controller.duration = duration; animateValueUpdate(incomingValue, pendingValues.removeAt(0)); } else { controller.duration = defaultDuration; } } } void animateValueUpdate(int outgoing, int incoming) { setState(() { outgoingValue = outgoing; incomingValue = incoming; controller.forward(from: 0); }); } // Rebuilding the widget with a new value causes the animations to run. // If the widget is updated while the value is being changed the new // value is added to pendingValues and is taken care of when the current // animation is complete (see handleAnimationCompleted()). @override void didUpdateWidget(AnimatedDigit oldWidget) { super.didUpdateWidget(oldWidget); if (widget.value != oldWidget.value) { if (controller.isAnimating) { // We're in the middle of animating outgoingValue out and // incomingValue in. Shorten the duration of the current // animation as well as the duration for animations that // will show the pending values. pendingValues.add(widget.value); final double percentRemaining = 1 - controller.value; duration = defaultDuration * (1 / (percentRemaining + pendingValues.length)); controller.animateTo(1.0, duration: duration * percentRemaining); } else { animateValueUpdate(incomingValue, widget.value); } } } // When the controller runs forward both SlideTransitions' children // animate upwards. This takes the outgoingValue out of sight and the // incoming value into view. See animateValueUpdate(). @override Widget build(BuildContext context) { final TextStyle textStyle = Theme.of(context).textTheme.displayLarge!; return ClipRect( child: Stack( children: <Widget>[ const _PlaceholderDigit(), SlideTransition( position: controller .drive( Tween<Offset>( begin: Offset.zero, end: const Offset(0, -1), // Out of view above the top. ), ), child: Text( key: ValueKey<int>(outgoingValue), '$outgoingValue', style: textStyle, ), ), SlideTransition( position: controller .drive( Tween<Offset>( begin: const Offset(0, 1), // Out of view below the bottom. end: Offset.zero, ), ), child: Text( key: ValueKey<int>(incomingValue), '$incomingValue', style: textStyle, ), ), ], ), ); } } class AnimatedDigitApp extends StatelessWidget { const AnimatedDigitApp({ super.key }); @override Widget build(BuildContext context) { return const MaterialApp( title: 'AnimatedDigit', home: AnimatedDigitHome(), ); } } class AnimatedDigitHome extends StatefulWidget { const AnimatedDigitHome({ super.key }); @override State<AnimatedDigitHome> createState() => _AnimatedDigitHomeState(); } class _AnimatedDigitHomeState extends State<AnimatedDigitHome> { int value = 0; @override Widget build(BuildContext context) { return Scaffold( body: Center( child: AnimatedDigit(value: value % 10), ), floatingActionButton: FloatingActionButton( onPressed: () { setState(() { value += 1; }); }, tooltip: 'Increment Digit', child: const Icon(Icons.add), ), ); } } void main() { runApp(const AnimatedDigitApp()); }