blob: b2d1e1d4833165325ce0ce1c4c3e1da5e2e76043 [file] [log] [blame]
// 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());
}