Add a RepeatingAnimationBuilder API (#174014)

Implements / Close https://github.com/flutter/flutter/issues/174011

Probably there are 400 LOC of comments + tests, so this removes more
code than adds.

<img width="1509" height="649" alt="diff"
src="https://github.com/user-attachments/assets/cd961ddc-3b79-4ffd-8e9a-a806d5743f17"
/>
This commit is contained in:
Bernardo Ferrari 2025-11-15 02:17:26 -03:00 committed by GitHub
parent ab8d05efee
commit 1814874c2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 783 additions and 135 deletions

View File

@ -17,34 +17,9 @@ class StrokeAlignApp extends StatelessWidget {
}
}
class StrokeAlignExample extends StatefulWidget {
class StrokeAlignExample extends StatelessWidget {
const StrokeAlignExample({super.key});
@override
State<StrokeAlignExample> createState() => _StrokeAlignExampleState();
}
class _StrokeAlignExampleState extends State<StrokeAlignExample> with TickerProviderStateMixin {
late final AnimationController animation;
@override
void initState() {
super.initState();
animation = AnimationController(vsync: this, duration: const Duration(seconds: 1));
animation.repeat(reverse: true);
animation.addListener(_markDirty);
}
@override
void dispose() {
animation.dispose();
super.dispose();
}
void _markDirty() {
setState(() {});
}
static const double borderWidth = 10;
static const double cornerRadius = 10;
static const Color borderColor = Color(0x8000b4fc);
@ -53,127 +28,134 @@ class _StrokeAlignExampleState extends State<StrokeAlignExample> with TickerProv
Widget build(BuildContext context) {
return Material(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
BorderedBox(
shape: StadiumBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: (animation.value * 2) - 1,
),
),
),
Row(
child: RepeatingAnimationBuilder<double>(
animatable: Tween<double>(begin: -1.0, end: 1.0),
duration: const Duration(seconds: 1),
repeatMode: RepeatMode.reverse,
builder: (BuildContext context, double strokeAlign, Widget? child) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
BorderedBox(
shape: CircleBorder(
shape: StadiumBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: (animation.value * 2) - 1,
strokeAlign: strokeAlign,
),
),
),
BorderedBox(
shape: OvalBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: (animation.value * 2) - 1,
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
BorderedBox(
shape: CircleBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: strokeAlign,
),
),
),
),
BorderedBox(
shape: OvalBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: strokeAlign,
),
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
BorderedBox(
shape: BeveledRectangleBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: strokeAlign,
),
),
),
BorderedBox(
shape: BeveledRectangleBorder(
borderRadius: BorderRadius.circular(cornerRadius),
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: strokeAlign,
),
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
BorderedBox(
shape: RoundedRectangleBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: strokeAlign,
),
),
),
BorderedBox(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(cornerRadius),
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: strokeAlign,
),
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
BorderedBox(
shape: StarBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: strokeAlign,
),
),
),
BorderedBox(
shape: StarBorder(
pointRounding: 1,
innerRadiusRatio: 0.5,
points: 8,
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: strokeAlign,
),
),
),
BorderedBox(
shape: StarBorder.polygon(
sides: 6,
pointRounding: 0.5,
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: strokeAlign,
),
),
),
],
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
BorderedBox(
shape: BeveledRectangleBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: (animation.value * 2) - 1,
),
),
),
BorderedBox(
shape: BeveledRectangleBorder(
borderRadius: BorderRadius.circular(cornerRadius),
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: (animation.value * 2) - 1,
),
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
BorderedBox(
shape: RoundedRectangleBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: (animation.value * 2) - 1,
),
),
),
BorderedBox(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(cornerRadius),
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: (animation.value * 2) - 1,
),
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
BorderedBox(
shape: StarBorder(
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: (animation.value * 2) - 1,
),
),
),
BorderedBox(
shape: StarBorder(
pointRounding: 1,
innerRadiusRatio: 0.5,
points: 8,
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: (animation.value * 2) - 1,
),
),
),
BorderedBox(
shape: StarBorder.polygon(
sides: 6,
pointRounding: 0.5,
side: BorderSide(
color: borderColor,
width: borderWidth,
strokeAlign: (animation.value * 2) - 1,
),
),
),
],
),
],
);
},
),
),
);

View File

