flutter_hooks
flutter_hooks copied to clipboard
A ticker created by `useSingleTickerProvider` is not muted properly
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,
- Verify that Screen A is rebuilt when you press the button on Screen A.
- Verify that Screen A is rebuilt when you press the back button on Screen B
Code example
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
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
/// Creates a single usage [TickerProvider].
///
/// See also:
/// * [SingleTickerProviderStateMixin]
TickerProvider useSingleTickerProvider({List<Object?>? keys}) {
return use(
keys != null
? _SingleTickerProviderHook(keys)
: const _SingleTickerProviderHook(),
);
}
class _SingleTickerProviderHook extends Hook<TickerProvider> {
const _SingleTickerProviderHook([List<Object?>? keys]) : super(keys: keys);
@override
_TickerProviderHookState createState() => _TickerProviderHookState();
}
class _TickerProviderHookState
extends HookState<TickerProvider, _SingleTickerProviderHook>
implements TickerProvider {
Ticker? _ticker;
ValueListenable<bool>? _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<bool> 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
- https://github.com/flutter/flutter/pull/93166
Thank you.