Skip to content

Add a RepeatedTweenAnimationBuilder API #174011

@loic-sharma

Description

@loic-sharma

Use case

Implicit widgets are great, but they can't be used for repeating animations.
Instead, you need to use explicit animations.

Spinning green square

This is a basic animation that requires heavy boilerplate.

Code sample...
import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(home: SpinningSquarePage()));
}

class SpinningSquarePage extends StatefulWidget {
  const SpinningSquarePage({super.key});

  @override
  State<SpinningSquarePage> createState() => _SpinningSquarePageState();
}

class _SpinningSquarePageState extends State<SpinningSquarePage>
    with TickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    duration: const Duration(seconds: 2),
    vsync: this,
  );

  @override
  void initState() {
    super.initState();
    _controller.repeat();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: RotationTransition(
          turns: _controller,
          child: const ColoredBox(
            color: Colors.green,
            child: SizedBox.square(dimension: 100),
          ),
        ),
      ),
    );
  }
}

LinearProgressIndicator

Another example is LinearProgressIndicator - it shows a spinning indicator if its value is null:

class _LinearProgressIndicatorState extends State<LinearProgressIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: _kIndeterminateLinearDuration),
vsync: this,
);
if (widget.value == null) {
_controller.repeat();
}
}
@override
void didUpdateWidget(LinearProgressIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value == null && !_controller.isAnimating) {
_controller.repeat();
} else if (widget.value != null && _controller.isAnimating) {
_controller.stop();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _buildIndicator(BuildContext context, double animationValue, TextDirection textDirection) {
final ProgressIndicatorThemeData indicatorTheme = ProgressIndicatorTheme.of(context);
final bool year2023 = widget.year2023 ?? indicatorTheme.year2023 ?? true;
final ProgressIndicatorThemeData defaults = switch (Theme.of(context).useMaterial3) {
true =>
year2023
? _LinearProgressIndicatorDefaultsM3Year2023(context)
: _LinearProgressIndicatorDefaultsM3(context),
false => _LinearProgressIndicatorDefaultsM2(context),
};
final Color trackColor =
widget.backgroundColor ?? indicatorTheme.linearTrackColor ?? defaults.linearTrackColor!;
final double minHeight =
widget.minHeight ?? indicatorTheme.linearMinHeight ?? defaults.linearMinHeight!;
final BorderRadiusGeometry? borderRadius =
widget.borderRadius ?? indicatorTheme.borderRadius ?? defaults.borderRadius;
final Color? stopIndicatorColor = !year2023
? widget.stopIndicatorColor ??
indicatorTheme.stopIndicatorColor ??
defaults.stopIndicatorColor
: null;
final double? stopIndicatorRadius = !year2023
? widget.stopIndicatorRadius ??
indicatorTheme.stopIndicatorRadius ??
defaults.stopIndicatorRadius
: null;
final double? trackGap = !year2023
? widget.trackGap ?? indicatorTheme.trackGap ?? defaults.trackGap
: null;
Widget result = ConstrainedBox(
constraints: BoxConstraints(minWidth: double.infinity, minHeight: minHeight),
child: CustomPaint(
painter: _LinearProgressIndicatorPainter(
trackColor: trackColor,
valueColor: widget._getValueColor(context, defaultColor: defaults.color),
value: widget.value, // may be null
animationValue: animationValue, // ignored if widget.value is not null
textDirection: textDirection,
indicatorBorderRadius: borderRadius,
stopIndicatorColor: stopIndicatorColor,
stopIndicatorRadius: stopIndicatorRadius,
trackGap: trackGap,
),
),
);
// Clip is only needed with indeterminate progress indicators
if (borderRadius != null && widget.value == null) {
result = ClipRRect(borderRadius: borderRadius, child: result);
}
return widget._buildSemanticsWrapper(context: context, child: result);
}
@override
Widget build(BuildContext context) {
final TextDirection textDirection = Directionality.of(context);
if (widget.value != null) {
return _buildIndicator(context, _controller.value, textDirection);
}
return AnimatedBuilder(
animation: _controller.view,
builder: (BuildContext context, Widget? child) {
return _buildIndicator(context, _controller.value, textDirection);
},
);
}
}

Proposal

Option 1

class TweenAnimationBuilder {
  TweenAnimationBuilder.repeat({
    Key? key,
    required Tween<T> tween,
    required Duration duration,
    Curve curve,

    // If true, the animation flip-flops between forwards and reverse.
    bool reverse = false,

    // If true, the animation is paused.
    //
    // For example, LinearProgressIndicator would pause its animation
    // if it has a value, and would unpause the animation if its value
    // is null. 
    bool paused = false,

    required ValueWidgetBuilder builder,
    Widget? child,
  });
}

Option 2 (preferred)

class RepeatedTweenAnimationBuilder {
  RepeatedTweenAnimationBuilder({
    Key? key,
    required Tween<T> tween,
    required Duration duration,
    Curve curve,

    // If true, the animation flip-flops between forwards and reverse.
    bool reverse = false,

    // If true, the animation is paused.
    //
    // For example, LinearProgressIndicator would pause its animation
    // if it has a value, and would unpause the animation if its value
    // is null. 
    bool paused = false,

    required ValueWidgetBuilder builder,
    Widget? child,
  });
}

Usage

import 'dart:math' as math;
import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(home: SpinningSquarePage()));
}

class SpinningSquarePage extends StatelessWidget {
  const SpinningSquarePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: RepeatedTweenAnimationBuilder<double>(
          tween: Tween<double>(begin: 0, end: 1),
          duration: const Duration(seconds: 2),
          builder: (context, rotation, _) {
            return Transform.rotate(
              angle: math.pi * rotation * 2,
              child: const ColoredBox(
                color: Colors.green,
                child: SizedBox.square(dimension: 100),
              ),
            );
          },
        ),
      ),
    );
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work lista: animationAnimation APIsc: new featureNothing broken; request for a new capabilityc: proposalA detailed proposal for a change to Flutterteam-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework team

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions