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/lib/src/animation.dart b/packages/flutter_hooks/lib/src/animation.dart index 86c0c8ce..141023ae 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: +/// * [TickerProviderStateMixin] +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(); + _tickers.add(ticker); + _updateTickers(); + 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/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` 并返回其当前值 | 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..c3bd089c --- /dev/null +++ b/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart @@ -0,0 +1,144 @@ +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), + ); + // With a multi provider, creating additional AnimationControllers is allowed. + final animationControllerB = AnimationController( + vsync: provider, + duration: const Duration(seconds: 1), + ); + + unawaited(animationControllerA.forward()); + unawaited(animationControllerB.forward()); + + 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('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; + + 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)); + }); +}