Skip to content

Commit 84bfe64

Browse files
authored
Added an AnimationController API doc example (flutter#137975)
This example shows how to use `AnimationController` and `SlideTransition` to create an animated digit like you might find on a digital clock. New digit values slide into place from below, as the old value slides upwards and out of view. Taps that occur while the controller is already animating cause the controller's `AnimationController.duration` to be reduced so that the visuals don't fall behind. You can try the example here: https://dartpad.dev/?id=9553c20fe0fdb0c5447c1293e02400eb
1 parent d550ba5 commit 84bfe64

File tree

3 files changed

+262
-0
lines changed

3 files changed

+262
-0
lines changed
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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(context).textTheme.displayLarge!.copyWith(
23+
fontWeight: FontWeight.w500,
24+
);
25+
26+
final Iterable<Widget> placeholderDigits = <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map<Widget>(
27+
(int n) {
28+
return Text('$n', style: textStyle);
29+
},
30+
);
31+
32+
return Opacity(
33+
opacity: 0,
34+
child: Stack(children: placeholderDigits.toList()),
35+
);
36+
}
37+
}
38+
39+
// Displays a single digit [value].
40+
//
41+
// When the value changes the old value slides upwards and out of sight
42+
// at the same as the new value slides into view.
43+
class AnimatedDigit extends StatefulWidget {
44+
const AnimatedDigit({ super.key, required this.value });
45+
46+
final int value;
47+
48+
@override
49+
State<AnimatedDigit> createState() => _AnimatedDigitState();
50+
}
51+
52+
class _AnimatedDigitState extends State<AnimatedDigit> with SingleTickerProviderStateMixin {
53+
static const Duration defaultDuration = Duration(milliseconds: 300);
54+
55+
late final AnimationController controller;
56+
late int incomingValue;
57+
late int outgoingValue;
58+
List<int> pendingValues = <int>[]; // widget.value updates that occurred while the animation is underway
59+
Duration duration = defaultDuration;
60+
61+
@override
62+
void initState() {
63+
super.initState();
64+
controller = AnimationController(
65+
duration: duration,
66+
vsync: this,
67+
);
68+
controller.addStatusListener(handleAnimationCompleted);
69+
incomingValue = widget.value;
70+
outgoingValue = widget.value;
71+
}
72+
73+
@override
74+
void dispose() {
75+
controller.dispose();
76+
super.dispose();
77+
}
78+
79+
void handleAnimationCompleted(AnimationStatus status) {
80+
if (status == AnimationStatus.completed) {
81+
if (pendingValues.isNotEmpty) {
82+
// Display the next pending value. The duration was scaled down
83+
// in didUpdateWidget by the total number of pending values so
84+
// that all of the pending changes are shown within
85+
// defaultDuration of the last one (the past pending change).
86+
controller.duration = duration;
87+
animateValueUpdate(incomingValue, pendingValues.removeAt(0));
88+
} else {
89+
controller.duration = defaultDuration;
90+
}
91+
}
92+
}
93+
94+
void animateValueUpdate(int outgoing, int incoming) {
95+
setState(() {
96+
outgoingValue = outgoing;
97+
incomingValue = incoming;
98+
controller.forward(from: 0);
99+
});
100+
}
101+
102+
// Rebuilding the widget with a new value causes the animations to run.
103+
// If the widget is updated while the value is being changed the new
104+
// value is added to pendingValues and is taken care of when the current
105+
// animation is complete (see handleAnimationCompleted()).
106+
@override
107+
void didUpdateWidget(AnimatedDigit oldWidget) {
108+
super.didUpdateWidget(oldWidget);
109+
if (widget.value != oldWidget.value) {
110+
if (controller.isAnimating) {
111+
// We're in the middle of animating outgoingValue out and
112+
// incomingValue in. Shorten the duration of the current
113+
// animation as well as the duration for animations that
114+
// will show the pending values.
115+
pendingValues.add(widget.value);
116+
final double percentRemaining = 1 - controller.value;
117+
duration = defaultDuration * (1 / (percentRemaining + pendingValues.length));
118+
controller.animateTo(1.0, duration: duration * percentRemaining);
119+
} else {
120+
animateValueUpdate(incomingValue, widget.value);
121+
}
122+
}
123+
}
124+
125+
// When the controller runs forward both SlideTransitions' children
126+
// animate upwards. This takes the outgoingValue out of sight and the
127+
// incoming value into view. See animateValueUpdate().
128+
@override
129+
Widget build(BuildContext context) {
130+
final TextStyle textStyle = Theme.of(context).textTheme.displayLarge!;
131+
return ClipRect(
132+
child: Stack(
133+
children: <Widget>[
134+
const _PlaceholderDigit(),
135+
SlideTransition(
136+
position: controller
137+
.drive(
138+
Tween<Offset>(
139+
begin: Offset.zero,
140+
end: const Offset(0, -1), // Out of view above the top.
141+
),
142+
),
143+
child: Text(
144+
key: ValueKey<int>(outgoingValue),
145+
'$outgoingValue',
146+
style: textStyle,
147+
),
148+
),
149+
SlideTransition(
150+
position: controller
151+
.drive(
152+
Tween<Offset>(
153+
begin: const Offset(0, 1), // Out of view below the bottom.
154+
end: Offset.zero,
155+
),
156+
),
157+
child: Text(
158+
key: ValueKey<int>(incomingValue),
159+
'$incomingValue',
160+
style: textStyle,
161+
),
162+
),
163+
],
164+
),
165+
);
166+
}
167+
}
168+
169+
class AnimatedDigitApp extends StatelessWidget {
170+
const AnimatedDigitApp({ super.key });
171+
172+
@override
173+
Widget build(BuildContext context) {
174+
return const MaterialApp(
175+
title: 'AnimatedDigit',
176+
home: AnimatedDigitHome(),
177+
);
178+
}
179+
}
180+
181+
class AnimatedDigitHome extends StatefulWidget {
182+
const AnimatedDigitHome({ super.key });
183+
184+
@override
185+
State<AnimatedDigitHome> createState() => _AnimatedDigitHomeState();
186+
}
187+
188+
class _AnimatedDigitHomeState extends State<AnimatedDigitHome> {
189+
int value = 0;
190+
191+
@override
192+
Widget build(BuildContext context) {
193+
return Scaffold(
194+
body: Center(
195+
child: AnimatedDigit(value: value % 10),
196+
),
197+
floatingActionButton: FloatingActionButton(
198+
onPressed: () {
199+
setState(() { value += 1; });
200+
},
201+
tooltip: 'Increment Digit',
202+
child: const Icon(Icons.add),
203+
),
204+
);
205+
}
206+
}
207+
208+
void main() {
209+
runApp(const AnimatedDigitApp());
210+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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+
import 'package:flutter_api_samples/animation/animation_controller/animated_digit.0.dart'
7+
as example;
8+
import 'package:flutter_test/flutter_test.dart';
9+
10+
void main() {
11+
testWidgets('animated digit example', (WidgetTester tester) async {
12+
await tester.pumpWidget(
13+
const example.AnimatedDigitApp(),
14+
);
15+
16+
Finder findVisibleDigit(int digit) {
17+
return find.descendant(
18+
of: find.byType(SlideTransition).last,
19+
matching: find.text('$digit'),
20+
);
21+
}
22+
23+
expect(findVisibleDigit(0), findsOneWidget);
24+
25+
await tester.tap(find.byType(FloatingActionButton));
26+
await tester.pumpAndSettle();
27+
expect(findVisibleDigit(1), findsOneWidget);
28+
29+
await tester.tap(find.byType(FloatingActionButton));
30+
await tester.pumpAndSettle();
31+
expect(findVisibleDigit(2), findsOneWidget);
32+
33+
await tester.tap(find.byType(FloatingActionButton));
34+
await tester.pump(const Duration(milliseconds: 100)); // Animation duration is 300ms
35+
await tester.tap(find.byType(FloatingActionButton));
36+
await tester.pumpAndSettle();
37+
expect(findVisibleDigit(4), findsOneWidget);
38+
});
39+
}

packages/flutter/lib/src/animation/animation_controller.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,19 @@ enum AnimationBehavior {
208208
/// controllers are created in [State.initState] and disposed in
209209
/// [State.dispose], as described in the previous section.)
210210
///
211+
/// {@tool dartpad}
212+
/// This example shows how to use [AnimationController] and
213+
/// [SlideTransition] to create an animated digit like you might find
214+
/// on an old pinball machine our your car's odometer. New digit
215+
/// values slide into place from below, as the old value slides
216+
/// upwards and out of view. Taps that occur while the controller is
217+
/// already animating cause the controller's
218+
/// [AnimationController.duration] to be reduced so that the visuals
219+
/// don't fall behind.
220+
///
221+
/// ** See code in examples/api/lib/animation/animation_controller/animated_digit.0.dart **
222+
/// {@end-tool}
223+
211224
/// See also:
212225
///
213226
/// * [Tween], the base class for converting an [AnimationController] to a

0 commit comments

Comments
 (0)