From e31aa98c8fc1375b51615329e8d26162c7927bff Mon Sep 17 00:00:00 2001 From: Vice Phenek Date: Sun, 30 Nov 2025 22:55:52 +0100 Subject: [PATCH 1/4] - useMultiTickerProvider --- packages/flutter_hooks/lib/src/animation.dart | 92 +++++++++++++ .../test/use_multi_ticker_provider_test.dart | 125 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 packages/flutter_hooks/test/use_multi_ticker_provider_test.dart diff --git a/packages/flutter_hooks/lib/src/animation.dart b/packages/flutter_hooks/lib/src/animation.dart index 86c0c8ce..35465c6a 100644 --- a/packages/flutter_hooks/lib/src/animation.dart +++ b/packages/flutter_hooks/lib/src/animation.dart @@ -237,3 +237,95 @@ class _TickerProviderHookState @override bool get debugSkipValue => true; } + +/// Creates a multi-usage [TickerProvider]. +/// +/// See also: +/// * [SingleTickerProviderStateMixin] +TickerProvider useMultiTickerProvider({List? keys}) { + return use( + keys != null + ? _MultiTickerProviderHook(keys) + : const _MultiTickerProviderHook(), + ); +} + +class _MultiTickerProviderHook extends Hook { + const _MultiTickerProviderHook([List? keys]) : super(keys: keys); + + @override + _MultiTickerProviderHookState createState() => _MultiTickerProviderHookState(); +} + +class _MultiTickerProviderHookState + extends HookState + implements TickerProvider { + final Set _tickers = {}; + ValueListenable? _tickerModeNotifier; + + @override + Ticker createTicker(TickerCallback onTick) { + final ticker = Ticker(onTick, debugLabel: 'created by $context (multi)'); + _updateTickerModeNotifier(); + _updateTickers(); + _tickers.add(ticker); + return ticker; + } + + @override + void dispose() { + assert(() { + // Ensure there are no active tickers left. Controllers that own Tickers + // are responsible for disposing them — leaving an active ticker here is + // almost always a leak or misuse. + for (final t in _tickers) { + if (t.isActive) { + throw FlutterError( + 'useMultiTickerProvider created Ticker(s), but at the time ' + 'dispose() was called on the Hook, at least one of those Tickers ' + 'was still active. Tickers used by AnimationControllers should ' + 'be disposed by calling dispose() on the AnimationController ' + 'itself. Otherwise, the ticker will leak.\n'); + } + } + return true; + }(), ''); + + _tickerModeNotifier?.removeListener(_updateTickers); + _tickerModeNotifier = null; + _tickers.clear(); + super.dispose(); + } + + @override + TickerProvider build(BuildContext context) { + _updateTickerModeNotifier(); + _updateTickers(); + return this; + } + + void _updateTickers() { + if (_tickers.isNotEmpty) { + final muted = !(_tickerModeNotifier?.value ?? TickerMode.of(context)); + for (final t in _tickers) { + t.muted = muted; + } + } + } + + void _updateTickerModeNotifier() { + final newNotifier = TickerMode.getNotifier(context); + if (newNotifier == _tickerModeNotifier) { + return; + } + _tickerModeNotifier?.removeListener(_updateTickers); + newNotifier.addListener(_updateTickers); + _tickerModeNotifier = newNotifier; + } + + @override + String get debugLabel => 'useMultiTickerProvider'; + + @override + bool get debugSkipValue => true; +} \ No newline at end of file diff --git a/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart b/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart new file mode 100644 index 00000000..3ad693f7 --- /dev/null +++ b/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useMultiTickerProvider(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useMultiTickerProvider\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('useMultiTickerProvider basic', (tester) async { + late TickerProvider provider; + + await tester.pumpWidget(TickerMode( + enabled: true, + child: HookBuilder(builder: (context) { + provider = useMultiTickerProvider(); + return Container(); + }), + )); + + final animationControllerA = AnimationController( + vsync: provider, + duration: const Duration(seconds: 1), + ); + final animationControllerB = AnimationController( + vsync: provider, + duration: const Duration(seconds: 1), + ); + + unawaited(animationControllerA.forward()); + unawaited(animationControllerB.forward()); + + // With a multi provider, creating additional AnimationControllers is allowed. + expect( + () => AnimationController(vsync: provider, duration: const Duration(seconds: 1)), + returnsNormally, + ); + + animationControllerA.dispose(); + animationControllerB.dispose(); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('useMultiTickerProvider unused', (tester) async { + await tester.pumpWidget(HookBuilder(builder: (context) { + useMultiTickerProvider(); + return Container(); + })); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('useMultiTickerProvider still active', (tester) async { + late TickerProvider provider; + + await tester.pumpWidget(TickerMode( + enabled: true, + child: HookBuilder(builder: (context) { + provider = useMultiTickerProvider(); + return Container(); + }), + )); + + final animationController = AnimationController( + vsync: provider, + duration: const Duration(seconds: 1), + ); + + try { + // ignore: unawaited_futures + animationController.forward(); + + await tester.pumpWidget(const SizedBox()); + + expect(tester.takeException(), isFlutterError); + } finally { + animationController.dispose(); + } + }); + + testWidgets('useMultiTickerProvider pass down keys', (tester) async { + late TickerProvider provider; + List? keys; + + await tester.pumpWidget(HookBuilder(builder: (context) { + provider = useMultiTickerProvider(keys: keys); + return Container(); + })); + + final previousProvider = provider; + keys = []; + + await tester.pumpWidget(HookBuilder(builder: (context) { + provider = useMultiTickerProvider(keys: keys); + return Container(); + })); + + expect(previousProvider, isNot(provider)); + }); +} \ No newline at end of file From f9af86ee9787bc74f74b01a075ca905fcb7b8fd5 Mon Sep 17 00:00:00 2001 From: Vice Phenek Date: Sun, 30 Nov 2025 22:59:36 +0100 Subject: [PATCH 2/4] - readme for useMultiTickerProvider --- README.md | 1 + packages/flutter_hooks/resources/translations/ja_jp/README.md | 1 + packages/flutter_hooks/resources/translations/ko_kr/README.md | 1 + packages/flutter_hooks/resources/translations/pt_br/README.md | 1 + packages/flutter_hooks/resources/translations/zh_cn/README.md | 1 + 5 files changed, 5 insertions(+) diff --git a/README.md b/README.md index ee67f958..24d2774a 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ They will take care of creating/updating/disposing an object. | Name | Description | | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | | [useSingleTickerProvider](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useSingleTickerProvider.html) | Creates a single usage `TickerProvider`. | +| [useMultiTickerProvider](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useMultiTickerProvider.html) | Creates a `TickerProvider` that supports creating multiple `Ticker`s. | | [useAnimationController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAnimationController.html) | Creates an `AnimationController` which will be automatically disposed. | | [useAnimation](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAnimation.html) | Subscribes to an `Animation` and returns its value. | diff --git a/packages/flutter_hooks/resources/translations/ja_jp/README.md b/packages/flutter_hooks/resources/translations/ja_jp/README.md index 98e015ef..f05f58c0 100644 --- a/packages/flutter_hooks/resources/translations/ja_jp/README.md +++ b/packages/flutter_hooks/resources/translations/ja_jp/README.md @@ -304,6 +304,7 @@ Flutter_Hooksには、再利用可能なフックのリストが既に含まれ | 名前 | 説明 | | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | | [useSingleTickerProvider](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useSingleTickerProvider.html) | 単一使用の`TickerProvider`を作成します。 | +| [useMultiTickerProvider](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useMultiTickerProvider.html) | 複数の`Ticker`を作成できる`TickerProvider`を作成します。 | | [useAnimationController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAnimationController.html) | 自動的に破棄される`AnimationController`を作成します。 | | [useAnimation](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAnimation.html) | `Animation`を購読し、その値を返します。 | diff --git a/packages/flutter_hooks/resources/translations/ko_kr/README.md b/packages/flutter_hooks/resources/translations/ko_kr/README.md index 112dd7e2..4137b120 100644 --- a/packages/flutter_hooks/resources/translations/ko_kr/README.md +++ b/packages/flutter_hooks/resources/translations/ko_kr/README.md @@ -302,6 +302,7 @@ Flutter_Hooks 는 이미 재사용 가능한 훅 목록을 제공합니다. 이 | Name | Description | | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | [useSingleTickerProvider](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useSingleTickerProvider.html) | `TickerProvider`를 생성합니다. | +| [useMultiTickerProvider](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useMultiTickerProvider.html) | 여러 `Ticker`를 생성할 수 있는 `TickerProvider`를 생성합니다. | | [useAnimationController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useAnimationController.html) | 자동으로 dispose 되는 `AnimationController`를 생성합니다. | | [useAnimation](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useAnimation.html) | `Animation` 를 구독합니다. 해당 객체의 value를 반환합니다. | diff --git a/packages/flutter_hooks/resources/translations/pt_br/README.md b/packages/flutter_hooks/resources/translations/pt_br/README.md index a804cdee..6d169a4c 100644 --- a/packages/flutter_hooks/resources/translations/pt_br/README.md +++ b/packages/flutter_hooks/resources/translations/pt_br/README.md @@ -322,6 +322,7 @@ Eles serão responsáveis por criar/atualizar/descartar o objeto. | nome | descrição | | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | [useSingleTickerProvider](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useSingleTickerProvider.html) | Cria um único `TickerProvider`. | +| [useMultiTickerProvider](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useMultiTickerProvider.html) | Cria um `TickerProvider` que suporta criar múltiplos `Ticker`s. | | [useAnimationController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useAnimationController.html) | Cria um `AnimationController` automaticamente descartado. | | [useAnimation](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useAnimation.html) | Inscreve um uma `Animation` e retorna seu valor. | diff --git a/packages/flutter_hooks/resources/translations/zh_cn/README.md b/packages/flutter_hooks/resources/translations/zh_cn/README.md index a46698d5..2e2d15a6 100644 --- a/packages/flutter_hooks/resources/translations/zh_cn/README.md +++ b/packages/flutter_hooks/resources/translations/zh_cn/README.md @@ -313,6 +313,7 @@ Flutter_Hooks 已经包含一些不同类别的可复用的钩子: | 名称 | 描述 | | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | | [useSingleTickerProvider](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useSingleTickerProvider.html) | 创建单次使用的 `TickerProvider` | +| [useMultiTickerProvider](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useMultiTickerProvider.html) | 创建支持多个 `Ticker` 的 `TickerProvider` | | [useAnimationController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAnimationController.html) | 创建并会自动释放的 `AnimationController` | | [useAnimation](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAnimation.html) | 订阅一个 `Animation` 并返回其当前值 | From 466e14f953e6f27c9cc140a6e9a769c92ab01e3c Mon Sep 17 00:00:00 2001 From: Vice Phenek Date: Tue, 2 Dec 2025 01:20:15 +0100 Subject: [PATCH 3/4] - fix tickers with TickerMode disabled should be muted --- packages/flutter_hooks/lib/src/animation.dart | 2 +- .../test/use_multi_ticker_provider_test.dart | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/flutter_hooks/lib/src/animation.dart b/packages/flutter_hooks/lib/src/animation.dart index 35465c6a..192e133f 100644 --- a/packages/flutter_hooks/lib/src/animation.dart +++ b/packages/flutter_hooks/lib/src/animation.dart @@ -267,8 +267,8 @@ class _MultiTickerProviderHookState Ticker createTicker(TickerCallback onTick) { final ticker = Ticker(onTick, debugLabel: 'created by $context (multi)'); _updateTickerModeNotifier(); - _updateTickers(); _tickers.add(ticker); + _updateTickers(); return ticker; } diff --git a/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart b/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart index 3ad693f7..761e8533 100644 --- a/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart +++ b/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart @@ -103,6 +103,32 @@ void main() { } }); + testWidgets('new tickers created when TickerMode disabled are muted', (tester) async { + late TickerProvider provider; + + await tester.pumpWidget(TickerMode( + enabled: false, + child: HookBuilder(builder: (context) { + provider = useMultiTickerProvider(); + return Container(); + }), + )); + + final animationController = AnimationController( + vsync: provider, + duration: const Duration(seconds: 1), + ); + + // Attempt to start the animation — when the underlying ticker is muted + // (because TickerMode is disabled), the controller should not advance. + unawaited(animationController.forward()); + await tester.pump(const Duration(milliseconds: 100)); + + expect(animationController.value, equals(0.0)); + + animationController.dispose(); + }); + testWidgets('useMultiTickerProvider pass down keys', (tester) async { late TickerProvider provider; List? keys; From 059cfe6180c8db370ceff4e03e107ac1af865c89 Mon Sep 17 00:00:00 2001 From: Vice Phenek Date: Tue, 2 Dec 2025 01:31:36 +0100 Subject: [PATCH 4/4] - review on useMultiTickerProvider basic test --- packages/flutter_hooks/lib/src/animation.dart | 2 +- .../test/use_multi_ticker_provider_test.dart | 13 +++---------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/flutter_hooks/lib/src/animation.dart b/packages/flutter_hooks/lib/src/animation.dart index 192e133f..141023ae 100644 --- a/packages/flutter_hooks/lib/src/animation.dart +++ b/packages/flutter_hooks/lib/src/animation.dart @@ -241,7 +241,7 @@ class _TickerProviderHookState /// Creates a multi-usage [TickerProvider]. /// /// See also: -/// * [SingleTickerProviderStateMixin] +/// * [TickerProviderStateMixin] TickerProvider useMultiTickerProvider({List? keys}) { return use( keys != null diff --git a/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart b/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart index 761e8533..c3bd089c 100644 --- a/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart +++ b/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart @@ -20,9 +20,7 @@ void main() { final element = tester.element(find.byType(HookBuilder)); expect( - element - .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) - .toStringDeep(), + element.toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage).toStringDeep(), equalsIgnoringHashCodes( 'HookBuilder\n' ' │ useMultiTickerProvider\n' @@ -46,6 +44,7 @@ void main() { vsync: provider, duration: const Duration(seconds: 1), ); + // With a multi provider, creating additional AnimationControllers is allowed. final animationControllerB = AnimationController( vsync: provider, duration: const Duration(seconds: 1), @@ -54,12 +53,6 @@ void main() { unawaited(animationControllerA.forward()); unawaited(animationControllerB.forward()); - // With a multi provider, creating additional AnimationControllers is allowed. - expect( - () => AnimationController(vsync: provider, duration: const Duration(seconds: 1)), - returnsNormally, - ); - animationControllerA.dispose(); animationControllerB.dispose(); @@ -148,4 +141,4 @@ void main() { expect(previousProvider, isNot(provider)); }); -} \ No newline at end of file +}