Skip to content

Allow Passing an AnimationController to CircularProgressIndicator (Fix #165741) #170380

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8a1b072
fix 165741
Sten435 Jun 10, 2025
825efbc
Merge pull request #4 from Sten435/fix-165741
Sten435 Jun 10, 2025
bd6f142
Merge branch 'master' into main
Sten435 Jun 10, 2025
e70097a
Merge branch 'master' into main
Sten435 Jun 11, 2025
8480570
Merge branch 'master' into fix-165741
Sten435 Jun 11, 2025
4efde13
Fix indentation issue that caused the Linux analyze to fail
Sten435 Jun 11, 2025
e8e58c1
Merge branch 'main' into fix-165741
Sten435 Jun 11, 2025
4e8686e
fix 165741
Sten435 Jun 10, 2025
18349ab
Fix indentation issue that caused the Linux analyze to fail
Sten435 Jun 11, 2025
07ed6df
Merge branch 'main' of https://github.com/sten435/flutter
Sten435 Jun 23, 2025
8a7091f
Merge branch 'master'
Sten435 Jun 23, 2025
0391df9
Merge branch 'main' into fix-165741
Sten435 Jun 23, 2025
633bba3
Merge branch 'master' into fix-165741
Sten435 Jul 5, 2025
ed0fe82
Fix review changes
Sten435 Jul 5, 2025
4e59d1a
Merge pull request #6 from Sten435/fix-165741
Sten435 Jul 5, 2025
b54ab30
Merge branch 'master' into main
Sten435 Jul 5, 2025
24847c1
Merge branch 'master' into fix-165741
Sten435 Jul 16, 2025
73ed374
FIX-165741 Fix tests
Sten435 Jul 16, 2025
ff46c0b
Merge branch 'main' into fix-165741
Sten435 Jul 16, 2025
24d9add
Merge pull request #7 from Sten435/fix-165741
Sten435 Jul 16, 2025
c173a39
Added docs
Sten435 Jul 16, 2025
cd37464
Merge pull request #8 from Sten435/fix-165741
Sten435 Jul 16, 2025
7d1660c
Merge branch 'master' into main
Sten435 Jul 16, 2025
e153e71
FIX formatting
Sten435 Jul 17, 2025
81722ae
Merge branch 'main' into fix-165741
Sten435 Jul 17, 2025
e6b6346
Merge pull request #9 from Sten435/fix-165741
Sten435 Jul 17, 2025
688e2e0
Merge branch 'master' into fix-165741
Sten435 Jul 21, 2025
7b9c42b
Merge branch 'master' into fix-165741
Sten435 Aug 17, 2025
a229099
Add progress indicator test
Sten435 Aug 17, 2025
33c9903
Merge branch 'master' into fix-165741
Sten435 Aug 17, 2025
df0dd2a
Merge pull request #10 from Sten435/fix-165741
Sten435 Aug 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 105 additions & 18 deletions packages/flutter/lib/src/material/progress_indicator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ class LinearProgressIndicator extends ProgressIndicator {
'This feature was deprecated after v3.26.0-0.1.pre.',
)
this.year2023,
this.controller,
}) : assert(minHeight == null || minHeight > 0);

/// {@template flutter.material.LinearProgressIndicator.trackColor}
Expand Down Expand Up @@ -427,39 +428,81 @@ class LinearProgressIndicator extends ProgressIndicator {
)
final bool? year2023;

/// ## Animation synchronization
///
/// When multiple [ProgressIndicator] widgets are animating on screen
/// simultaneously (e.g., in a list of loading items), their uncoordinated
/// animations can appear visually cluttered. To address this, the animation of
/// an indicator can be driven by a custom [AnimationController].
///
/// This allows multiple indicators to be synchronized to a single animation
/// source. The most convenient way to achieve this for a group of indicators is
/// by providing a controller via [ProgressIndicatorTheme]. All
/// [ProgressIndicator] widgets within that theme's subtree will then share
/// the same animation, resulting in a more coordinated and visually pleasing
/// effect.
///
/// Alternatively, a specific [AnimationController] can be passed directly to the
/// [controller] property of an individual indicator.
final AnimationController? controller;