@ -0,0 +1,222 @@
// 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 'dart:math' as math;
import 'package:flutter/material.dart';
/// Flutter code sample for [RepeatingAnimationBuilder].
void main() {
runApp(const RepeatingAnimationBuilderExampleApp());
}
class RepeatingAnimationBuilderExampleApp extends StatelessWidget {
const RepeatingAnimationBuilderExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.from(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark),
useMaterial3: true,
),
home: const RepeatingAnimationBuilderExample(),
);
}
}
class RepeatingAnimationBuilderExample extends StatefulWidget {
const RepeatingAnimationBuilderExample({super.key});
@override
State<RepeatingAnimationBuilderExample> createState() => _RepeatingAnimationBuilderExampleState();
}
class _RepeatingAnimationBuilderExampleState extends State<RepeatingAnimationBuilderExample> {
bool _isPaused = false;
bool _isReversing = false;
@override
Widget build(BuildContext context) {
final ColorScheme colors = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('RepeatingAnimationBuilder'),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: RepeatingAnimationBuilder<double>(
animatable: Tween<double>(begin: 0.0, end: 1.0),
duration: const Duration(seconds: 4),
paused: _isPaused,
repeatMode: _isReversing ? RepeatMode.reverse : RepeatMode.restart,
curve: Curves.easeInOut,
builder: (BuildContext context, double value, Widget? child) {
return Stack(
children: <Widget>[
Center(
child: Transform.rotate(angle: value * 0.5 * math.pi, child: child),
),
_buildControls(colors, value),
],
);
},
child: _buildFlowerGem(colors),
),
);
}
/// Builds the layered visual of the flower and the gem.
Widget _buildFlowerGem(ColorScheme colors) {
return SizedBox(
width: 250,
height: 250,
child: Stack(
alignment: Alignment.center,
children: <Widget>[
Container(
decoration: ShapeDecoration(
color: colors.primaryContainer.withOpacity(0.5),
shape: StarBorder(
points: 8,
innerRadiusRatio: 0.7,
pointRounding: 0.5,
side: BorderSide(color: colors.primary, width: 2),
),
),
),
RepeatingAnimationBuilder<double>(
animatable: Tween<double>(begin: 0.8, end: 1.0),
duration: const Duration(seconds: 2),
paused: _isPaused,
repeatMode: RepeatMode.reverse,
curve: Curves.easeInOutSine,
builder: (BuildContext context, double value, Widget? child) {
return Transform.scale(
scale: value,
child: Container(
decoration: BoxDecoration(
boxShadow: <BoxShadow>[
BoxShadow(color: colors.primary.withOpacity(value * 0.7), blurRadius: 25),
],
),
child: child,
),
);
},
child: _buildGemCore(colors),
),
],
),
);
}
/// Builds the static, non-animated core of the gem.
Widget _buildGemCore(ColorScheme colors) {
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25),
gradient: LinearGradient(
colors: <Color>[colors.primary, colors.secondary],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
);
}
/// Builds the controls area at the bottom of the screen.
Widget _buildControls(ColorScheme colors, double animationValue) {
return Positioned(
bottom: 40,
left: 0,
right: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_buildPlayPauseButton(colors, animationValue),
const SizedBox(height: 24),
_buildReverseToggle(colors),
],
),
);
}
/// Builds the custom Play/Pause button with a progress indicator border.
Widget _buildPlayPauseButton(ColorScheme colors, double animationValue) {
const double buttonSize = 88.0;
return SizedBox(
width: buttonSize,
height: buttonSize,
// InkWell provides the ripple effect on tap.
child: InkWell(
borderRadius: BorderRadius.circular(buttonSize / 2),
onTap: () => setState(() => _isPaused = !_isPaused),
child: Stack(
alignment: Alignment.center,
children: <Widget>[
// The progress indicator is the bottom layer, acting as a border.
SizedBox.expand(
// This makes the indicator fill the SizedBox
child: CircularProgressIndicator(
value: animationValue,
strokeWidth: 6,
backgroundColor: colors.surfaceVariant.withOpacity(0.3),
color: colors.primary,
),
),
// The solid button core is the middle layer.
Container(
margin: const EdgeInsets.all(8), // Inset from the progress ring
decoration: BoxDecoration(shape: BoxShape.circle, color: colors.primary),
),
// The icon is the top layer.
Icon(_isPaused ? Icons.play_arrow : Icons.pause, size: 40, color: colors.onPrimary),
],
),
),
);
}
/// Builds the animation mode toggle control.
Widget _buildReverseToggle(ColorScheme colors) {
return GestureDetector(
// The entire area is clickable.
onTap: () => setState(() => _isReversing = !_isReversing),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
decoration: BoxDecoration(
color: colors.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(24.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
_isReversing ? Icons.sync : Icons.sync_disabled,
color: colors.onSurface,
size: 20,
),
const SizedBox(width: 8),
Text(
_isReversing ? 'Back & Forth' : 'Forward Only',
style: TextStyle(color: colors.onSurface, fontWeight: FontWeight.w500),
),
const SizedBox(width: 8),
Switch(
value: _isReversing,
onChanged: (bool value) {
setState(() {
_isReversing = value;
});
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,60 @@
// 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';
import 'package:flutter_api_samples/widgets/repeating_animation_builder/repeating_animation_builder.0.dart'
as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('RepeatingAnimationBuilder animates continuously', (WidgetTester tester) async {
await tester.pumpWidget(const example.RepeatingAnimationBuilderExampleApp());
// Verify animation is happening by checking Transform changes
final Transform initial = tester.widget(find.byType(Transform).first);
await tester.pump(const Duration(milliseconds: 500));
final Transform after = tester.widget(find.byType(Transform).first);
expect(initial.transform, isNot(equals(after.transform)));
});
testWidgets('Play/pause button controls animation', (WidgetTester tester) async {
await tester.pumpWidget(const example.RepeatingAnimationBuilderExampleApp());
// Initially playing (pause icon visible)
expect(find.byIcon(Icons.pause), findsOneWidget);
// Tap to pause
await tester.tap(find.byType(InkWell).first);
await tester.pump();
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
// Verify animation stopped
final Transform paused = tester.widget(find.byType(Transform).first);
await tester.pump(const Duration(milliseconds: 500));
final Transform stillPaused = tester.widget(find.byType(Transform).first);
expect(paused.transform, equals(stillPaused.transform));
// Resume animation
await tester.tap(find.byType(InkWell).first);
await tester.pump();
expect(find.byIcon(Icons.pause), findsOneWidget);
});
testWidgets('Reverse toggle changes animation direction', (WidgetTester tester) async {
await tester.pumpWidget(const example.RepeatingAnimationBuilderExampleApp());
// Check initial state
Switch switchWidget = tester.widget(find.byType(Switch));
expect(switchWidget.value, false);
// Toggle reverse
await tester.tap(find.byType(Switch));
await tester.pump();
// Verify toggled
switchWidget = tester.widget(find.byType(Switch));
expect(switchWidget.value, true);
});
}

View File

@ -0,0 +1,188 @@
// 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/animation.dart';
import 'framework.dart';
import 'ticker_provider.dart';
import 'transitions.dart';
import 'value_listenable_builder.dart';
/// Configures how [RepeatingAnimationBuilder] loops its animation.
enum RepeatMode {
/// Each iteration starts over from the beginning once 1.0 is reached.
restart,
/// Each iteration runs forward, then reverses back to the beginning.
reverse,
}
/// Widget that animates an [Animatable] value and repeats indefinitely.
///
/// The animation continuously cycles from the value produced by the
/// [Animatable] at 0.0 to the value at 1.0. The [builder] receives the current
/// value produced by this [Animatable] and builds the child from it. When the
/// animation reaches 1.0, it either restarts from 0.0 or reverses direction,
/// based on the configured [repeatMode]. When [paused] is true, the animation
/// stops at its current value.
///
/// {@tool dartpad}
/// This example shows a continuously rotating square that can be paused and resumed.
///
/// ** See code in examples/api/lib/widgets/repeating_animation_builder/repeating_animation_builder.0.dart **
/// {@end-tool}
///
/// For more complex, stateful, or coordinated animations, consider managing an
/// [AnimationController] directly and composing it with [AnimatedBuilder] or an
/// [AnimatedWidget].
///
/// See also:
///
/// * [TweenAnimationBuilder], which animates a tween value once.
/// * [AnimationController.repeat], the underlying mechanism.
class RepeatingAnimationBuilder<T extends Object> extends StatefulWidget {
/// Creates a widget that repeats an animation.
const RepeatingAnimationBuilder({
super.key,
required this.animatable,
required this.duration,
this.curve = Curves.linear,
this.repeatMode = RepeatMode.restart,
this.paused = false,
required this.builder,
this.child,
});
/// The animatable to drive repeatedly.
///
/// Typically this is a [Tween] or a [TweenSequence], but any [Animatable]
/// that produces values of type [T] is accepted.
final Animatable<T> animatable;
/// The duration of the animation.
///
/// If [repeatMode] is [RepeatMode.restart], this is the
/// duration of the entire animation sequence from 0.0 to 1.0.
///
/// If [repeatMode] is [RepeatMode.reverse], both the
/// forward segment (0.0 to 1.0) and the backward segment
/// (1.0 to 0.0) will each take this duration separately.
/// The total time for one complete forward-and-reverse cycle
/// will be twice this value.
final Duration duration;
/// The curve applied to the animation input before it is passed to the
/// [animatable].
///
/// In other words, the curve transforms the controller's 0.0..1.0 timeline
/// and the resulting value is then fed into the [animatable].
///
/// Defaults to [Curves.linear].
final Curve curve;
/// A builder that creates the animated widget subtree.
///
/// The builder is called every time the animation value changes. The optional
/// child can be used to avoid unnecessary rebuilds if a part of the subtree
/// does not depend on the animation.
final ValueWidgetBuilder<T> builder;
/// An optional widget to pass to the builder.
///
/// If a builder callback's return value contains a subtree that does not
/// depend on the animation, it's more efficient to build that subtree once
/// instead of rebuilding it on every animation tick.
///
/// If the pre-built subtree is passed as the child parameter, the
/// [RepeatingAnimationBuilder] will pass it back to the [builder]
/// function so that it can be incorporated into the build.
///
/// Using this pre-built child is entirely optional, but can improve
/// performance significantly in some cases and is therefore a good practice.
final Widget? child;
/// How the animation behaves after reaching 1.0.
///
/// When set to [RepeatMode.reverse], the animation plays forward and then
/// backward. Defaults to [RepeatMode.restart], which jumps back to 0.0 and
/// resumes from there.
final RepeatMode repeatMode;
/// Whether the animation is currently paused.
///
/// When true, the animation stops at its current value. When changed to
/// false, the animation resumes from that value. Defaults to false.
final bool paused;
@override
State<RepeatingAnimationBuilder<T>> createState() {
return _RepeatingAnimationBuilderState<T>();
}
}
class _RepeatingAnimationBuilderState<T extends Object> extends State<RepeatingAnimationBuilder<T>>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final CurvedAnimation _curvedAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: widget.duration, vsync: this);
_curvedAnimation = CurvedAnimation(parent: _controller, curve: widget.curve);
if (!widget.paused) {
_controller.repeat(reverse: widget.repeatMode == RepeatMode.reverse);
}
}
@override
void didUpdateWidget(RepeatingAnimationBuilder<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.duration != oldWidget.duration) {
_controller.duration = widget.duration;
}
if (widget.curve != oldWidget.curve) {
_curvedAnimation.curve = widget.curve;
}
if (widget.paused) {
if (!oldWidget.paused || _controller.isAnimating) {
_controller.stop(canceled: false);
}
return;
}
final bool shouldRestart =
oldWidget.paused ||
widget.repeatMode != oldWidget.repeatMode ||
widget.duration != oldWidget.duration ||
!_controller.isAnimating;
if (shouldRestart) {
_controller.repeat(reverse: widget.repeatMode == RepeatMode.reverse);
}
}
@override
void dispose() {
_curvedAnimation.dispose();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _curvedAnimation,
builder: (BuildContext context, Widget? child) {
final T value = widget.animatable.transform(_curvedAnimation.value);
return widget.builder(context, value, child);
},
child: widget.child,
);
}
}

