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
5import '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.
17class _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.
40class 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
49class _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
154class 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
163class AnimatedDigitHome extends StatefulWidget {
164 const AnimatedDigitHome({super.key});
165
166 @override
167 State<AnimatedDigitHome> createState() => _AnimatedDigitHomeState();
168}
169
170class _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
190void main() {
191 runApp(const AnimatedDigitApp());
192}
193