/// The default duration for [LinearProgressIndicator] animation.
static const Duration defaultAnimationDuration = Duration(
milliseconds: _kIndeterminateLinearDuration,
);

@override
State<LinearProgressIndicator> createState() => _LinearProgressIndicatorState();
}

class _LinearProgressIndicatorState extends State<LinearProgressIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late AnimationController _internalController;
AnimationController? _inheritedController;

AnimationController get _controller =>
widget.controller ?? _inheritedController ?? _internalController;

bool get _usingInternalController => _controller == _internalController;

@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: _kIndeterminateLinearDuration),
_inheritedController =
context.getInheritedWidgetOfExactType<ProgressIndicatorTheme>()?.data.controller ??
context.findAncestorWidgetOfExactType<Theme>()?.data.progressIndicatorTheme.controller;

_internalController = AnimationController(
duration: LinearProgressIndicator.defaultAnimationDuration,
vsync: this,
);
if (widget.value == null) {

if (_usingInternalController && 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();
if (_usingInternalController) {
if (widget.value == null && !_controller.isAnimating) {
_controller.repeat();
} else if (widget.value != null && _controller.isAnimating) {
_controller.stop();
}
}
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
_inheritedController = ProgressIndicatorTheme.of(context).controller;
}

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

Expand Down Expand Up @@ -722,6 +765,7 @@ class CircularProgressIndicator extends ProgressIndicator {
)
this.year2023,
this.padding,
this.controller,
}) : _indicatorType = _ActivityIndicatorType.material;

/// Creates an adaptive progress indicator that is a
Expand Down Expand Up @@ -753,6 +797,7 @@ class CircularProgressIndicator extends ProgressIndicator {
)
this.year2023,
this.padding,
this.controller,
}) : _indicatorType = _ActivityIndicatorType.adaptive;

final _ActivityIndicatorType _indicatorType;
Expand Down Expand Up @@ -844,6 +889,24 @@ class CircularProgressIndicator extends ProgressIndicator {
/// padding. Otherwise, defaults to zero padding.
final EdgeInsetsGeometry? padding;

/// ## Animation synchronization
///
/// When multiple [ProgressIndicator] widgets are animating on screen
/// simultaneously (e.g., in a list of loading items), their uncoordinated
/// animations can appear visually cluttered. To address this, the animation of
/// an indicator can be driven by a custom [AnimationController].
///
/// This allows multiple indicators to be synchronized to a single animation
/// source. The most convenient way to achieve this for a group of indicators is
/// by providing a controller via [ProgressIndicatorTheme]. All
/// [ProgressIndicator] widgets within that theme's subtree will then share
/// the same animation, resulting in a more coordinated and visually pleasing
/// effect.
///
/// Alternatively, a specific [AnimationController] can be passed directly to the
/// [controller] property of an individual indicator.
final AnimationController? controller;

/// The indicator stroke is drawn fully inside of the indicator path.
///
/// This is a constant for use with [strokeAlign].
Expand All @@ -863,6 +926,11 @@ class CircularProgressIndicator extends ProgressIndicator {
/// This is a constant for use with [strokeAlign].
static const double strokeAlignOutside = 1.0;

/// The default duration for [CircularProgressIndicator] animation.
static const Duration defaultAnimationDuration = Duration(
milliseconds: _kIndeterminateCircularDuration,
);

@override
State<CircularProgressIndicator> createState() => _CircularProgressIndicatorState();
}
Expand All @@ -883,33 +951,52 @@ class _CircularProgressIndicatorState extends State<CircularProgressIndicator>
curve: const SawTooth(_rotationCount),
);

late AnimationController _controller;
late AnimationController _internalController;
AnimationController? _inheritedController;

AnimationController get _controller =>
widget.controller ?? _inheritedController ?? _internalController;

bool get _usingInternalController => _controller == _internalController;

@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: _kIndeterminateCircularDuration),
_inheritedController =
context.getInheritedWidgetOfExactType<ProgressIndicatorTheme>()?.data.controller ??
context.findAncestorWidgetOfExactType<Theme>()?.data.progressIndicatorTheme.controller;

_internalController = AnimationController(
duration: CircularProgressIndicator.defaultAnimationDuration,
vsync: this,
);
if (widget.value == null) {

if (_usingInternalController && widget.value == null) {
_controller.repeat();
}
}

@override
void didUpdateWidget(CircularProgressIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value == null && !_controller.isAnimating) {
_controller.repeat();
} else if (widget.value != null && _controller.isAnimating) {
_controller.stop();
if (_usingInternalController) {
if (widget.value == null && !_controller.isAnimating) {
_controller.repeat();
} else if (widget.value != null && _controller.isAnimating) {
_controller.stop();
}
}
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
_inheritedController = ProgressIndicatorTheme.of(context).controller;
}

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

Expand Down
29 changes: 28 additions & 1 deletion packages/flutter/lib/src/material/progress_indicator_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class ProgressIndicatorThemeData with Diagnosticable {
'This feature was deprecated after v3.27.0-0.2.pre.',
)
this.year2023,
this.controller,
});

/// The color of the [ProgressIndicator]'s indicator.
Expand Down Expand Up @@ -138,6 +139,24 @@ class ProgressIndicatorThemeData with Diagnosticable {
)
final bool? year2023;

/// ## Animation synchronization
///
/// When multiple [ProgressIndicator] widgets are animating on screen
/// simultaneously (e.g., in a list of loading items), their uncoordinated
/// animations can appear visually cluttered. To address this, the animation of
/// an indicator can be driven by a custom [AnimationController].
///
/// This allows multiple indicators to be synchronized to a single animation
/// source. The most convenient way to achieve this for a group of indicators is
/// by providing a controller via [ProgressIndicatorTheme]. All
/// [ProgressIndicator] widgets within that theme's subtree will then share
/// the same animation, resulting in a more coordinated and visually pleasing
/// effect.
///
/// Alternatively, a specific [AnimationController] can be passed directly to the
/// [controller] property of an individual indicator.
final AnimationController? controller;

/// Creates a copy of this object but with the given fields replaced with the
/// new values.
ProgressIndicatorThemeData copyWith({
Expand All @@ -156,6 +175,7 @@ class ProgressIndicatorThemeData with Diagnosticable {
double? trackGap,
EdgeInsetsGeometry? circularTrackPadding,
bool? year2023,
AnimationController? controller,
}) {
return ProgressIndicatorThemeData(
color: color ?? this.color,
Expand All @@ -173,6 +193,7 @@ class ProgressIndicatorThemeData with Diagnosticable {
trackGap: trackGap ?? this.trackGap,
circularTrackPadding: circularTrackPadding ?? this.circularTrackPadding,
year2023: year2023 ?? this.year2023,
controller: controller ?? this.controller,
);
}

Expand Down Expand Up @@ -207,6 +228,7 @@ class ProgressIndicatorThemeData with Diagnosticable {
t,
),
year2023: t < 0.5 ? a?.year2023 : b?.year2023,
controller: t < 0.5 ? a?.controller : b?.controller,
);
}

Expand All @@ -227,6 +249,7 @@ class ProgressIndicatorThemeData with Diagnosticable {
trackGap,
circularTrackPadding,
year2023,
controller,
);

@override
Expand All @@ -252,7 +275,8 @@ class ProgressIndicatorThemeData with Diagnosticable {
other.constraints == constraints &&
other.trackGap == trackGap &&
other.circularTrackPadding == circularTrackPadding &&
other.year2023 == year2023;
other.year2023 == year2023 &&
other.controller == controller;
}

