| 1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | import 'package:flutter/material.dart'; |
| 6 | |
| 7 | /// An example of [AnimationController] and [SlideTransition]. |
| 8 | |
| 9 | // Occupies the same width as the widest single digit used by AnimatedDigit. |
| 10 | // |
| 11 | // By stacking this widget behind AnimatedDigit's visible digit, we |
| 12 | // ensure that AnimatedWidget's width will not change when its value |
| 13 | // changes. Typically digits like '8' or '9' are wider than '1'. If |
| 14 | // an app arranges several AnimatedDigits in a centered Row, we don't |
| 15 | // want the Row to wiggle when the digits change because the overall |
| 16 | // width of the Row changes. |
| 17 | class _PlaceholderDigit extends StatelessWidget { |
| 18 | const _PlaceholderDigit(); |
| 19 | |
| 20 | @override |
| 21 | Widget build(BuildContext context) { |
| 22 | final TextStyle textStyle = Theme.of( |
| 23 | context, |
| 24 | ).textTheme.displayLarge!.copyWith(fontWeight: FontWeight.w500); |
| 25 | |
| 26 | final Iterable<Widget> placeholderDigits = <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map<Widget>(( |
| 27 | int n, |
| 28 | ) { |
| 29 | return Text(' $n' , style: textStyle); |
| 30 | }); |
| 31 | |
| 32 | return Opacity(opacity: 0, child: Stack(children: placeholderDigits.toList())); |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // Displays a single digit [value]. |
| 37 | // |
| 38 | // When the value changes the old value slides upwards and out of sight |
| 39 | // at the same as the new value slides into view. |
| 40 | class AnimatedDigit extends StatefulWidget { |
| 41 | const AnimatedDigit({super.key, required this.value}); |
| 42 | |
| 43 | final int value; |
| 44 | |
| 45 | @override |
| 46 | State<AnimatedDigit> createState() => _AnimatedDigitState(); |
| 47 | } |
| 48 | |
| 49 | class _AnimatedDigitState extends State<AnimatedDigit> with SingleTickerProviderStateMixin { |
| 50 | static const Duration defaultDuration = Duration(milliseconds: 300); |
| 51 | |
| 52 | late final AnimationController controller; |
| 53 | late int incomingValue; |
| 54 | late int outgoingValue; |
| 55 | List<int> pendingValues = |
| 56 | <int>[]; // widget.value updates that occurred while the animation is underway |
| 57 | Duration duration = defaultDuration; |
| 58 | |
| 59 | @override |
| 60 | void initState() { |
| 61 | super.initState(); |
| 62 | controller = AnimationController(duration: duration, vsync: this); |
| 63 | controller.addStatusListener(handleAnimationCompleted); |
| 64 | incomingValue = widget.value; |
| 65 | outgoingValue = widget.value; |
| 66 | } |
| 67 | |
| 68 | @override |
| 69 | void dispose() { |
| 70 | controller.dispose(); |
| 71 | super.dispose(); |
| 72 | } |
| 73 | |
| 74 | void handleAnimationCompleted(AnimationStatus status) { |
| 75 | if (status.isCompleted) { |
| 76 | if (pendingValues.isNotEmpty) { |
| 77 | // Display the next pending value. The duration was scaled down |
| 78 | // in didUpdateWidget by the total number of pending values so |
| 79 | // that all of the pending changes are shown within |
| 80 | // defaultDuration of the last one (the past pending change). |
| 81 | controller.duration = duration; |
| 82 | animateValueUpdate(incomingValue, pendingValues.removeAt(0)); |
| 83 | } else { |
| 84 | controller.duration = defaultDuration; |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | void animateValueUpdate(int outgoing, int incoming) { |
| 90 | setState(() { |
| 91 | outgoingValue = outgoing; |
| 92 | incomingValue = incoming; |
| 93 | controller.forward(from: 0); |
| 94 | }); |
| 95 | } |
| 96 | |
| 97 | // Rebuilding the widget with a new value causes the animations to run. |
| 98 | // If the widget is updated while the value is being changed the new |
| 99 | // value is added to pendingValues and is taken care of when the current |
| 100 | // animation is complete (see handleAnimationCompleted()). |
| 101 | @override |
| 102 | void didUpdateWidget(AnimatedDigit oldWidget) { |
| 103 | super.didUpdateWidget(oldWidget); |
| 104 | if (widget.value != oldWidget.value) { |
| 105 | if (controller.isAnimating) { |
| 106 | // We're in the middle of animating outgoingValue out and |
| 107 | // incomingValue in. Shorten the duration of the current |
| 108 | // animation as well as the duration for animations that |
| 109 | // will show the pending values. |
| 110 | pendingValues.add(widget.value); |
| 111 | final double percentRemaining = 1 - controller.value; |
| 112 | duration = defaultDuration * (1 / (percentRemaining + pendingValues.length)); |
| 113 | controller.animateTo(1.0, duration: duration * percentRemaining); |
| 114 | } else { |
| 115 | animateValueUpdate(incomingValue, widget.value); |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | // When the controller runs forward both SlideTransitions' children |
| 121 | // animate upwards. This takes the outgoingValue out of sight and the |
| 122 | // incoming value into view. See animateValueUpdate(). |
| 123 | @override |
| 124 | Widget build(BuildContext context) { |
| 125 | final TextStyle textStyle = Theme.of(context).textTheme.displayLarge!; |
| 126 | return ClipRect( |
| 127 | child: Stack( |
| 128 | children: <Widget>[ |
| 129 | const _PlaceholderDigit(), |
| 130 | SlideTransition( |
| 131 | position: controller.drive( |
| 132 | Tween<Offset>( |
| 133 | begin: Offset.zero, |
| 134 | end: const Offset(0, -1), // Out of view above the top. |
| 135 | ), |
| 136 | ), |
| 137 | child: Text(key: ValueKey<int>(outgoingValue), ' $outgoingValue' , style: textStyle), |
| 138 | ), |
| 139 | SlideTransition( |
| 140 | position: controller.drive( |
| 141 | Tween<Offset>( |
| 142 | begin: const Offset(0, 1), // Out of view below the bottom. |
| 143 | end: Offset.zero, |
| 144 | ), |
| 145 | ), |
| 146 | child: Text(key: ValueKey<int>(incomingValue), ' $incomingValue' , style: textStyle), |
| 147 | ), |
| 148 | ], |
| 149 | ), |
| 150 | ); |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | class AnimatedDigitApp extends StatelessWidget { |
| 155 | const AnimatedDigitApp({super.key}); |
| 156 | |
| 157 | @override |
| 158 | Widget build(BuildContext context) { |
| 159 | return const MaterialApp(title: 'AnimatedDigit' , home: AnimatedDigitHome()); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | class AnimatedDigitHome extends StatefulWidget { |
| 164 | const AnimatedDigitHome({super.key}); |
| 165 | |
| 166 | @override |
| 167 | State<AnimatedDigitHome> createState() => _AnimatedDigitHomeState(); |
| 168 | } |
| 169 | |
| 170 | class _AnimatedDigitHomeState extends State<AnimatedDigitHome> { |
| 171 | int value = 0; |
| 172 | |
| 173 | @override |
| 174 | Widget build(BuildContext context) { |
| 175 | return Scaffold( |
| 176 | body: Center(child: AnimatedDigit(value: value % 10)), |
| 177 | floatingActionButton: FloatingActionButton( |
| 178 | onPressed: () { |
| 179 | setState(() { |
| 180 | value += 1; |
| 181 | }); |
| 182 | }, |
| 183 | tooltip: 'Increment Digit' , |
| 184 | child: const Icon(Icons.add), |
| 185 | ), |
| 186 | ); |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | void main() { |
| 191 | runApp(const AnimatedDigitApp()); |
| 192 | } |
| 193 | |