mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
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:
parent
ab8d05efee
commit
1814874c2d
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user