rrousselGit / flutter_hooks

React hooks for Flutter. Hooks are a new kind of object that manages a Widget life-cycles. They are used to increase code sharing between widgets and as a complete replacement for StatefulWidget.
MIT License
3.06k stars 175 forks source link

A ticker created by `useSingleTickerProvider` is not muted properly #431

Open dev-tatsuya opened 3 weeks ago

dev-tatsuya commented 3 weeks ago

Describe the bug Suppose you have Screen A using useSingleTickerProvider. Screen A is rebuilt when you push from Screen A to Screen B and when you pop from Screen B to Screen A.

To Reproduce

With the code below,

  1. Verify that Screen A is rebuilt when you press the button on Screen A.
  2. Verify that Screen A is rebuilt when you press the back button on Screen B
Code example

```dart import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; void main() { runApp(const MaterialApp(home: AScreen())); } class AScreen extends HookWidget { const AScreen({super.key}); @override Widget build(BuildContext context) { print('Screen A: build'); final ticker = useSingleTickerProvider(); final controller = useAnimationController(vsync: ticker); return Scaffold( appBar: AppBar(title: const Text('Screen A')), body: Center( child: FilledButton( onPressed: () { Navigator.of(context).push( MaterialPageRoute(builder: (context) => const BScreen()), ); }, child: const Text('Go to Screen B'), ).animate(controller: controller).shake(), ), ); } } class BScreen extends StatelessWidget { const BScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold(appBar: AppBar(title: const Text('Screen B'))); } } ```

Expected behavior It is expected that a push to Screen B and a pop from Screen B will not rebuild Screen A.

My thoughts When I didn't prepare the AnimationController myself and managed it inside Animate Widget as shown below, it worked as expected.

use ticker created by SingleTickerProviderStateMixin

```dart import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; void main() { runApp(const MaterialApp(home: AScreen())); } class AScreen extends HookWidget { const AScreen({super.key}); @override Widget build(BuildContext context) { print('Screen A: build'); return Scaffold( appBar: AppBar(title: const Text('Screen A')), body: Center( child: FilledButton( onPressed: () { Navigator.of(context).push( MaterialPageRoute(builder: (context) => const BScreen()), ); }, child: const Text('Go to Screen B'), ).animate().shake(), ), ); } } class BScreen extends StatelessWidget { const BScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold(appBar: AppBar(title: const Text('Screen B'))); } } ```

In other words, there seemed to be a difference in the implementation of useSingleTickerProvider and SingleTickerProviderStateMixin. As a test, I changed useSingleTickerProvider based on the implementation of SingleTickerProviderStateMixin as shown below, and it worked as expected.

changed useSingleTickerProvider

```dart /// Creates a single usage [TickerProvider]. /// /// See also: /// * [SingleTickerProviderStateMixin] TickerProvider useSingleTickerProvider({List? keys}) { return use( keys != null ? _SingleTickerProviderHook(keys) : const _SingleTickerProviderHook(), ); } class _SingleTickerProviderHook extends Hook { const _SingleTickerProviderHook([List? keys]) : super(keys: keys); @override _TickerProviderHookState createState() => _TickerProviderHookState(); } class _TickerProviderHookState extends HookState implements TickerProvider { Ticker? _ticker; ValueListenable? _tickerModeNotifier; @override Ticker createTicker(TickerCallback onTick) { assert(() { if (_ticker == null) { return true; } throw FlutterError( '${context.widget.runtimeType} attempted to use a useSingleTickerProvider multiple times.\n' 'A SingleTickerProviderStateMixin can only be used as a TickerProvider once. ' 'If you need multiple Ticker, consider using useSingleTickerProvider multiple times ' 'to create as many Tickers as needed.'); }(), ''); _ticker = Ticker(onTick, debugLabel: 'created by $context'); _updateTickerModeNotifier(); _updateTicker(); // Sets _ticker.mute correctly. return _ticker!; } void _updateTicker() { if (_ticker != null) { _ticker!.muted = !_tickerModeNotifier!.value; } } void _updateTickerModeNotifier() { final ValueListenable newNotifier = TickerMode.getNotifier(context); if (newNotifier == _tickerModeNotifier) { return; } _tickerModeNotifier?.removeListener(_updateTicker); newNotifier.addListener(_updateTicker); _tickerModeNotifier = newNotifier; } @override void dispose() { assert(() { if (_ticker == null || !_ticker!.isActive) { return true; } throw FlutterError( 'useSingleTickerProvider created a Ticker, but at the time ' 'dispose() was called on the Hook, that Ticker was still active. Tickers used ' ' by AnimationControllers should be disposed by calling dispose() on ' ' the AnimationController itself. Otherwise, the ticker will leak.\n'); }(), ''); _tickerModeNotifier?.removeListener(_updateTicker); _tickerModeNotifier = null; super.dispose(); } @override TickerProvider build(BuildContext context) { _updateTickerModeNotifier(); _updateTicker(); return this; } @override String get debugLabel => 'useSingleTickerProvider'; @override bool get debugSkipValue => true; } ```

reference

Thank you.