View File

@ -111,6 +111,7 @@ export 'src/widgets/raw_keyboard_listener.dart';
export 'src/widgets/raw_menu_anchor.dart';
export 'src/widgets/raw_radio.dart';
export 'src/widgets/reorderable_list.dart';
export 'src/widgets/repeating_animation_builder.dart';
export 'src/widgets/restoration.dart';
export 'src/widgets/restoration_properties.dart';
export 'src/widgets/router.dart';

View File

@ -0,0 +1,195 @@
// 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/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('RepeatingAnimationBuilder', () {
testWidgets('Repeats animation continuously', (WidgetTester tester) async {
final List<double> values = <double>[];
const Duration duration = Duration(milliseconds: 100);
await tester.pumpWidget(
RepeatingAnimationBuilder<double>(
duration: duration,
animatable: Tween<double>(begin: 0.0, end: 1.0),
builder: (BuildContext context, double value, Widget? child) {
values.add(value);
return const Placeholder();
},
),
);
// Initial value should be 0.0.
expect(values.last, 0.0);
// Pump to a frame just before the end of the first cycle.
await tester.pump(const Duration(milliseconds: 99));
expect(values.last, moreOrLessEquals(0.99));
// Pump past the cycle boundary (1ms to complete the cycle + 50ms into the next cycle).
await tester.pump(const Duration(milliseconds: 51));
expect(values.last, moreOrLessEquals(0.5));
});
testWidgets('Reverses animation when repeatMode is reverse', (WidgetTester tester) async {
final List<double> values = <double>[];
await tester.pumpWidget(
RepeatingAnimationBuilder<double>(
duration: const Duration(milliseconds: 100),
animatable: Tween<double>(begin: 0, end: 1),
repeatMode: RepeatMode.reverse,
builder: (BuildContext context, double value, Widget? child) {
values.add(value);
return const Placeholder();
},
),
);
for (int i = 0; i < 20; i++) {
await tester.pump(const Duration(milliseconds: 20));
}
expect(values.first, 0.0);
expect(values.any((double v) => v == 1.0), isTrue, reason: 'Should reach max');
bool foundReverse = false;
for (int i = 1; i < values.length; i++) {
if (values[i] < values[i - 1] && values[i - 1] > 0.5 && values[i] < 0.9) {
foundReverse = true;
break;
}
}
expect(foundReverse, isTrue, reason: 'Should have reversing motion');
});
testWidgets('Handles pause and unpause correctly', (WidgetTester tester) async {
final List<int> values = <int>[];
Widget buildWidget({required bool paused}) {
return RepeatingAnimationBuilder<int>(
duration: const Duration(seconds: 1),
animatable: IntTween(begin: 0, end: 100),
paused: paused,
builder: (BuildContext context, int value, Widget? child) {
values.add(value);
return const Placeholder();
},
);
}
await tester.pumpWidget(buildWidget(paused: false));
await tester.pump(const Duration(milliseconds: 500));
expect(values, <int>[0, 50]);
await tester.pumpWidget(buildWidget(paused: true));
final int pausedValue = values.last;
await tester.pump(const Duration(seconds: 2));
expect(values.last, pausedValue);
await tester.pumpWidget(buildWidget(paused: false));
await tester.pump(const Duration(milliseconds: 400));
expect(values.last, greaterThan(pausedValue));
});
testWidgets('Animates even when begin equals end', (WidgetTester tester) async {
final List<int> values = <int>[];
await tester.pumpWidget(
RepeatingAnimationBuilder<int>(
duration: const Duration(seconds: 1),
animatable: IntTween(begin: 100, end: 100),
builder: (BuildContext context, int value, Widget? child) {
values.add(value);
return const Placeholder();
},
),
);
expect(values.last, 100);
await tester.pump(const Duration(milliseconds: 16));
expect(values.length, greaterThan(1));
expect(values.every((int value) => value == 100), isTrue);
});
testWidgets('Animates when endpoints match but intermediate values change', (
WidgetTester tester,
) async {
final List<double> values = <double>[];
await tester.pumpWidget(
RepeatingAnimationBuilder<double>(
duration: const Duration(milliseconds: 120),
animatable: TweenSequence<double>(<TweenSequenceItem<double>>[
TweenSequenceItem<double>(tween: Tween<double>(begin: 0.0, end: 1.0), weight: 1),
TweenSequenceItem<double>(tween: Tween<double>(begin: 1.0, end: 0.0), weight: 1),
]),
builder: (BuildContext context, double value, Widget? child) {
values.add(value);
return const Placeholder();
},
),
);
await tester.pump(const Duration(milliseconds: 40));
expect(values.last, greaterThan(0.0));
await tester.pump(const Duration(milliseconds: 40));
expect(values.last, greaterThan(0.5));
await tester.pump(const Duration(milliseconds: 40));
expect(values.last, lessThan(0.6));
});
testWidgets('Passes child to builder correctly', (WidgetTester tester) async {
const Widget childWidget = Text('Child');
Widget? receivedChild;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: RepeatingAnimationBuilder<double>(
duration: const Duration(seconds: 1),
animatable: Tween<double>(begin: 0.0, end: 1.0),
builder: (BuildContext context, double value, Widget? child) {
receivedChild = child;
return child ?? const Placeholder();
},
child: childWidget,
),
),
);
expect(receivedChild, childWidget);
expect(find.text('Child'), findsOneWidget);
});
testWidgets('Supports animatables without explicit begin/end', (WidgetTester tester) async {
final List<double> values = <double>[];
await tester.pumpWidget(
RepeatingAnimationBuilder<double>(
duration: const Duration(milliseconds: 100),
animatable: CurveTween(curve: Curves.easeIn),
builder: (BuildContext context, double value, Widget? child) {
values.add(value);
return const Placeholder();
},
),
);
expect(values, isNotEmpty);
expect(values.first, 0.0);
await tester.pump(const Duration(milliseconds: 50));
expect(values.last, greaterThan(0.0));
await tester.pump(const Duration(milliseconds: 49));
expect(values.last, greaterThan(0.9));
await tester.pump(const Duration(milliseconds: 10));
expect(values.last, lessThan(0.2));
});
});
}