@override
Expand Down Expand Up @@ -285,6 +309,9 @@ class ProgressIndicatorThemeData with Diagnosticable {
),
);
properties.add(DiagnosticsProperty<bool>('year2023', year2023, defaultValue: null));
properties.add(
DiagnosticsProperty<AnimationController>('controller', controller, defaultValue: null),
);
}
}

Expand Down
74 changes: 74 additions & 0 deletions packages/flutter/test/material/progress_indicator_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,80 @@ void main() {
expect(tester.binding.transientCallbackCount, 0);
});

testWidgets('LinearProgressIndicator reflects controller value', (WidgetTester tester) async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the point of both tests. The controller's value is passed into the progress indicator directly?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is adding a new property that increases the API surface. Tests should check that that property is respected (for both progress indicators and ProgressIndicatorThemeData as well.).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what way should I check that its respected ?
Like check if the hashcode is the same or ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the point of both tests. The controller's value is passed into the progress indicator directly?

Any idea's on how you would tackle this, differently ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dkwingsmt What do you think about this ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One example I can think of is passing the same animation controller into multiple progress indicators on the same page and verifying at certain points they all have the same value?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't be done because everything is private in the class.
And there is no way to access private proprties, not even in tests...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't be done because everything is private in the class.

Testing in the framework focuses on observable behavior, not implementation details.

In progress_indicator_test.dart, there are tests that check what is painted by the progress indicators. A good test could have multiple progress indicators aligned in a Column, inserted at different times. Then some of them can have the same controller, and the ones with the same controller can be observed to have the same progress (i.e by the same paint extent, indicating a synchronized animation) while the others have a different paint extent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Sten435, did you run into problems adding a test that fit the description in https://github.com/flutter/flutter/pull/170380/files#r2214098382?

final AnimationController controller = AnimationController(
vsync: tester,
duration: const Duration(seconds: 2),
);

Widget buildWidget(AnimationController? controller) {
return MaterialApp(
home: Material(
child: Center(
child: AnimatedBuilder(
animation: controller!,
builder: (BuildContext context, Widget? child) {
return LinearProgressIndicator(value: controller.value);
},
),
),
),
);
}

await tester.pumpWidget(buildWidget(controller));
await tester.pump(const Duration(milliseconds: 100)); // build

expect(find.byType(LinearProgressIndicator), paints..rect());

controller.value = 0.5;
await tester.pump(); // triggers rebuild via AnimatedBuilder

expect(find.byType(LinearProgressIndicator), paints..rect());
controller.dispose();
});

testWidgets('LinearProgressIndicator paints at 50% when controller value is 0.5', (
WidgetTester tester,
) async {
final AnimationController controller = AnimationController(
vsync: tester,
duration: const Duration(seconds: 2),
);

Widget buildWidget(AnimationController? controller) {
return MaterialApp(
home: Material(
child: Center(
child: SizedBox(
width: 200,
child: AnimatedBuilder(
animation: controller!,
builder: (BuildContext context, Widget? child) {
return LinearProgressIndicator(value: controller.value);
},
),
),
),
),
);
}

await tester.pumpWidget(buildWidget(controller));
await tester.pump();

controller.value = 0.5;
await tester.pump();

expect(
find.byType(LinearProgressIndicator),
paints
..rect(rect: const Rect.fromLTWH(0.0, 0.0, 200.0, 4.0)) // background
..rect(rect: const Rect.fromLTWH(0.0, 0.0, 100.0, 4.0)), // progress at 50%
);
controller.dispose();
});

testWidgets('CircularProgressIndicator paint colors', (WidgetTester tester) async {
const Color green = Color(0xFF00FF00);
const Color blue = Color(0xFF0000FF);
Expand Down
Loading