Skip to content
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

A ticker created by useSingleTickerProvider is not muted properly #431

Closed
dev-tatsuya opened this issue Jun 10, 2024 · 0 comments · Fixed by #432
Closed

A ticker created by useSingleTickerProvider is not muted properly #431

dev-tatsuya opened this issue Jun 10, 2024 · 0 comments · Fixed by #432
Assignees
Labels
bug Something isn't working

Comments

@dev-tatsuya
Copy link
Contributor

dev-tatsuya commented Jun 10, 2024

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

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

Thank you.

@dev-tatsuya dev-tatsuya added bug Something isn't working needs triage labels Jun 10, 2024
@dev-tatsuya dev-tatsuya changed the title A ticker created by 'useSingleTickerProvider' is not muted properly. A ticker created by useSingleTickerProvider is not muted properly. Jun 10, 2024
@dev-tatsuya dev-tatsuya changed the title A ticker created by useSingleTickerProvider is not muted properly. A ticker created by useSingleTickerProvider is not muted properly Jun 10, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants