diff --git a/examples/api/lib/painting/borders/border_side.stroke_align.0.dart b/examples/api/lib/painting/borders/border_side.stroke_align.0.dart index 78e88dfedea..4aad4814558 100644 --- a/examples/api/lib/painting/borders/border_side.stroke_align.0.dart +++ b/examples/api/lib/painting/borders/border_side.stroke_align.0.dart @@ -17,34 +17,9 @@ class StrokeAlignApp extends StatelessWidget { } } -class StrokeAlignExample extends StatefulWidget { +class StrokeAlignExample extends StatelessWidget { const StrokeAlignExample({super.key}); - @override - State createState() => _StrokeAlignExampleState(); -} - -class _StrokeAlignExampleState extends State 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 with TickerProv Widget build(BuildContext context) { return Material( child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - BorderedBox( - shape: StadiumBorder( - side: BorderSide( - color: borderColor, - width: borderWidth, - strokeAlign: (animation.value * 2) - 1, - ), - ), - ), - Row( + child: RepeatingAnimationBuilder( + animatable: Tween(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: [ 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: [ + 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: [ + 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: [ + 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: [ + 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: [ - 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: [ - 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: [ - 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, - ), - ), - ), - ], - ), - ], + ); + }, ), ), ); diff --git a/examples/api/lib/widgets/repeating_animation_builder/repeating_animation_builder.0.dart b/examples/api/lib/widgets/repeating_animation_builder/repeating_animation_builder.0.dart new file mode 100644 index 00000000000..868ad1c61d7 --- /dev/null +++ b/examples/api/lib/widgets/repeating_animation_builder/repeating_animation_builder.0.dart @@ -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 createState() => _RepeatingAnimationBuilderExampleState(); +} + +class _RepeatingAnimationBuilderExampleState extends State { + 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( + animatable: Tween(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: [ + 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: [ + 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( + animatable: Tween(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(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: [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: [ + _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: [ + // 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: [ + 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; + }); + }, + ), + ], + ), + ), + ); + } +} diff --git a/examples/api/test/widgets/repeating_animation_builder/repeating_animation_builder.0_test.dart b/examples/api/test/widgets/repeating_animation_builder/repeating_animation_builder.0_test.dart new file mode 100644 index 00000000000..c3350c9bbf2 --- /dev/null +++ b/examples/api/test/widgets/repeating_animation_builder/repeating_animation_builder.0_test.dart @@ -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); + }); +} diff --git a/packages/flutter/lib/src/widgets/repeating_animation_builder.dart b/packages/flutter/lib/src/widgets/repeating_animation_builder.dart new file mode 100644 index 00000000000..74bc021fd25 --- /dev/null +++ b/packages/flutter/lib/src/widgets/repeating_animation_builder.dart @@ -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 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 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 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> createState() { + return _RepeatingAnimationBuilderState(); + } +} + +class _RepeatingAnimationBuilderState extends State> + 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 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, + ); + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 4db57744df9..6e7cb4d41f1 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -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'; diff --git a/packages/flutter/test/widgets/repeating_animation_builder_test.dart b/packages/flutter/test/widgets/repeating_animation_builder_test.dart new file mode 100644 index 00000000000..8e8e9633ba9 --- /dev/null +++ b/packages/flutter/test/widgets/repeating_animation_builder_test.dart @@ -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 values = []; + const Duration duration = Duration(milliseconds: 100); + + await tester.pumpWidget( + RepeatingAnimationBuilder( + duration: duration, + animatable: Tween(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 values = []; + + await tester.pumpWidget( + RepeatingAnimationBuilder( + duration: const Duration(milliseconds: 100), + animatable: Tween(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 values = []; + Widget buildWidget({required bool paused}) { + return RepeatingAnimationBuilder( + 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, [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 values = []; + await tester.pumpWidget( + RepeatingAnimationBuilder( + 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 values = []; + + await tester.pumpWidget( + RepeatingAnimationBuilder( + duration: const Duration(milliseconds: 120), + animatable: TweenSequence(>[ + TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1), + TweenSequenceItem(tween: Tween(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( + duration: const Duration(seconds: 1), + animatable: Tween(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 values = []; + + await tester.pumpWidget( + RepeatingAnimationBuilder( + 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)); + }); + }); +}