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

Add useOnListenableChange #438

Merged
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ They will take care of creating/updating/disposing an object.
| [useListenableSelector](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useListenableSelector.html) | Similar to `useListenable`, but allows filtering UI rebuilds |
| [useValueNotifier](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueNotifier.html) | Creates a `ValueNotifier` which will be automatically disposed. |
| [useValueListenable](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueListenable.html) | Subscribes to a `ValueListenable` and return its value. |
| [useOnListenableChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnListenableChange.html) | Adds a given listener callback to a `Listenable` which will be automatically removed. |

#### Misc hooks:

Expand Down
3 changes: 3 additions & 0 deletions packages/flutter_hooks/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## Unreleased patch
- Added `useOnListenableChange` (thanks to @whynotmake-it)

## 0.21.1-pre.2 - 2024-07-22

- Added `onAttach` and `onDetach` to `useScrollController` and `usePageController` (thanks to @whynotmake-it)
Expand Down
68 changes: 68 additions & 0 deletions packages/flutter_hooks/lib/src/listenable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,71 @@ class _UseValueNotifierHookState<T>
@override
String get debugLabel => 'useValueNotifier';
}

/// Adds a given [listener] to a [Listenable] and removes it when the hook is
/// disposed.
///
/// As opposed to `useListenable`, this hook does not mark the widget as needing
/// build when the listener is called. Use this for side effects that do not
/// require a rebuild.
///
/// See also:
/// * [Listenable]
/// * [ValueListenable]
/// * [useListenable]
void useOnListenableChange(
Listenable? listenable,
VoidCallback listener,
) {
return use(_OnListenableChangeHook(listenable, listener));
}

class _OnListenableChangeHook extends Hook<void> {
const _OnListenableChangeHook(
this.listenable,
this.listener,
);

final Listenable? listenable;
final VoidCallback listener;

@override
_OnListenableChangeHookState createState() => _OnListenableChangeHookState();
}

class _OnListenableChangeHookState
extends HookState<void, _OnListenableChangeHook> {
@override
void initHook() {
super.initHook();
hook.listenable?.addListener(_listener);
}

@override
void didUpdateHook(_OnListenableChangeHook oldHook) {
super.didUpdateHook(oldHook);
if (hook.listenable != oldHook.listenable) {
oldHook.listenable?.removeListener(_listener);
hook.listenable?.addListener(_listener);
}
}

@override
void build(BuildContext context) {}

@override
void dispose() {
hook.listenable?.removeListener(_listener);
}

/// Wraps `hook.listener` so we have a non-changing reference to it.
void _listener() {
hook.listener();
}

@override
String get debugLabel => 'useOnListenableChange';

@override
Object? get debugValue => hook.listenable;
}
165 changes: 165 additions & 0 deletions packages/flutter_hooks/test/use_on_listenable_change_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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 {
timcreatedit marked this conversation as resolved.
Show resolved Hide resolved
final listenable = ValueNotifier(42);

await tester.pumpWidget(
HookBuilder(builder: (context) {
useOnListenableChange(listenable, () {});
return const SizedBox();
}),
);

await tester.pump();

final element = tester.element(find.byType(HookBuilder));

expect(
element
.toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage)
.toStringDeep(),
equalsIgnoringHashCodes(
'HookBuilder\n'
' │ useOnListenableChange: ValueNotifier<int>#00000(42)\n'
' └SizedBox(renderObject: RenderConstrainedBox#00000)\n',
),
);
});

testWidgets('calls listener when Listenable updates', (tester) async {
final listenable = ValueNotifier(42);

int? value;

await tester.pumpWidget(
HookBuilder(builder: (context) {
useOnListenableChange(
listenable,
() => value = listenable.value,
);
return const SizedBox();
}),
);

expect(value, isNull);
listenable.value++;
expect(value, 43);
});

testWidgets(
'listens new Listenable when Listenable is changed',
(tester) async {
final listenable1 = ValueNotifier(42);
final listenable2 = ValueNotifier(42);

await tester.pumpWidget(
HookBuilder(
builder: (context) {
useOnListenableChange(listenable1, () {});
return const SizedBox();
},
),
);

await tester.pumpWidget(
HookBuilder(
builder: (context) {
useOnListenableChange(listenable2, () {});
return const SizedBox();
},
),
);

// ignore: invalid_use_of_protected_member
expect(listenable1.hasListeners, isFalse);
// ignore: invalid_use_of_protected_member
expect(listenable2.hasListeners, isTrue);
},
);

testWidgets(
'listens new listener when listener is changed',
(tester) async {
final listenable = ValueNotifier(42);
late final int value;

void listener1() {
throw StateError('listener1 should not have been called');
}

void listener2() {
value = listenable.value;
}

await tester.pumpWidget(
HookBuilder(
builder: (context) {
useOnListenableChange(listenable, listener1);
return const SizedBox();
},
),
);

await tester.pumpWidget(
HookBuilder(
builder: (context) {
useOnListenableChange(listenable, listener2);
return const SizedBox();
},
),
);

listenable.value++;
// By now, we should have subscribed to listener2, which sets the value
expect(value, 43);
},
);

testWidgets('unsubscribes when listenable becomes null', (tester) async {
final listenable = ValueNotifier(42);

await tester.pumpWidget(
HookBuilder(builder: (context) {
useOnListenableChange(listenable, () {});
return const SizedBox();
}),
);

// ignore: invalid_use_of_protected_member
expect(listenable.hasListeners, isTrue);

await tester.pumpWidget(
HookBuilder(builder: (context) {
useOnListenableChange(null, () {});
return const SizedBox();
}),
);

// ignore: invalid_use_of_protected_member
expect(listenable.hasListeners, isFalse);
});

testWidgets('unsubscribes when disposed', (tester) async {
final listenable = ValueNotifier(42);

await tester.pumpWidget(
HookBuilder(builder: (context) {
useOnListenableChange(listenable, () {});
return const SizedBox();
}),
);

// ignore: invalid_use_of_protected_member
expect(listenable.hasListeners, isTrue);

await tester.pumpWidget(Container());

// ignore: invalid_use_of_protected_member
expect(listenable.hasListeners, isFalse);
});
}
Loading