diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..63c29411 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: rrousselGit diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..20065d84 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,18 @@ +--- +name: Bug report +about: There is a problem in how provider behaves +title: "" +labels: bug, needs triage +assignees: + - rrousselGit +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** + + + +**Expected behavior** +A clear and concise description of what you expected to happen. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..e5779122 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: I have a problem and I need help + url: https://github.com/rrousselGit/flutter_hooks/discussions + about: Please ask and answer questions here. diff --git a/.github/ISSUE_TEMPLATE/example_request.md b/.github/ISSUE_TEMPLATE/example_request.md new file mode 100644 index 00000000..332337ba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/example_request.md @@ -0,0 +1,20 @@ +--- +name: Documentation improvement request +about: >- + Suggest a new example/documentation or ask for clarification about an + existing one. +title: "" +labels: documentation, needs triage +assignees: + - rrousselGit +--- + +**Describe what scenario you think is uncovered by the existing examples/articles** +A clear and concise description of the problem that you want explained. + +**Describe why existing examples/articles do not cover this case** +Explain which examples/articles you have seen before making this request, and +why they did not help you with your problem. + +**Additional context** +Add any other context or screenshots about the documentation request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..65c5ae35 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "" +labels: enhancement, needs triage +assignees: + - rrousselGit +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 00000000..39bd9ac1 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,7 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..8cfe46a4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,50 @@ +name: Build + +on: + push: + pull_request: + schedule: + # runs the CI everyday at 10AM + - cron: "0 10 * * *" + +jobs: + flutter: + runs-on: ubuntu-latest + + strategy: + matrix: + package: + - flutter_hooks + channel: + - master + - stable + fail-fast: false + + steps: + - uses: actions/checkout@v2 + - uses: subosito/flutter-action@v1 + with: + channel: ${{ matrix.channel }} + + - name: Install dependencies + run: flutter pub get + working-directory: packages/${{ matrix.package }} + + - name: Check format + run: dart format --set-exit-if-changed . + if: matrix.channel == 'stable' + working-directory: packages/${{ matrix.package }} + + - name: Analyze + run: dart analyze . + if: matrix.channel == 'stable' + working-directory: packages/${{ matrix.package }} + + - name: Run tests + run: flutter test --coverage + working-directory: packages/${{ matrix.package }} + + - name: Upload coverage to codecov + run: curl -s https://codecov.io/bash | bash + if: matrix.channel == 'stable' + working-directory: packages/${{ matrix.package }} diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml new file mode 100644 index 00000000..d8b8abf1 --- /dev/null +++ b/.github/workflows/project.yml @@ -0,0 +1,17 @@ +name: Add new issues to project + +on: + issues: + types: + - opened + - reopened + +jobs: + add-to-project: + name: Add issue to project + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@v0.5.0 + with: + project-url: https://github.com/users/rrousselGit/projects/8 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 9ac9d889..00000000 --- a/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -android/ -ios/ -.packages \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 29933add..00000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: bash -os: - - osx -sudo: false -before_script: - - cd .. - - git clone https://github.com/flutter/flutter.git -b beta - - export PATH=$PATH:$PWD/flutter/bin - - export PATH=$PATH:$PWD/flutter/bin/cache/dart-sdk/bin - - flutter doctor - - cd - -script: - # abort on error - - set -e - - flutter packages get - - flutter format --set-exit-if-changed lib example - - flutter analyze --no-current-package lib example - - flutter test --no-pub - # export coverage - - bash <(curl -s https://codecov.io/bash) -cache: - directories: - - $HOME/.pub-cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..cdeef1d5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Remi Rousselet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..24d2774a --- /dev/null +++ b/README.md @@ -0,0 +1,399 @@ +[English](https://github.com/rrousselGit/flutter_hooks/blob/master/README.md) | [Português](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/pt_br/README.md) | [한국어](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/ko_kr/README.md) | [简体中文](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/zh_cn/README.md) | [日本語](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/ja_jp/README.md) + +[![Build](https://github.com/rrousselGit/flutter_hooks/workflows/Build/badge.svg)](https://github.com/rrousselGit/flutter_hooks/actions?query=workflow%3ABuild) [![codecov](https://codecov.io/gh/rrousselGit/flutter_hooks/branch/master/graph/badge.svg)](https://codecov.io/gh/rrousselGit/flutter_hooks) [![pub package](https://img.shields.io/pub/v/flutter_hooks.svg)](https://pub.dev/packages/flutter_hooks) [![pub package](https://img.shields.io/badge/Awesome-Flutter-blue.svg?longCache=true&style=flat-square)](https://github.com/Solido/awesome-flutter) +Discord + + + +# Flutter Hooks + +A Flutter implementation of React hooks: https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889 + +Hooks are a new kind of object that manage the life-cycle of a `Widget`. They exist +for one reason: increase the code-sharing _between_ widgets by removing duplicates. + +## Motivation + +`StatefulWidget` suffers from a big problem: it is very difficult to reuse the +logic of say `initState` or `dispose`. An obvious example is `AnimationController`: + +```dart +class Example extends StatefulWidget { + const Example({super.key, required this.duration}); + + final Duration duration; + + @override + _ExampleState createState() => _ExampleState(); +} + +class _ExampleState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: widget.duration); + } + + @override + void didUpdateWidget(Example oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.duration != oldWidget.duration) { + _controller.duration = widget.duration; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container(); + } +} +``` + +All widgets that desire to use an `AnimationController` will have to reimplement +almost all of this logic from scratch, which is of course undesired. + +Dart mixins can partially solve this issue, but they suffer from other problems: + +- A given mixin can only be used once per class. +- Mixins and the class share the same object.\ + This means that if two mixins define a variable under the same name, the result + may vary between compilation fails to unknown behavior. + +--- + +This library proposes a third solution: + +```dart +class Example extends HookWidget { + const Example({super.key, required this.duration}); + + final Duration duration; + + @override + Widget build(BuildContext context) { + final controller = useAnimationController(duration: duration); + return Container(); + } +} +``` + +This code is functionally equivalent to the previous example. It still disposes the +`AnimationController` and still updates its `duration` when `Example.duration` changes. +But you're probably thinking: + +> Where did all the logic go? + +That logic has been moved into `useAnimationController`, a function included directly in +this library (see [Existing hooks](https://github.com/rrousselGit/flutter_hooks#existing-hooks)) - It is what we call a _Hook_. + +Hooks are a new kind of object with some specificities: + +- They can only be used in the `build` method of a widget that mix-in `Hooks`. +- The same hook can be reused arbitrarily many times. + The following code defines two independent `AnimationController`, and they are + correctly preserved when the widget rebuild. + + ```dart + Widget build(BuildContext context) { + final controller = useAnimationController(); + final controller2 = useAnimationController(); + return Container(); + } + ``` + +- Hooks are entirely independent of each other and from the widget.\ + This means that they can easily be extracted into a package and published on + [pub](https://pub.dev/) for others to use. + +## Principle + +Similar to `State`, hooks are stored in the `Element` of a `Widget`. However, instead +of having one `State`, the `Element` stores a `List`. Then in order to use a `Hook`, +one must call `Hook.use`. + +The hook returned by `use` is based on the number of times it has been called. +The first call returns the first hook; the second call returns the second hook, +the third call returns the third hook and so on. + +If this idea is still unclear, a naive implementation of hooks could look as follows: + +```dart +class HookElement extends Element { + List _hooks; + int _hookIndex; + + T use(Hook hook) => _hooks[_hookIndex++].build(this); + + @override + performRebuild() { + _hookIndex = 0; + super.performRebuild(); + } +} +``` + +For more explanation of how hooks are implemented, here's a great article about +how it was done in React: https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e + +## Rules + +Due to hooks being obtained from their index, some rules must be respected: + +### DO always prefix your hooks with `use`: + +```dart +Widget build(BuildContext context) { + // starts with `use`, good name + useMyHook(); + // doesn't start with `use`, could confuse people into thinking that this isn't a hook + myHook(); + // .... +} +``` + +### DO call hooks unconditionally + +```dart +Widget build(BuildContext context) { + useMyHook(); + // .... +} +``` + +### DON'T wrap `use` into a condition + +```dart +Widget build(BuildContext context) { + if (condition) { + useMyHook(); + } + // .... +} +``` + +--- + +### About hot-reload + +Since hooks are obtained from their index, one may think that hot-reloads while refactoring will break the application. + +But worry not, a `HookWidget` overrides the default hot-reload behavior to work with hooks. Still, there are some situations in which the state of a Hook may be reset. + +Consider the following list of hooks: + +```dart +useA(); +useB(0); +useC(); +``` + +Then consider that we edited the parameter of `HookB` after performing a hot-reload: + +```dart +useA(); +useB(42); +useC(); +``` + +Here everything works fine and all hooks maintain their state. + +Now consider that we removed `HookB`. We now have: + +```dart +useA(); +useC(); +``` + +In this situation, `HookA` maintains its state but `HookC` gets hard reset. +This happens because, when a hot-reload is performed after refactoring, all hooks _after_ the first line impacted are disposed of. +So, since `HookC` was placed _after_ `HookB`, it will be disposed. + +## How to create a hook + +There are two ways to create a hook: + +- A function + + Functions are by far the most common way to write hooks. Thanks to hooks being + composable by nature, a function will be able to combine other hooks to create + a more complex custom hook. By convention, these functions will be prefixed by `use`. + + The following code defines a custom hook that creates a variable and logs its value + to the console whenever the value changes: + + ```dart + ValueNotifier useLoggedState([T initialData]) { + final result = useState(initialData); + useValueChanged(result.value, (_, __) { + print(result.value); + }); + return result; + } + ``` + +- A class + + When a hook becomes too complex, it is possible to convert it into a class that extends `Hook` - which can then be used using `Hook.use`.\ + As a class, the hook will look very similar to a `State` class and have access to widget + life-cycle and methods such as `initHook`, `dispose` and `setState`. + + It is usually good practice to hide the class under a function as such: + + ```dart + Result useMyHook() { + return use(const _TimeAlive()); + } + ``` + + The following code defines a hook that prints the total time a `State` has been alive on its dispose. + + ```dart + class _TimeAlive extends Hook { + const _TimeAlive(); + + @override + _TimeAliveState createState() => _TimeAliveState(); + } + + class _TimeAliveState extends HookState { + DateTime start; + + @override + void initHook() { + super.initHook(); + start = DateTime.now(); + } + + @override + void build(BuildContext context) {} + + @override + void dispose() { + print(DateTime.now().difference(start)); + super.dispose(); + } + } + ``` + +## Existing hooks + +Flutter_Hooks already comes with a list of reusable hooks which are divided into different kinds: + +### Primitives + +A set of low-level hooks that interact with the different life-cycles of a widget + +| Name | Description | +| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| [useEffect](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useEffect.html) | Useful for side-effects and optionally canceling them. | +| [useState](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useState.html) | Creates a variable and subscribes to it. | +| [useMemoized](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useMemoized.html) | Caches the instance of a complex object. | +| [useRef](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useRef.html) | Creates an object that contains a single mutable property. | +| [useCallback](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useCallback.html) | Caches a function instance. | +| [useContext](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useContext.html) | Obtains the `BuildContext` of the building `HookWidget`. | +| [useValueChanged](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueChanged.html) | Watches a value and triggers a callback whenever its value changed. | + +### Object-binding + +This category of hooks the manipulation of existing Flutter/Dart objects with hooks. +They will take care of creating/updating/disposing an object. + +#### dart:async related hooks: + +| Name | Description | +| ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| [useStream](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useStream.html) | Subscribes to a `Stream` and returns its current state as an `AsyncSnapshot`. | +| [useStreamController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useStreamController.html) | Creates a `StreamController` which will automatically be disposed. | +| [useOnStreamChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnStreamChange.html) | Subscribes to a `Stream`, registers handlers, and returns the `StreamSubscription`. | +| [useFuture](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFuture.html) | Subscribes to a `Future` and returns its current state as an `AsyncSnapshot`. | + +#### Animation related hooks: + +| 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. | + +#### Listenable related hooks: + +| Name | Description | +| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| [useListenable](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useListenable.html) | Subscribes to a `Listenable` and marks the widget as needing build whenever the listener is called. | +| [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: + +A series of hooks with no particular theme. + +| Name | Description | +| -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [useReducer](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useReducer.html) | An alternative to `useState` for more complex states. | +| [usePrevious](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/usePrevious.html) | Returns the previous argument called to [usePrevious]. | +| [useTextEditingController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTextEditingController-constant.html) | Creates a `TextEditingController`. | +| [useFocusNode](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFocusNode.html) | Creates a `FocusNode`. | +| [useTabController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTabController.html) | Creates and disposes a `TabController`. | +| [useScrollController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useScrollController.html) | Creates and disposes a `ScrollController`. | +| [usePageController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/usePageController.html) | Creates and disposes a `PageController`. | +| [useFixedExtentScrollController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFixedExtentScrollController.html) | Creates and disposes a `FixedExtentScrollController`. | +| [useAppLifecycleState](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAppLifecycleState.html) | Returns the current `AppLifecycleState` and rebuilds the widget on change. | +| [useOnAppLifecycleStateChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnAppLifecycleStateChange.html) | Listens to `AppLifecycleState` changes and triggers a callback on change. | +| [useTransformationController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTransformationController.html) | Creates and disposes a `TransformationController`. | +| [useIsMounted](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useIsMounted.html) | An equivalent to `State.mounted` for hooks. | +| [useAutomaticKeepAlive](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAutomaticKeepAlive.html) | An equivalent to the `AutomaticKeepAlive` widget for hooks. | +| [useOnPlatformBrightnessChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnPlatformBrightnessChange.html) | Listens to platform `Brightness` changes and triggers a callback on change. | +| [useSearchController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useSearchController.html) | Creates and disposes a `SearchController`. | +| [useWidgetStatesController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useWidgetStatesController.html) | Creates and disposes a `WidgetStatesController`. | +| [useExpansibleController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useExpansibleController.html) | Creates a `ExpansibleController`. | +| [useDebounced](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useDebounced.html) | Returns a debounced version of the provided value, triggering widget updates accordingly after a specified timeout duration | +| [useDraggableScrollableController](https://api.flutter.dev/flutter/widgets/DraggableScrollableController-class.html) | Creates a `DraggableScrollableController`. | +| [useCarouselController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useCarouselController.html) | Creates and disposes a **`CarouselController`**. | +| [useTreeSliverController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTreeSliverController.html) | Creates a `TreeSliverController`. | +| [useOverlayPortalController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOverlayPortalController.html) | Creates and manages an `OverlayPortalController` for controlling the visibility of overlay content. The controller will be automatically disposed when no longer needed. | +| [useSnapshotController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useSnapshotController.html) | Creates and manages a `SnapshotController` | +| [useCupertinoController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useCupertinoController.html) | Creates and manages a `CupertinoController` | + +## Contributions + +Contributions are welcomed! + +If you feel that a hook is missing, feel free to open a pull-request. + +For a custom-hook to be merged, you will need to do the following: + +- Describe the use-case. + + Open an issue explaining why we need this hook, how to use it, ... + This is important as a hook will not get merged if the hook doesn't appeal to + a large number of people. + + If your hook is rejected, don't worry! A rejection doesn't mean that it won't + be merged later in the future if more people show interest in it. + In the mean-time, feel free to publish your hook as a package on https://pub.dev. + +- Write tests for your hook + + A hook will not be merged unless fully tested to avoid inadvertently breaking it + in the future. + +- Add it to the README and write documentation for it. + +## Sponsors + +

+ + + +

diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index 064c6286..00000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,9 +0,0 @@ -linter: - rules: - - cancel_subscriptions - - hash_and_equals - - iterable_contains_unrelated_type - - list_remove_unrelated_type - - test_types_in_equals - - unrelated_type_equality_checks - - valid_regexps diff --git a/example/lib/main.dart b/example/lib/main.dart deleted file mode 100644 index da1497bd..00000000 --- a/example/lib/main.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/hook.dart'; -import 'package:rxdart/rxdart.dart'; - -void main() => runApp(MyApp()); - -class MyApp extends StatelessWidget { - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp(title: 'Flutter Demo', home: Home()); - } -} - -Observable controller = - Observable.periodic(const Duration(seconds: 1), (i) => i); -Observable controller2 = - Observable.periodic(const Duration(seconds: 2), (i) => i); - -class Home extends HookWidget { - @override - Widget build(HookContext context) { - final v1 = context.useStream(controller); - final v2 = context.useStream(controller2); - return Scaffold( - appBar: AppBar(), - body: Column( - children: [ - Text(v1.data?.toString() ?? "0"), - Text(v2.data?.toString() ?? "0"), - ], - ), - ); - } -} diff --git a/example/pubspec.lock b/example/pubspec.lock deleted file mode 100644 index 01f7f08e..00000000 --- a/example/pubspec.lock +++ /dev/null @@ -1,146 +0,0 @@ -# Generated by pub -# See https://www.dartlang.org/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.8" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.4" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.2" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.14.11" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_hooks: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "0.0.0+1" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.3+1" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.6" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.2" - quiver: - dependency: transitive - description: - name: quiver - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - rxdart: - dependency: "direct main" - description: - name: rxdart - url: "https://pub.dartlang.org" - source: hosted - version: "0.19.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.4.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.9.3" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.8" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.4" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.1" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.6" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.8" -sdks: - dart: ">=2.0.0 <3.0.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml deleted file mode 100644 index 3d98afce..00000000 --- a/example/pubspec.yaml +++ /dev/null @@ -1,24 +0,0 @@ -name: example -description: A new Flutter project. - -version: 1.0.0+1 - -environment: - sdk: ">=2.0.0-dev.68.0 <3.0.0" - -dependencies: - flutter: - sdk: flutter - rxdart: 0.19.0 - flutter_hooks: 0.0.1 - -dev_dependencies: - flutter_test: - sdk: flutter - -dependency_overrides: - flutter_hooks: - path: ../ - -flutter: - uses-material-design: true diff --git a/lib/hook.dart b/lib/hook.dart deleted file mode 100644 index 5481e9bf..00000000 --- a/lib/hook.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; - -@immutable -abstract class Hook { - HookState createHookState(); -} - -class HookState { - Element _element; - BuildContext get context => _element; - T _hook; - T get hook => _hook; - void initHook() {} - void dispose() {} - void didUpdateWidget(covariant Widget widget) {} - void didUpdateHook(covariant Hook hook) {} - void setState(VoidCallback callback) { - callback(); - _element.markNeedsBuild(); - } -} - -class _StreamHook extends Hook { - final Stream stream; - final T initialData; - _StreamHook({this.stream, this.initialData}); - @override - HookState createHookState() => _StreamHookState(); -} - -class _StreamHookState extends HookState<_StreamHook> { - StreamSubscription subscription; - AsyncSnapshot snapshot; - @override - void initHook() { - super.initHook(); - snapshot = hook.stream == null - ? AsyncSnapshot.nothing() - : AsyncSnapshot.withData(ConnectionState.waiting, hook.initialData); - subscription = hook.stream.listen(onData, onDone: onDone, onError: onError); - } - - void onData(T event) { - print('on data $event'); - setState(() { - snapshot = AsyncSnapshot.withData(ConnectionState.active, event); - }); - } - - void onDone() { - setState(() { - snapshot = AsyncSnapshot.withData(ConnectionState.done, snapshot.data); - }); - } - - void onError(Object error) { - setState(() { - snapshot = AsyncSnapshot.withError(ConnectionState.active, error); - }); - } - - @override - void dispose() { - subscription?.cancel(); - super.dispose(); - } -} - -class HookElement extends StatelessElement implements HookContext { - int _hooksIndex; - List _hooks; - - HookElement(HookWidget widget) : super(widget); - - @override - HookWidget get widget => super.widget; - - HookState useHook(Hook hook) { - final int hooksIndex = _hooksIndex; - _hooksIndex++; - _hooks ??= []; - - HookState state; - if (hooksIndex >= _hooks.length) { - state = hook.createHookState() - .._element = this - .._hook = hook - ..initHook(); - _hooks.add(state); - } else { - state = _hooks[hooksIndex]; - if (!identical(state._hook, hook)) { - final Hook previousHook = state._hook; - state._hook = hook; - state.didUpdateHook(previousHook); - } - } - return state; - } - - AsyncSnapshot useStream(Stream stream, {T initialData}) { - final _StreamHookState state = - useHook(_StreamHook(stream: stream, initialData: initialData)); - return state.snapshot; - } - - @override - void performRebuild() { - _hooksIndex = 0; - super.performRebuild(); - } -} - -abstract class HookWidget extends StatelessWidget { - @override - HookElement createElement() => HookElement(this); - - @protected - Widget build(covariant HookContext context); -} - -abstract class HookContext extends BuildContext { - HookState useHook(Hook hook); - AsyncSnapshot useStream(Stream stream, {T initialData}); -} diff --git a/packages/flutter_hooks/.gitignore b/packages/flutter_hooks/.gitignore new file mode 100644 index 00000000..d85365b2 --- /dev/null +++ b/packages/flutter_hooks/.gitignore @@ -0,0 +1,15 @@ +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.packages +.pub-cache/ +.pub/ +build/ +android/ +ios/ +/coverage +pubspec.lock +.vscode/ +.fvm +.idea/ diff --git a/packages/flutter_hooks/CHANGELOG.md b/packages/flutter_hooks/CHANGELOG.md new file mode 100644 index 00000000..bf33d63a --- /dev/null +++ b/packages/flutter_hooks/CHANGELOG.md @@ -0,0 +1,343 @@ +## 0.21.3+1 - 2025-08-19 + +Updated Discord link + +## 0.21.3 - 2025-08-12 + +- Deprecated `useExpansionTileController` in favor of `useExpansibleController`. +- Added `useCupertinoTabController` thanks to @Erengun +- Added `useSnapshotController` thanks to @benthillerkus +- Added `descendantsAreTraversable` to `useFocusNode` thanks to @gabber235 + +## 0.21.2 - 2025-02-23 + +- Add `useCarouselController` (thanks to @riscait) +- Add `useTreeSliverController` (thanks to @benthillerkus) +- Added `useOverlayPortalController` (thanks to @offich) +- Add animationDuration property to useTabController hook (thanks to @d-polikhranidi) + +## 0.21.1-pre.4 - 2024-08-01 + +- Added `useFixedExtentScrollController` (thanks to @whynotmake-it) + +## 0.21.1-pre.3 - 2024-07-30 + +- 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) + +## 0.21.1-pre.1 - 2024-07-08 + +- Added `useDraggableScrollableController` (thanks to @dickermoshe) +- Fix TickerMode not getting muted by hooks (thanks to @dev-tatsuya) + +## 0.21.1-pre.0 + +- Bump minimum Flutter SDK to 3.21.0-13.0.pre.4 + +## 0.21.0 - 2024-03-22 (retracted) + +- Renamed `useMaterialStatesController` to `useWidgetStatesController` to follow the rename in Flutter. + +## 0.20.5 - 2024-02-05 + +- Deprecate the `useIsMounted` hook as you should use `BuildContext.mounted` instead if you're on Flutter 3.7.0 or greater + +## 0.20.4 - 2023-12-29 + +- Added `useDebounced` (thanks to @itisnajim) +- Removed `@visibleForTesting` on `HookMixin` + +## 0.20.3 - 2023-10-10 + +- Added `useExpansionTileController` (thanks to @droidbg) +- Added `useMaterialStatesController` (thanks to @AdamHavlicek) + +## 0.20.2 - 2023-10-02 + +- Fixed key matching algorithm incorrectly not checking all keys if a 0 or a NaN is present (thanks to @AlexDochioiu) + +## 0.20.1 - 2023-08-29 + +- `useListenableSelector` now supports `null` listeners (thanks to @JWo1F) + +## 0.20.0 - 2023-07-25 + +- **Breaking**: `keys` comparison has changed under the following conditions: + - Change from `double.nan` to `double.nan` preserves the state. + - Change from `0.0` to `-0.0` or vice versa does NOT preserve the state. +- Added `useOnStreamChange` (thanks to @jezsung) + +## 0.19.0 - 2023-07-10 + +- Added `useSearchController` (thanks to @snapsl) +- Keys on `useCallback` are now optional, to match `useMemoized` (thanks to @jezsung) +- Update `usePlatformBrightness` to stop relying on deprecated code + +## 0.18.6 + +- Added korean translation (thanks to @sejun2) +- Add `useFocusScopeNode` hook (thanks to @iamsahilsonawane) + +## 0.18.5+1 + +Update links to the repository + +## 0.18.5 + +- Added `useListenableSelector`, similar to `useListenable`, for listening to a + `Listenable` but rebuilding the widget only if a certain data has changed (thanks to @ronnieeeeee) +- Added `useAutomaticKeepAlive` (thanks to @DevNico) +- Added `usePlatformBrightness` and `useOnPlatformBrightnessChange` to interact with platform `Brightness` (thanks to @AhmedLSayed9) + +## 0.18.4 + +Upgrade to Flutter 3.0.0 + +## 0.18.3 + +Added `onKeyEvent` to `useFocusNode` (thanks to @kdelorey) + +## 0.18.2+1 + +Improved the documentation (thanks to @Renni771) + +## 0.18.2 + +- Allow null in `useListenable` + +## 0.18.1 + +- Added `useTransformationController`, to create a `TransformationController` (thanks to @shrouxm) +- Added `useAppLifecycleState` and `useOnAppLifecycleStateChange` to interact with `AppLifecycleState` (thanks to @francipvb) + +## 0.18.0 + +- **Breaking**: `useRef` now receive an initial value as parameter. + To migrate, you can change: + + ```dart + ObjectRef ref = useRef(); + ``` + + to: + + ```dart + ObjectRef ref = useRef(null); + ``` + +- Updated `useStream`/`useFuture` to match the behavior of `StreamBuilder`/`FutureBuilder` regarding initial values. + +- Added a `reverseDuration` parameter to `useAnimationController` + +## 0.17.0 + +- `useFuture`/`useStream`'s `AsynsSnapshot` now correctly expose the StackTrace when there is an error. +- added `useRef` and `useCallback`, similar to the React equivalents. +- `initialData` of `useStream` and `useFuture` is now optional. + +## 0.16.0 + +Stable null-safety release + +## 0.16.0-nullsafety.0 + +Migrated flutter_hooks to null-safety (special thanks to @DevNico for the help!) + +## 0.15.0 + +- Added `usePageController` to create a `PageController` + +## 0.14.1 + +- Increased the minimum version of the Flutter SDK required to match changes on + `useFocusNode` + + The minimum required is now 1.20.0 (the stable channel is at 1.20.4) + +## 0.14.0 + +- added all `FocusNode` parameters to `useFocusNode` +- Fixed a bug where on hot-reload, a `HookWidget` could potentially not rebuild +- Allow hooks to integrate with the devtool using the `Diagnosticable` API, and + implement it for all built-in hooks. + +## 0.13.1 + +- `useIsMounted` now returns a function instead of a callable class. + +## 0.13.0 + +- Added `useIsMounted` to determine whether a widget was destroyed or not (thanks to @davidmartos96) + +## 0.12.0 + +- Added `useScrollController` to create a `ScrollController` +- added `useTabController` to create a `TabController` (thanks to @Albert221) + +## 0.11.0 + +**Breaking change**: + +- Removed `HookState.didBuild`. + If you still need it, use `addPostFrameCallback` or `Future.microtask`. + +**Non-breaking changes**: + +- Fix a bug where the order in which hooks are disposed is incorrect. +- It is now allowed to rebuild a `HookWidget` with more/less hooks than previously. + Example: + + ```dart + Widget build(context) { + useSomething(); + if (condition) { + return Container(); + } + useSomething() + return Container(); + } + ``` + +- Deprecated `Hook.use` in favor of a new short-hand `use`. + Before: + + ```dart + Hook.use(MyHook()); + ``` + + After: + + ```dart + use(MyHook()); + ``` + +## 0.10.0 + +**Breaking change**: + +- The order in which hooks are disposed has been reversed. + + Consider: + + ```dart + useSomething(); + useSomethingElse(); + ``` + + Before, the `useSomething` was disposed before `useSomethingElse`. + Now, `useSomethingElse` is disposed before the `useSomething`. + + The reason for this change is for cases like: + + ```dart + // Creates an AnimationController + final animationController = useAnimationController(); + // Immediately listen to the AnimationController + useListenable(animationController); + ``` + + Before, when the widget was disposed, this caused an exception as + `useListenable` unsubscribed to the `AnimationController` _after_ its `dispose` + method was called. + +**Non-breaking changes**: + +- Added a way for hooks to potentially abort a widget rebuild. +- Added `StatefulHookWidget`, a `StatefulWidget` that can use hooks inside its `build` method. + +## 0.9.0 + +- Added a `deactivate` life-cycle to `HookState` + +## 0.8.0+1 + +- Fixed link to "Existing hooks" in `README.md`. + +## 0.8.0: + +Added `useFocusNode` + +## 0.7.0: + +- Added `useTextEditingController`, thanks to simolus3! + +## 0.6.1: + +- Added `useReassemble` hook, thanks to @SahandAkbarzadeh + +## 0.6.0: + +- Make hooks compatible with newer flutter stable version 1.7.8-hotfix.2. + +## 0.4.0: + +- Make hooks compatible with newer flutter version. (see https://groups.google.com/forum/#!topic/flutter-announce/hp1RNIgej38) + +## 0.3.0: + +- NEW: `usePrevious`, a hook that returns the previous argument is received. +- NEW: it is now impossible to call `inheritFromWidgetOfExactType` inside `initHook` of hooks. This forces authors to handle value updates. +- FIX: use List instead of List for keys. This fixes `implicit-dynamic` rule mistakenly reporting errors. +- NEW: Hooks are now visible on `HookElement` through `debugHooks` in development, for testing purposes. +- NEW: If a widget throws on the first build or after a hot-reload, next rebuilds can still add/edit hooks until one `build` finishes entirely. +- NEW: new life-cycle available on `HookState`: `didBuild`. + This life-cycle is called synchronously right after `build` method of `HookWidget` finished. +- NEW: new `reassemble` life-cycle on `HookState`. It is equivalent to `State.reassemble` of statefulwidgets. +- NEW: `useStream` and `useFuture` now have an optional `preserveState` flag. + This toggle how these hooks behave when changing the stream/future: + If true (default) they keep the previous value, else they reset to initialState. + +## 0.2.1: + +- NEW: `useValueNotifier`, which creates a `ValueNotifier` similarly to `useState`. But without listening it. + This can be useful to have a more granular rebuild when combined to `useValueListenable`. +- NEW: `useContext`, which exposes the `BuildContext` of the currently building `HookWidget`. + +## 0.2.0: + +- Made all existing hooks as static functions, and removed `HookContext`. The migration is as followed: + +```dart +Widget build(HookContext context) { + final state = context.useState(0); +} +``` + +becomes: + +```dart +Widget build(BuildContext context) { + final state = useState(0); +} +``` + +- Introduced keys for hooks and applied them to hooks where it makes sense. +- Added `useReducer` for complex state. It is similar to `useState` but is being managed by a `Reducer` and can only be changed by dispatching an action. +- fixes a bug where hot-reload without using hooks thrown an exception + +## 0.1.0: + +- `useMemoized` callback doesn't take the previous value anymore (to match React API) + Use `useValueChanged` instead. +- Introduced `useEffect` and `useStreamController` +- fixed a bug where hot-reload while reordering/adding hooks did not work properly +- improved readme + +## 0.0.1: + +Added a few common hooks: + +- `useStream` +- `useFuture` +- `useAnimationController` +- `useSingleTickerProvider` +- `useListenable` +- `useValueListenable` +- `useAnimation` + +## 0.0.0: + +- initial release diff --git a/packages/flutter_hooks/LICENSE b/packages/flutter_hooks/LICENSE new file mode 120000 index 00000000..30cff740 --- /dev/null +++ b/packages/flutter_hooks/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/packages/flutter_hooks/README.md b/packages/flutter_hooks/README.md new file mode 120000 index 00000000..fe840054 --- /dev/null +++ b/packages/flutter_hooks/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/packages/flutter_hooks/all_lint_rules.yaml b/packages/flutter_hooks/all_lint_rules.yaml new file mode 100644 index 00000000..225568ae --- /dev/null +++ b/packages/flutter_hooks/all_lint_rules.yaml @@ -0,0 +1,180 @@ +linter: + rules: + - always_declare_return_types + - always_put_control_body_on_new_line + - always_put_required_named_parameters_first + - always_require_non_null_named_parameters + - always_specify_types + - always_use_package_imports + - annotate_overrides + - avoid_annotating_with_dynamic + - avoid_bool_literals_in_conditional_expressions + - avoid_catches_without_on_clauses + - avoid_catching_errors + - avoid_classes_with_only_static_members + - avoid_double_and_int_checks + - avoid_dynamic_calls + - avoid_empty_else + - avoid_equals_and_hash_code_on_mutable_classes + - avoid_escaping_inner_quotes + - avoid_field_initializers_in_const_classes + - avoid_function_literals_in_foreach_calls + - avoid_implementing_value_types + - avoid_init_to_null + - avoid_js_rounded_ints + - avoid_null_checks_in_equality_operators + - avoid_positional_boolean_parameters + - avoid_print + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null + - avoid_returning_null_for_future + - avoid_returning_null_for_void + - avoid_returning_this + - avoid_setters_without_getters + - avoid_shadowing_type_parameters + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_type_to_string + - avoid_types_as_parameter_names + - avoid_types_on_closure_parameters + - avoid_unnecessary_containers + - avoid_unused_constructor_parameters + - avoid_void_async + - avoid_web_libraries_in_flutter + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + - cascade_invocations + - cast_nullable_to_non_nullable + - close_sinks + - comment_references + - constant_identifier_names + - control_flow_in_finally + - curly_braces_in_flow_control_structures + - diagnostic_describe_all_properties + - directives_ordering + - do_not_use_environment + - empty_catches + - empty_constructor_bodies + - empty_statements + - exhaustive_cases + - file_names + - flutter_style_todos + - hash_and_equals + - implementation_imports + - invariant_booleans + - iterable_contains_unrelated_type + - join_return_with_assignment + - leading_newlines_in_multiline_strings + - library_names + - library_prefixes + - lines_longer_than_80_chars + - list_remove_unrelated_type + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_default_cases + - no_duplicate_case_values + - no_logic_in_create_state + - no_runtimeType_toString + - non_constant_identifier_names + - null_check_on_nullable_type_parameter + - null_closures + - omit_local_variable_types + - one_member_abstracts + - only_throw_errors + - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + - parameter_assignments + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + - prefer_asserts_with_message + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + - prefer_constructors_over_static_methods + - prefer_contains + - prefer_double_quotes + - prefer_equal_for_default_values + - prefer_expression_function_bodies + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_for_elements_to_map_fromIterable + - prefer_foreach + - prefer_function_declarations_over_variables + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + - prefer_int_literals + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + - prefer_mixin + - prefer_null_aware_operators + - prefer_relative_imports + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + - provide_deprecation_message + - public_member_api_docs + - recursive_getters + - sized_box_for_whitespace + - slash_for_doc_comments + - sort_child_properties_last + - sort_constructors_first + - sort_pub_dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + - tighten_type_of_initializing_formals + - type_annotate_public_apis + - type_init_formals + - unawaited_futures + - unnecessary_await_in_return + - unnecessary_brace_in_string_interps + - unnecessary_const + - unnecessary_final + - unnecessary_getters_setters + - unnecessary_lambdas + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_checks + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_raw_strings + - unnecessary_statements + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unrelated_type_equality_checks + - unsafe_html + - use_full_hex_values_for_flutter_colors + - use_function_type_syntax_for_parameters + - use_is_even_rather_than_modulo + - use_key_in_widget_constructors + - use_late_for_private_fields_and_variables + - use_raw_strings + - use_rethrow_when_possible + - use_setters_to_change_properties + - use_string_buffers + - use_to_and_as_if_applicable + - valid_regexps + - void_checks \ No newline at end of file diff --git a/packages/flutter_hooks/analysis_options.yaml b/packages/flutter_hooks/analysis_options.yaml new file mode 100644 index 00000000..810113f9 --- /dev/null +++ b/packages/flutter_hooks/analysis_options.yaml @@ -0,0 +1,69 @@ +include: all_lint_rules.yaml +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # Otherwise cause the import of all_lint_rules to warn because of some rules conflicts. + # We explicitly enabled even conflicting rules and are fixing the conflict + # in this file + included_file_warning: ignore + +linter: + rules: + # Uninitianalized late variables are dangerous + use_late_for_private_fields_and_variables: false + + # Personal preference. I don't find it more readable + cascade_invocations: false + + # Conflicts with `prefer_single_quotes` + # Single quotes are easier to type and don't compromise on readability. + prefer_double_quotes: false + + # Conflicts with `omit_local_variable_types` and other rules. + # As per Dart guidelines, we want to avoid unnecessary types to make the code + # more readable. + # See https://dart.dev/guides/language/effective-dart/design#avoid-type-annotating-initialized-local-variables + always_specify_types: false + + # Incompatible with `prefer_final_locals` + # Having immutable local variables makes larger functions more predictable + # so we will use `prefer_final_locals` instead. + unnecessary_final: false + + # Not quite suitable for Flutter, which may have a `build` method with a single + # return, but that return is still complex enough that a "body" is worth it. + prefer_expression_function_bodies: false + + # Conflicts with the convention used by flutter, which puts `Key key` + # and `@required Widget child` last. + always_put_required_named_parameters_first: false + + # This project doesn't use Flutter-style todos + flutter_style_todos: false + + # There are situations where we voluntarily want to catch everything, + # especially as a library. + avoid_catches_without_on_clauses: false + + # Boring as it sometimes force a line of 81 characters to be split in two. + # As long as we try to respect that 80 characters limit, going slightly + # above is fine. + lines_longer_than_80_chars: false + + # Conflicts with disabling `implicit-dynamic` + avoid_annotating_with_dynamic: false + + # conflicts with `prefer_relative_imports` + always_use_package_imports: false + + # Disabled for now until we have NNBD as it otherwise conflicts with `missing_return` + no_default_cases: false + + # Too verbose + diagnostic_describe_all_properties: false diff --git a/packages/flutter_hooks/example/.gitignore b/packages/flutter_hooks/example/.gitignore new file mode 100644 index 00000000..f1c88358 --- /dev/null +++ b/packages/flutter_hooks/example/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages + +generated_plugin_registrant.dart +/test/ diff --git a/packages/flutter_hooks/example/.metadata b/packages/flutter_hooks/example/.metadata new file mode 100644 index 00000000..460bc20b --- /dev/null +++ b/packages/flutter_hooks/example/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 5391447fae6209bb21a89e6a5a6583cac1af9b4b + channel: stable + +project_type: app diff --git a/packages/flutter_hooks/example/README.md b/packages/flutter_hooks/example/README.md new file mode 100644 index 00000000..5e4a954d --- /dev/null +++ b/packages/flutter_hooks/example/README.md @@ -0,0 +1,12 @@ +# Flutter Hooks Gallery + +A series of examples demonstrating how to use Flutter Hooks! It teaches how to +use the Widgets and hooks that are provided by this library, as well examples +demonstrating how to write custom hooks. + +## Run the app + + 1. Open a terminal + 2. Navigate to this `example` directory + 3. Run `flutter create .` + 4. Run `flutter run` from your Terminal, or launch the project from your IDE! \ No newline at end of file diff --git a/packages/flutter_hooks/example/analysis_options.yaml b/packages/flutter_hooks/example/analysis_options.yaml new file mode 100644 index 00000000..41fe64f6 --- /dev/null +++ b/packages/flutter_hooks/example/analysis_options.yaml @@ -0,0 +1,14 @@ +include: ../analysis_options.yaml +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + todo: error + include_file_not_found: ignore +linter: + rules: + public_member_api_docs: false + avoid_print: false + use_key_in_widget_constructors: false diff --git a/packages/flutter_hooks/example/lib/custom_hook_function.dart b/packages/flutter_hooks/example/lib/custom_hook_function.dart new file mode 100644 index 00000000..cd2453f1 --- /dev/null +++ b/packages/flutter_hooks/example/lib/custom_hook_function.dart @@ -0,0 +1,47 @@ +// ignore_for_file: omit_local_variable_types +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// This example demonstrates how to write a hook function that enhances the +/// useState hook with logging functionality. +class CustomHookFunctionExample extends HookWidget { + @override + Widget build(BuildContext context) { + // Next, invoke the custom `useLoggedState` hook with a default value to + // create a `counter` variable that contains a `value`. Whenever the value + // is changed, this Widget will be rebuilt and the result will be logged! + final counter = useLoggedState(0); + + return Scaffold( + appBar: AppBar( + title: const Text('Custom Hook: Function'), + ), + body: Center( + // Read the current value from the counter + child: Text('Button tapped ${counter.value} times'), + ), + floatingActionButton: FloatingActionButton( + // When the button is pressed, update the value of the counter! This + // will trigger a rebuild as well as printing the latest value to the + // console! + onPressed: () => counter.value++, + child: const Icon(Icons.add), + ), + ); + } +} + +/// A custom hook that wraps the useState hook to add logging. Hooks can be +/// composed -- meaning you can use hooks within hooks! +ValueNotifier useLoggedState(T initialData) { + // First, call the useState hook. It will create a ValueNotifier for you that + // rebuilds the Widget whenever the value changes. + final result = useState(initialData); + + // Next, call the useValueChanged hook to print the state whenever it changes + useValueChanged(result.value, (_, __) { + print(result.value); + }); + + return result; +} diff --git a/packages/flutter_hooks/example/lib/main.dart b/packages/flutter_hooks/example/lib/main.dart new file mode 100644 index 00000000..44c14cdf --- /dev/null +++ b/packages/flutter_hooks/example/lib/main.dart @@ -0,0 +1,71 @@ +// ignore_for_file: omit_local_variable_types +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'star_wars/planet_screen.dart'; +import 'use_effect.dart'; +import 'use_state.dart'; +import 'use_stream.dart'; + +void main() => runApp(HooksGalleryApp()); + +/// An App that demonstrates how to use hooks. It includes examples that cover +/// the hooks provided by this library as well as examples that demonstrate +/// how to write custom hooks. +class HooksGalleryApp extends HookWidget { + @override + Widget build(BuildContext context) { + useAnimationController(duration: const Duration(seconds: 2)); + return MaterialApp( + title: 'Flutter Hooks Gallery', + home: Scaffold( + appBar: AppBar( + title: const Text('Flutter Hooks Gallery'), + ), + body: ListView(children: [ + _GalleryItem( + title: 'useState', + builder: (context) => UseStateExample(), + ), + _GalleryItem( + title: 'useMemoize + useStream', + builder: (context) => UseStreamExample(), + ), + _GalleryItem( + title: 'Custom Hook Function', + builder: (context) => CustomHookExample(), + ), + _GalleryItem( + title: 'Star Wars Planets', + builder: (context) => PlanetScreen(), + ) + ]), + ), + ); + } +} + +class _GalleryItem extends StatelessWidget { + const _GalleryItem({ + required this.title, + required this.builder, + }); + + final String title; + final WidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(title), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: builder, + ), + ); + }, + ); + } +} diff --git a/packages/flutter_hooks/example/lib/star_wars/README.md b/packages/flutter_hooks/example/lib/star_wars/README.md new file mode 100644 index 00000000..efa910f7 --- /dev/null +++ b/packages/flutter_hooks/example/lib/star_wars/README.md @@ -0,0 +1,5 @@ +# Star wars API + +This folder is specifically for handling the Star wars API. This is one API endpoint: https://swapi.co/api/planets + +To understand the generated .g.dart code see https://flutter.dev/docs/development/data-and-backend/json#serializing-json-using-code-generation-libraries diff --git a/packages/flutter_hooks/example/lib/star_wars/models.dart b/packages/flutter_hooks/example/lib/star_wars/models.dart new file mode 100644 index 00000000..c7746231 --- /dev/null +++ b/packages/flutter_hooks/example/lib/star_wars/models.dart @@ -0,0 +1,49 @@ +// ignore_for_file: public_member_api_docs + +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; +import 'package:built_value/standard_json_plugin.dart'; +import 'package:meta/meta.dart'; + +part 'models.g.dart'; + +/// json serializer to build models +@SerializersFor([ + PlanetPageModel, + PlanetModel, +]) +final Serializers serializers = + (_$serializers.toBuilder()..addPlugin(StandardJsonPlugin())).build(); + +@immutable +abstract class PlanetPageModel + implements Built { + factory PlanetPageModel([ + void Function(PlanetPageModelBuilder) updates, + ]) = _$PlanetPageModel; + + const PlanetPageModel._(); + + static Serializer get serializer => + _$planetPageModelSerializer; + + String? get next; + + String? get previous; + + BuiltList get results; +} + +@immutable +abstract class PlanetModel implements Built { + factory PlanetModel([ + void Function(PlanetModelBuilder) updates, + ]) = _$PlanetModel; + + const PlanetModel._(); + + static Serializer get serializer => _$planetModelSerializer; + + String get name; +} diff --git a/packages/flutter_hooks/example/lib/star_wars/models.g.dart b/packages/flutter_hooks/example/lib/star_wars/models.g.dart new file mode 100644 index 00000000..f4e07ffa --- /dev/null +++ b/packages/flutter_hooks/example/lib/star_wars/models.g.dart @@ -0,0 +1,327 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'models.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +Serializers _$serializers = (new Serializers().toBuilder() + ..add(PlanetModel.serializer) + ..add(PlanetPageModel.serializer) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(PlanetModel)]), + () => new ListBuilder())) + .build(); +Serializer _$planetPageModelSerializer = + new _$PlanetPageModelSerializer(); +Serializer _$planetModelSerializer = new _$PlanetModelSerializer(); + +class _$PlanetPageModelSerializer + implements StructuredSerializer { + @override + final Iterable types = const [PlanetPageModel, _$PlanetPageModel]; + @override + final String wireName = 'PlanetPageModel'; + + @override + Iterable serialize(Serializers serializers, PlanetPageModel object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'results', + serializers.serialize(object.results, + specifiedType: + const FullType(BuiltList, const [const FullType(PlanetModel)])), + ]; + Object? value; + value = object.next; + if (value != null) { + result + ..add('next') + ..add(serializers.serialize(value, + specifiedType: const FullType(String))); + } + value = object.previous; + if (value != null) { + result + ..add('previous') + ..add(serializers.serialize(value, + specifiedType: const FullType(String))); + } + return result; + } + + @override + PlanetPageModel deserialize( + Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new PlanetPageModelBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'next': + result.next = serializers.deserialize(value, + specifiedType: const FullType(String)) as String?; + break; + case 'previous': + result.previous = serializers.deserialize(value, + specifiedType: const FullType(String)) as String?; + break; + case 'results': + result.results.replace(serializers.deserialize(value, + specifiedType: const FullType( + BuiltList, const [const FullType(PlanetModel)]))! + as BuiltList); + break; + } + } + + return result.build(); + } +} + +class _$PlanetModelSerializer implements StructuredSerializer { + @override + final Iterable types = const [PlanetModel, _$PlanetModel]; + @override + final String wireName = 'PlanetModel'; + + @override + Iterable serialize(Serializers serializers, PlanetModel object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'name', + serializers.serialize(object.name, specifiedType: const FullType(String)), + ]; + + return result; + } + + @override + PlanetModel deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new PlanetModelBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'name': + result.name = serializers.deserialize(value, + specifiedType: const FullType(String))! as String; + break; + } + } + + return result.build(); + } +} + +class _$PlanetPageModel extends PlanetPageModel { + @override + final String? next; + @override + final String? previous; + @override + final BuiltList results; + + factory _$PlanetPageModel([void Function(PlanetPageModelBuilder)? updates]) => + (new PlanetPageModelBuilder()..update(updates))._build(); + + _$PlanetPageModel._({this.next, this.previous, required this.results}) + : super._() { + BuiltValueNullFieldError.checkNotNull( + results, r'PlanetPageModel', 'results'); + } + + @override + PlanetPageModel rebuild(void Function(PlanetPageModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + PlanetPageModelBuilder toBuilder() => + new PlanetPageModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is PlanetPageModel && + next == other.next && + previous == other.previous && + results == other.results; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, next.hashCode); + _$hash = $jc(_$hash, previous.hashCode); + _$hash = $jc(_$hash, results.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'PlanetPageModel') + ..add('next', next) + ..add('previous', previous) + ..add('results', results)) + .toString(); + } +} + +class PlanetPageModelBuilder + implements Builder { + _$PlanetPageModel? _$v; + + String? _next; + String? get next => _$this._next; + set next(String? next) => _$this._next = next; + + String? _previous; + String? get previous => _$this._previous; + set previous(String? previous) => _$this._previous = previous; + + ListBuilder? _results; + ListBuilder get results => + _$this._results ??= new ListBuilder(); + set results(ListBuilder? results) => _$this._results = results; + + PlanetPageModelBuilder(); + + PlanetPageModelBuilder get _$this { + final $v = _$v; + if ($v != null) { + _next = $v.next; + _previous = $v.previous; + _results = $v.results.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(PlanetPageModel other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$PlanetPageModel; + } + + @override + void update(void Function(PlanetPageModelBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + PlanetPageModel build() => _build(); + + _$PlanetPageModel _build() { + _$PlanetPageModel _$result; + try { + _$result = _$v ?? + new _$PlanetPageModel._( + next: next, previous: previous, results: results.build()); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'results'; + results.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + r'PlanetPageModel', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +class _$PlanetModel extends PlanetModel { + @override + final String name; + + factory _$PlanetModel([void Function(PlanetModelBuilder)? updates]) => + (new PlanetModelBuilder()..update(updates))._build(); + + _$PlanetModel._({required this.name}) : super._() { + BuiltValueNullFieldError.checkNotNull(name, r'PlanetModel', 'name'); + } + + @override + PlanetModel rebuild(void Function(PlanetModelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + PlanetModelBuilder toBuilder() => new PlanetModelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is PlanetModel && name == other.name; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, name.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'PlanetModel')..add('name', name)) + .toString(); + } +} + +class PlanetModelBuilder implements Builder { + _$PlanetModel? _$v; + + String? _name; + String? get name => _$this._name; + set name(String? name) => _$this._name = name; + + PlanetModelBuilder(); + + PlanetModelBuilder get _$this { + final $v = _$v; + if ($v != null) { + _name = $v.name; + _$v = null; + } + return this; + } + + @override + void replace(PlanetModel other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$PlanetModel; + } + + @override + void update(void Function(PlanetModelBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + PlanetModel build() => _build(); + + _$PlanetModel _build() { + final _$result = _$v ?? + new _$PlanetModel._( + name: BuiltValueNullFieldError.checkNotNull( + name, r'PlanetModel', 'name')); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/flutter_hooks/example/lib/star_wars/planet_screen.dart b/packages/flutter_hooks/example/lib/star_wars/planet_screen.dart new file mode 100644 index 00000000..0640b582 --- /dev/null +++ b/packages/flutter_hooks/example/lib/star_wars/planet_screen.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:provider/provider.dart'; + +import 'redux.dart'; +import 'star_wars_api.dart'; + +/// This handler will take care of async api interactions +/// and updating the store afterwards. +class _PlanetHandler { + _PlanetHandler(this._store, this._starWarsApi); + + final Store _store; + final StarWarsApi _starWarsApi; + + /// This will load all planets and will dispatch all necessary actions + /// on the redux store. + Future fetchAndDispatch([String? url]) async { + _store.dispatch(FetchPlanetPageActionStart()); + try { + final page = await _starWarsApi.getPlanets(url); + _store.dispatch(FetchPlanetPageActionSuccess(page)); + } catch (e, stack) { + print('errpr $e $stack'); + _store.dispatch(FetchPlanetPageActionError('Error loading Planets')); + } + } +} + +/// This example will load, show and let you navigate through all star wars +/// planets. +/// +/// It will demonstrate on how to use [Provider] and [useReducer] +class PlanetScreen extends HookWidget { + @override + Widget build(BuildContext context) { + final api = useMemoized(() => StarWarsApi()); + + final store = useReducer( + reducer, + initialState: AppState(), + initialAction: null, + ); + + final planetHandler = useMemoized( + () { + /// Create planet handler and load the first page. + /// The first page will only be loaded once, after the handler was created + return _PlanetHandler(store, api)..fetchAndDispatch(); + }, + [store, api], + ); + + return MultiProvider( + providers: [ + Provider.value(value: planetHandler), + Provider.value(value: store.state), + ], + child: Scaffold( + appBar: AppBar( + title: const Text( + 'Star Wars Planets', + ), + ), + body: const _PlanetScreenBody(), + ), + ); + } +} + +class _PlanetScreenBody extends HookWidget { + const _PlanetScreenBody(); + + @override + Widget build(BuildContext context) { + final state = Provider.of(context); + + if (state.isFetchingPlanets) { + return const Center(child: CircularProgressIndicator()); + } else if (state.planetPage.results.isEmpty) { + return const Center(child: Text('No planets found')); + } else if (state.errorFetchingPlanets != null) { + return Center( + child: _Error( + errorMsg: state.errorFetchingPlanets, + ), + ); + } else { + return _PlanetList(); + } + } +} + +class _Error extends StatelessWidget { + const _Error({ + Key? key, + required this.errorMsg, + }) : super(key: key); + + final String? errorMsg; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (errorMsg != null) Text(errorMsg!), + ElevatedButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(Colors.redAccent), + ), + onPressed: () async { + await Provider.of<_PlanetHandler>( + context, + listen: false, + ).fetchAndDispatch(); + }, + child: const Text('Try again'), + ), + ], + ); + } +} + +class _LoadPageButton extends HookWidget { + const _LoadPageButton({this.next = true}); + + final bool next; + + @override + Widget build(BuildContext context) { + final state = Provider.of(context); + return ElevatedButton( + onPressed: () async { + final url = next ? state.planetPage.next : state.planetPage.previous; + await Provider.of<_PlanetHandler>(context, listen: false) + .fetchAndDispatch(url); + }, + child: next ? const Text('Next Page') : const Text('Prev Page'), + ); + } +} + +class _PlanetList extends HookWidget { + @override + Widget build(BuildContext context) { + final state = Provider.of(context); + return ListView.builder( + itemCount: 1 + state.planetPage.results.length, + itemBuilder: (context, index) { + if (index == 0) { + return _PlanetListHeader(); + } + + final planet = state.planetPage.results[index - 1]; + return ListTile(title: Text(planet.name)); + }, + ); + } +} + +class _PlanetListHeader extends StatelessWidget { + @override + Widget build(BuildContext context) { + final state = Provider.of(context); + MainAxisAlignment buttonAlignment; + if (state.planetPage.previous == null) { + buttonAlignment = MainAxisAlignment.end; + } else if (state.planetPage.next == null) { + buttonAlignment = MainAxisAlignment.start; + } else { + buttonAlignment = MainAxisAlignment.spaceBetween; + } + + return Row( + mainAxisAlignment: buttonAlignment, + children: [ + if (state.planetPage.previous != null) + const _LoadPageButton(next: false), + if (state.planetPage.next != null) const _LoadPageButton() + ], + ); + } +} diff --git a/packages/flutter_hooks/example/lib/star_wars/redux.dart b/packages/flutter_hooks/example/lib/star_wars/redux.dart new file mode 100644 index 00000000..e0edab3a --- /dev/null +++ b/packages/flutter_hooks/example/lib/star_wars/redux.dart @@ -0,0 +1,70 @@ +import 'package:built_value/built_value.dart'; +import 'package:meta/meta.dart'; + +import 'models.dart'; + +part 'redux.g.dart'; + +/// Actions base class +abstract class ReduxAction {} + +/// Action that updates state to show that we are loading planets +class FetchPlanetPageActionStart extends ReduxAction {} + +/// Action that updates state to show that we are loading planets +class FetchPlanetPageActionError extends ReduxAction { + FetchPlanetPageActionError(this.errorMsg); + + /// Message that should be displayed in the UI + final String errorMsg; +} + +/// Action to set the planet page +class FetchPlanetPageActionSuccess extends ReduxAction { + FetchPlanetPageActionSuccess(this.page); + + final PlanetPageModel page; +} + +@immutable +abstract class AppState implements Built { + factory AppState([void Function(AppStateBuilder)? updates]) => + _$AppState((u) => u + ..isFetchingPlanets = false + ..update(updates)); + + const AppState._(); + + bool get isFetchingPlanets; + + String? get errorFetchingPlanets; + + PlanetPageModel get planetPage; +} + +AppState reducer( + S state, + A action, +) { + final b = state.toBuilder(); + if (action is FetchPlanetPageActionStart) { + b + ..isFetchingPlanets = true + ..planetPage = PlanetPageModelBuilder() + ..errorFetchingPlanets = null; + } + + if (action is FetchPlanetPageActionError) { + b + ..isFetchingPlanets = false + ..errorFetchingPlanets = action.errorMsg; + } + + if (action is FetchPlanetPageActionSuccess) { + b + ..isFetchingPlanets = false + ..planetPage.replace(action.page); + } + + return b.build(); +} diff --git a/packages/flutter_hooks/example/lib/star_wars/redux.g.dart b/packages/flutter_hooks/example/lib/star_wars/redux.g.dart new file mode 100644 index 00000000..9e122a93 --- /dev/null +++ b/packages/flutter_hooks/example/lib/star_wars/redux.g.dart @@ -0,0 +1,138 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'redux.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$AppState extends AppState { + @override + final bool isFetchingPlanets; + @override + final String? errorFetchingPlanets; + @override + final PlanetPageModel planetPage; + + factory _$AppState([void Function(AppStateBuilder)? updates]) => + (new AppStateBuilder()..update(updates))._build(); + + _$AppState._( + {required this.isFetchingPlanets, + this.errorFetchingPlanets, + required this.planetPage}) + : super._() { + BuiltValueNullFieldError.checkNotNull( + isFetchingPlanets, r'AppState', 'isFetchingPlanets'); + BuiltValueNullFieldError.checkNotNull( + planetPage, r'AppState', 'planetPage'); + } + + @override + AppState rebuild(void Function(AppStateBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + AppStateBuilder toBuilder() => new AppStateBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is AppState && + isFetchingPlanets == other.isFetchingPlanets && + errorFetchingPlanets == other.errorFetchingPlanets && + planetPage == other.planetPage; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, isFetchingPlanets.hashCode); + _$hash = $jc(_$hash, errorFetchingPlanets.hashCode); + _$hash = $jc(_$hash, planetPage.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'AppState') + ..add('isFetchingPlanets', isFetchingPlanets) + ..add('errorFetchingPlanets', errorFetchingPlanets) + ..add('planetPage', planetPage)) + .toString(); + } +} + +class AppStateBuilder implements Builder { + _$AppState? _$v; + + bool? _isFetchingPlanets; + bool? get isFetchingPlanets => _$this._isFetchingPlanets; + set isFetchingPlanets(bool? isFetchingPlanets) => + _$this._isFetchingPlanets = isFetchingPlanets; + + String? _errorFetchingPlanets; + String? get errorFetchingPlanets => _$this._errorFetchingPlanets; + set errorFetchingPlanets(String? errorFetchingPlanets) => + _$this._errorFetchingPlanets = errorFetchingPlanets; + + PlanetPageModelBuilder? _planetPage; + PlanetPageModelBuilder get planetPage => + _$this._planetPage ??= new PlanetPageModelBuilder(); + set planetPage(PlanetPageModelBuilder? planetPage) => + _$this._planetPage = planetPage; + + AppStateBuilder(); + + AppStateBuilder get _$this { + final $v = _$v; + if ($v != null) { + _isFetchingPlanets = $v.isFetchingPlanets; + _errorFetchingPlanets = $v.errorFetchingPlanets; + _planetPage = $v.planetPage.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(AppState other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$AppState; + } + + @override + void update(void Function(AppStateBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + AppState build() => _build(); + + _$AppState _build() { + _$AppState _$result; + try { + _$result = _$v ?? + new _$AppState._( + isFetchingPlanets: BuiltValueNullFieldError.checkNotNull( + isFetchingPlanets, r'AppState', 'isFetchingPlanets'), + errorFetchingPlanets: errorFetchingPlanets, + planetPage: planetPage.build()); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'planetPage'; + planetPage.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + r'AppState', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/flutter_hooks/example/lib/star_wars/star_wars_api.dart b/packages/flutter_hooks/example/lib/star_wars/star_wars_api.dart new file mode 100644 index 00000000..26079874 --- /dev/null +++ b/packages/flutter_hooks/example/lib/star_wars/star_wars_api.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'models.dart'; + +/// Api wrapper to retrieve Star Wars related data +class StarWarsApi { + /// load and return one page of planets + Future getPlanets([String? page]) async { + page ??= 'https://swapi.dev/api/planets'; + + final response = await http.get(Uri.parse(page)); + final dynamic json = jsonDecode(utf8.decode(response.bodyBytes)); + + return serializers.deserializeWith(PlanetPageModel.serializer, json)!; + } +} diff --git a/packages/flutter_hooks/example/lib/use_effect.dart b/packages/flutter_hooks/example/lib/use_effect.dart new file mode 100644 index 00000000..2643bc5b --- /dev/null +++ b/packages/flutter_hooks/example/lib/use_effect.dart @@ -0,0 +1,96 @@ +// ignore_for_file: omit_local_variable_types +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// This example demonstrates how to create a custom Hook. +class CustomHookExample extends HookWidget { + @override + Widget build(BuildContext context) { + // Consume the custom hook. It returns a StreamController that we can use + // within this Widget. + // + // To update the stored value, `add` data to the StreamController. To get + // the latest value from the StreamController, listen to the Stream with + // the useStream hook. + // ignore: close_sinks + final StreamController countController = + _useLocalStorageInt('counter'); + + return Scaffold( + appBar: AppBar( + title: const Text('Custom Hook example'), + ), + body: Center( + // Use a HookBuilder Widget to listen to the Stream. This ensures a + // smaller portion of the Widget tree is rebuilt when the stream emits a + // new value + child: HookBuilder( + builder: (context) { + final AsyncSnapshot count = + useStream(countController.stream, initialData: 0); + + return !count.hasData + ? const CircularProgressIndicator() + : GestureDetector( + onTap: () => countController.add(count.requireData + 1), + child: Text('You tapped me ${count.data} times.'), + ); + }, + ), + ), + ); + } +} + +// A custom hook that will read and write values to local storage using the +// SharedPreferences package. +StreamController _useLocalStorageInt( + String key, { + int defaultValue = 0, +}) { + // Custom hooks can use additional hooks internally! + final controller = useStreamController(keys: [key]); + + // Pass a callback to the useEffect hook. This function should be called on + // first build and every time the controller or key changes + useEffect( + () { + // Listen to the StreamController, and when a value is added, store it + // using SharedPrefs. + final sub = controller.stream.listen((data) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(key, data); + }); + // Unsubscribe when the widget is disposed + // or on controller/key change + return sub.cancel; + }, + // Pass the controller and key to the useEffect hook. This will ensure the + // useEffect hook is only called the first build or when one of the the + // values changes. + [controller, key], + ); + + // Load the initial value from local storage and add it as the initial value + // to the controller + useEffect( + () { + SharedPreferences.getInstance().then((prefs) async { + final int? valueFromStorage = prefs.getInt(key); + controller.add(valueFromStorage ?? defaultValue); + }).catchError(controller.addError); + return null; + }, + // Pass the controller and key to the useEffect hook. This will ensure the + // useEffect hook is only called the first build or when one of the the + // values changes. + [controller, key], + ); + + // Finally, return the StreamController. This allows users to add values from + // the Widget layer and listen to the stream for changes. + return controller; +} diff --git a/packages/flutter_hooks/example/lib/use_reducer.dart b/packages/flutter_hooks/example/lib/use_reducer.dart new file mode 100644 index 00000000..e9e57d6d --- /dev/null +++ b/packages/flutter_hooks/example/lib/use_reducer.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// This example emulates the basic Counter app generated by the +/// `flutter create` command to demonstrates the `useReducer` hook. +/// +/// First, instead of a StatefulWidget, use a HookWidget instead! + +@immutable +class State { + const State({this.counter = 0}); + final int counter; +} + +// Create the actions you wish to dispatch to the reducer +class IncrementCounter { + IncrementCounter({required this.counter}); + + final int counter; +} + +class UseReducerExample extends HookWidget { + @override + Widget build(BuildContext context) { + // Create the reducer function that will handle the actions you dispatch + State _reducer(State state, IncrementCounter? action) { + if (action is IncrementCounter) { + return State(counter: state.counter + action.counter); + } + return state; + } + + // Next, invoke the `useReducer` function with the reducer function and initial state to create a + // `_store` variable that contains the current state and dispatch. Whenever the value is + // changed, this Widget will be rebuilt! + final _store = useReducer( + _reducer, + initialState: const State(), + initialAction: null, + ); + + return Scaffold( + appBar: AppBar( + title: const Text('useState example'), + ), + body: Center( + // Read the current value from the counter + child: Text('Button tapped ${_store.state.counter} times'), + ), + floatingActionButton: FloatingActionButton( + // When the button is pressed, dispatch the Action you wish to trigger! This + // will trigger a rebuild, displaying the latest value in the Text + // Widget above! + onPressed: () => _store.dispatch(IncrementCounter(counter: 1)), + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/packages/flutter_hooks/example/lib/use_state.dart b/packages/flutter_hooks/example/lib/use_state.dart new file mode 100644 index 00000000..770fee00 --- /dev/null +++ b/packages/flutter_hooks/example/lib/use_state.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// This example emulates the basic Counter app generated by the +/// `flutter create` command to demonstrates the `useState` hook. +/// +/// First, instead of a StatefulWidget, use a HookWidget instead! +class UseStateExample extends HookWidget { + @override + Widget build(BuildContext context) { + // Next, invoke the `useState` function with a default value to create a + // `counter` variable that contains a `value`. Whenever the value is + // changed, this Widget will be rebuilt! + final counter = useState(0); + + return Scaffold( + appBar: AppBar( + title: const Text('useState example'), + ), + body: Center( + // Read the current value from the counter + child: Text('Button tapped ${counter.value} times'), + ), + floatingActionButton: FloatingActionButton( + // When the button is pressed, update the value of the counter! This + // will trigger a rebuild, displaying the latest value in the Text + // Widget above! + onPressed: () => counter.value++, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/packages/flutter_hooks/example/lib/use_stream.dart b/packages/flutter_hooks/example/lib/use_stream.dart new file mode 100644 index 00000000..8de63732 --- /dev/null +++ b/packages/flutter_hooks/example/lib/use_stream.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// This example demonstrates how to use Hooks to rebuild a Widget whenever +/// a Stream emits a new value. +class UseStreamExample extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('useStream example'), + ), + body: Center( + // In this example, the Text Widget is the only portion that needs to + // rebuild when the Stream changes. Therefore, use a HookBuilder + // Widget to limit rebuilds to this section of the app, rather than + // marking the entire UseStreamExample as a HookWidget! + child: HookBuilder( + builder: (context) { + // First, create and cache a Stream with the `useMemoized` hook. + // This hook allows you to create an Object (such as a Stream or + // Future) the first time this builder function is invoked without + // recreating it on each subsequent build! + final stream = useMemoized( + () => Stream.periodic( + const Duration(seconds: 1), (i) => i + 1), + ); + // Next, invoke the `useStream` hook to listen for updates to the + // Stream. This triggers a rebuild whenever a new value is emitted. + // + // Like normal StreamBuilders, it returns the current AsyncSnapshot. + final snapshot = useStream(stream, initialData: 0); + + // Finally, use the data from the Stream to render a text Widget. + // If no data is available, fallback to a default value. + return Text( + '${snapshot.data ?? 0}', + style: const TextStyle(fontSize: 36), + ); + }, + ), + ), + ); + } +} diff --git a/packages/flutter_hooks/example/pubspec.yaml b/packages/flutter_hooks/example/pubspec.yaml new file mode 100644 index 00000000..73e80f22 --- /dev/null +++ b/packages/flutter_hooks/example/pubspec.yaml @@ -0,0 +1,27 @@ +name: flutter_hooks_gallery +description: A new Flutter project. + +publish_to: none + +environment: + sdk: ">=2.12.0 <4.0.0" + +dependencies: + built_collection: ^5.0.0 + built_value: ^8.0.0 + flutter: + sdk: flutter + flutter_hooks: + path: ../ + http: ^0.13.4 + provider: ^6.0.5 + shared_preferences: ^2.0.0 + +dev_dependencies: + build_runner: ^2.1.11 + built_value_generator: ^8.3.3 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/packages/flutter_hooks/flutter-hook.svg b/packages/flutter_hooks/flutter-hook.svg new file mode 100644 index 00000000..260b868e --- /dev/null +++ b/packages/flutter_hooks/flutter-hook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/flutter_hooks/lib/flutter_hooks.dart b/packages/flutter_hooks/lib/flutter_hooks.dart new file mode 100644 index 00000000..dbbf2167 --- /dev/null +++ b/packages/flutter_hooks/lib/flutter_hooks.dart @@ -0,0 +1,2 @@ +export 'package:flutter_hooks/src/framework.dart'; +export 'package:flutter_hooks/src/hooks.dart'; diff --git a/packages/flutter_hooks/lib/src/animation.dart b/packages/flutter_hooks/lib/src/animation.dart new file mode 100644 index 00000000..08bcac7a --- /dev/null +++ b/packages/flutter_hooks/lib/src/animation.dart @@ -0,0 +1,331 @@ +part of 'hooks.dart'; + +/// Subscribes to an [Animation] and returns its value. +/// +/// See also: +/// * [Animation] +/// * [useValueListenable], [useListenable], [useStream] +T useAnimation(Animation animation) { + use(_UseAnimationHook(animation)); + return animation.value; +} + +class _UseAnimationHook extends _ListenableHook { + const _UseAnimationHook(Animation animation) : super(animation); + + @override + _UseAnimationStateHook createState() { + return _UseAnimationStateHook(); + } +} + +class _UseAnimationStateHook extends _ListenableStateHook { + @override + String get debugLabel => 'useAnimation'; + + @override + Object? get debugValue => (hook.listenable as Animation?)?.value; +} + +/// Creates an [AnimationController] and automatically disposes it when necessary. +/// +/// If no [vsync] is provided, the [TickerProvider] is implicitly obtained using [useSingleTickerProvider]. +/// If a [vsync] is specified, changing the instance of [vsync] will result in a call to [AnimationController.resync]. +/// It is not possible to switch between implicit and explicit [vsync]. +/// +/// Changing the [duration] parameter automatically updates the [AnimationController.duration]. +/// +/// [initialValue], [lowerBound], [upperBound] and [debugLabel] are ignored after the first call. +/// +/// See also: +/// * [AnimationController], the created object. +/// * [useAnimation], to listen to the created [AnimationController]. +AnimationController useAnimationController({ + Duration? duration, + Duration? reverseDuration, + String? debugLabel, + double initialValue = 0, + double lowerBound = 0, + double upperBound = 1, + TickerProvider? vsync, + AnimationBehavior animationBehavior = AnimationBehavior.normal, + List? keys, +}) { + vsync ??= useSingleTickerProvider(keys: keys); + + return use( + _AnimationControllerHook( + duration: duration, + reverseDuration: reverseDuration, + debugLabel: debugLabel, + initialValue: initialValue, + lowerBound: lowerBound, + upperBound: upperBound, + vsync: vsync, + animationBehavior: animationBehavior, + keys: keys, + ), + ); +} + +class _AnimationControllerHook extends Hook { + const _AnimationControllerHook({ + this.duration, + this.reverseDuration, + this.debugLabel, + required this.initialValue, + required this.lowerBound, + required this.upperBound, + required this.vsync, + required this.animationBehavior, + List? keys, + }) : super(keys: keys); + + final Duration? duration; + final Duration? reverseDuration; + final String? debugLabel; + final double initialValue; + final double lowerBound; + final double upperBound; + final TickerProvider vsync; + final AnimationBehavior animationBehavior; + + @override + _AnimationControllerHookState createState() => + _AnimationControllerHookState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('duration', duration)); + properties.add(DiagnosticsProperty('reverseDuration', reverseDuration)); + } +} + +class _AnimationControllerHookState + extends HookState { + late final AnimationController _animationController = AnimationController( + vsync: hook.vsync, + duration: hook.duration, + reverseDuration: hook.reverseDuration, + debugLabel: hook.debugLabel, + lowerBound: hook.lowerBound, + upperBound: hook.upperBound, + animationBehavior: hook.animationBehavior, + value: hook.initialValue, + ); + + @override + void didUpdateHook(_AnimationControllerHook oldHook) { + super.didUpdateHook(oldHook); + if (hook.vsync != oldHook.vsync) { + _animationController.resync(hook.vsync); + } + + if (hook.duration != oldHook.duration) { + _animationController.duration = hook.duration; + } + + if (hook.reverseDuration != oldHook.reverseDuration) { + _animationController.reverseDuration = hook.reverseDuration; + } + } + + @override + AnimationController build(BuildContext context) { + return _animationController; + } + + @override + void dispose() { + _animationController.dispose(); + } + + @override + bool get debugHasShortDescription => false; + + @override + String get debugLabel => 'useAnimationController'; +} + +/// 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(); + return _ticker!; + } + + @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; + } + + void _updateTicker() { + if (_ticker != null) { + _ticker!.muted = !_tickerModeNotifier!.value; + } + } + + void _updateTickerModeNotifier() { + final newNotifier = TickerMode.getNotifier(context); + if (newNotifier == _tickerModeNotifier) { + return; + } + _tickerModeNotifier?.removeListener(_updateTicker); + newNotifier.addListener(_updateTicker); + _tickerModeNotifier = newNotifier; + } + + @override + String get debugLabel => 'useSingleTickerProvider'; + + @override + bool get debugSkipValue => true; +} + +/// Creates a [TickerProvider] that supports creating multiple [Ticker]s. +/// +/// See also: +/// * [SingleTickerProviderStateMixin] +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(); + _updateTickers(); + _tickers.add(ticker); + 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; +} diff --git a/packages/flutter_hooks/lib/src/async.dart b/packages/flutter_hooks/lib/src/async.dart new file mode 100644 index 00000000..8510c736 --- /dev/null +++ b/packages/flutter_hooks/lib/src/async.dart @@ -0,0 +1,431 @@ +part of 'hooks.dart'; + +/// Subscribes to a [Future] and returns its current state as an [AsyncSnapshot]. +/// +/// * [preserveState] determines if the current value should be preserved when changing +/// the [Future] instance. +/// +/// The [Future] needs to be created outside of [useFuture]. +/// If the [Future] is created inside [useFuture], then, every time the build +/// method gets called, the [Future] will be called again. One way to create +/// the [Future] outside of [useFuture] is by using [useMemoized]. +/// +/// ```dart +/// // BAD +/// useFuture(fetchFromDatabase()); +/// +/// // GOOD +/// final result = useMemoized(() => fetchFromDatabase()); +/// useFuture(result); +/// ``` +/// +/// See also: +/// * [Future], the listened object. +/// * [useStream], similar to [useFuture] but for [Stream]. +AsyncSnapshot useFuture( + Future? future, { + T? initialData, + bool preserveState = true, +}) { + return use( + _FutureHook( + future, + initialData: initialData, + preserveState: preserveState, + ), + ); +} + +class _FutureHook extends Hook> { + const _FutureHook( + this.future, { + required this.initialData, + this.preserveState = true, + }); + + final Future? future; + final bool preserveState; + final T? initialData; + + @override + _FutureStateHook createState() => _FutureStateHook(); +} + +class _FutureStateHook extends HookState, _FutureHook> { + /// An object that identifies the currently active callbacks. Used to avoid + /// calling `setState` from stale callbacks, e.g. after disposal of this state, + /// or after widget reconfiguration to a new [Future]. + Object? _activeCallbackIdentity; + late AsyncSnapshot _snapshot = initial; + + AsyncSnapshot get initial => hook.initialData == null + ? AsyncSnapshot.nothing() + : AsyncSnapshot.withData(ConnectionState.none, hook.initialData as T); + + @override + void initHook() { + super.initHook(); + _subscribe(); + } + + @override + void didUpdateHook(_FutureHook oldHook) { + super.didUpdateHook(oldHook); + if (oldHook.future != hook.future) { + if (_activeCallbackIdentity != null) { + _unsubscribe(); + if (hook.preserveState) { + _snapshot = _snapshot.inState(ConnectionState.none); + } else { + _snapshot = initial; + } + } + _subscribe(); + } + } + + @override + void dispose() { + _unsubscribe(); + } + + void _subscribe() { + if (hook.future != null) { + final callbackIdentity = Object(); + _activeCallbackIdentity = callbackIdentity; + hook.future!.then((data) { + if (_activeCallbackIdentity == callbackIdentity) { + setState(() { + _snapshot = AsyncSnapshot.withData(ConnectionState.done, data); + }); + } + // ignore: avoid_types_on_closure_parameters + }, onError: (Object error, StackTrace stackTrace) { + if (_activeCallbackIdentity == callbackIdentity) { + setState(() { + _snapshot = AsyncSnapshot.withError( + ConnectionState.done, + error, + stackTrace, + ); + }); + } + }); + _snapshot = _snapshot.inState(ConnectionState.waiting); + } + } + + void _unsubscribe() { + _activeCallbackIdentity = null; + } + + @override + AsyncSnapshot build(BuildContext context) { + return _snapshot; + } + + @override + String get debugLabel => 'useFuture'; + + @override + Object? get debugValue => _snapshot; +} + +/// Subscribes to a [Stream] and returns its current state as an [AsyncSnapshot]. +/// +/// * [preserveState] determines if the current value should be preserved when changing +/// the [Stream] instance. +/// +/// When [preserveState] is true (the default) update jank is reduced when switching +/// streams, but this may result in inconsistent state when using multiple +/// or nested streams. See https://github.com/rrousselGit/flutter_hooks/issues/312 +/// for more context. +/// +/// See also: +/// * [Stream], the object listened. +/// * [useFuture], similar to [useStream] but for [Future]. +AsyncSnapshot useStream( + Stream? stream, { + T? initialData, + bool preserveState = true, +}) { + return use( + _StreamHook( + stream, + initialData: initialData, + preserveState: preserveState, + ), + ); +} + +class _StreamHook extends Hook> { + const _StreamHook( + this.stream, { + required this.initialData, + required this.preserveState, + }); + + final Stream? stream; + final T? initialData; + final bool preserveState; + + @override + _StreamHookState createState() => _StreamHookState(); +} + +/// a clone of [StreamBuilderBase] implementation +class _StreamHookState extends HookState, _StreamHook> { + StreamSubscription? _subscription; + late AsyncSnapshot _summary = initial; + + @override + void initHook() { + super.initHook(); + _subscribe(); + } + + @override + void didUpdateHook(_StreamHook oldWidget) { + super.didUpdateHook(oldWidget); + if (oldWidget.stream != hook.stream) { + if (_subscription != null) { + _unsubscribe(); + if (hook.preserveState) { + _summary = afterDisconnected(_summary); + } else { + _summary = initial; + } + } + _subscribe(); + } + } + + @override + void dispose() { + _unsubscribe(); + } + + void _subscribe() { + if (hook.stream != null) { + _subscription = hook.stream!.listen((data) { + setState(() { + _summary = afterData(data); + }); + // ignore: avoid_types_on_closure_parameters + }, onError: (Object error, StackTrace stackTrace) { + setState(() { + _summary = afterError(error, stackTrace); + }); + }, onDone: () { + setState(() { + _summary = afterDone(_summary); + }); + }); + _summary = afterConnected(_summary); + } + } + + void _unsubscribe() { + _subscription?.cancel(); + _subscription = null; + } + + @override + AsyncSnapshot build(BuildContext context) { + return _summary; + } + + AsyncSnapshot get initial => hook.initialData == null + ? AsyncSnapshot.nothing() + : AsyncSnapshot.withData(ConnectionState.none, hook.initialData as T); + + AsyncSnapshot afterConnected(AsyncSnapshot current) => + current.inState(ConnectionState.waiting); + + AsyncSnapshot afterData(T data) { + return AsyncSnapshot.withData(ConnectionState.active, data); + } + + AsyncSnapshot afterError(Object error, StackTrace stackTrace) { + return AsyncSnapshot.withError( + ConnectionState.active, + error, + stackTrace, + ); + } + + AsyncSnapshot afterDone(AsyncSnapshot current) => + current.inState(ConnectionState.done); + + AsyncSnapshot afterDisconnected(AsyncSnapshot current) => + current.inState(ConnectionState.none); + + @override + String get debugLabel => 'useStream'; +} + +/// Creates a [StreamController] which is automatically disposed when necessary. +/// +/// See also: +/// * [StreamController], the created object +/// * [useStream], to listen to the created [StreamController] +StreamController useStreamController({ + bool sync = false, + VoidCallback? onListen, + VoidCallback? onCancel, + List? keys, +}) { + return use( + _StreamControllerHook( + onCancel: onCancel, + onListen: onListen, + sync: sync, + keys: keys, + ), + ); +} + +class _StreamControllerHook extends Hook> { + const _StreamControllerHook({ + required this.sync, + this.onListen, + this.onCancel, + List? keys, + }) : super(keys: keys); + + final bool sync; + final VoidCallback? onListen; + final VoidCallback? onCancel; + + @override + _StreamControllerHookState createState() => + _StreamControllerHookState(); +} + +class _StreamControllerHookState + extends HookState, _StreamControllerHook> { + late final _controller = StreamController.broadcast( + sync: hook.sync, + onCancel: hook.onCancel, + onListen: hook.onListen, + ); + + @override + void didUpdateHook(_StreamControllerHook oldHook) { + super.didUpdateHook(oldHook); + if (oldHook.onListen != hook.onListen) { + _controller.onListen = hook.onListen; + } + if (oldHook.onCancel != hook.onCancel) { + _controller.onCancel = hook.onCancel; + } + } + + @override + StreamController build(BuildContext context) { + return _controller; + } + + @override + void dispose() { + _controller.close(); + } + + @override + String get debugLabel => 'useStreamController'; +} + +/// Subscribes to a [Stream] and calls the [Stream.listen] to register the [onData], +/// [onError], and [onDone]. +/// +/// Returns the [StreamSubscription] returned by the [Stream.listen]. +/// +/// See also: +/// * [Stream], the object listened. +/// * [Stream.listen], calls the provided handlers. +StreamSubscription? useOnStreamChange( + Stream? stream, { + void Function(T event)? onData, + void Function(Object error, StackTrace stackTrace)? onError, + void Function()? onDone, + bool? cancelOnError, +}) { + return use?>( + _OnStreamChangeHook( + stream, + onData: onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ), + ); +} + +class _OnStreamChangeHook extends Hook?> { + const _OnStreamChangeHook( + this.stream, { + this.onData, + this.onError, + this.onDone, + this.cancelOnError, + }); + + final Stream? stream; + final void Function(T event)? onData; + final void Function(Object error, StackTrace stackTrace)? onError; + final void Function()? onDone; + final bool? cancelOnError; + + @override + _StreamListenerHookState createState() => _StreamListenerHookState(); +} + +class _StreamListenerHookState + extends HookState?, _OnStreamChangeHook> { + StreamSubscription? _subscription; + + @override + void initHook() { + super.initHook(); + _subscribe(); + } + + @override + void didUpdateHook(_OnStreamChangeHook oldWidget) { + super.didUpdateHook(oldWidget); + if (oldWidget.stream != hook.stream || + oldWidget.cancelOnError != hook.cancelOnError) { + _unsubscribe(); + _subscribe(); + } + } + + @override + void dispose() { + _unsubscribe(); + } + + void _subscribe() { + final stream = hook.stream; + if (stream != null) { + _subscription = stream.listen( + hook.onData, + onError: hook.onError, + onDone: hook.onDone, + cancelOnError: hook.cancelOnError, + ); + } + } + + void _unsubscribe() { + _subscription?.cancel(); + } + + @override + StreamSubscription? build(BuildContext context) { + return _subscription; + } + + @override + String get debugLabel => 'useOnStreamChange'; +} diff --git a/packages/flutter_hooks/lib/src/carousel_controller.dart b/packages/flutter_hooks/lib/src/carousel_controller.dart new file mode 100644 index 00000000..b1ec352c --- /dev/null +++ b/packages/flutter_hooks/lib/src/carousel_controller.dart @@ -0,0 +1,46 @@ +part of 'hooks.dart'; + +/// Creates a [CarouselController] that will be disposed automatically. +/// +/// See also: +/// - [CarouselController] +CarouselController useCarouselController({ + int initialItem = 0, + List? keys, +}) { + return use( + _CarouselControllerHook( + initialItem: initialItem, + keys: keys, + ), + ); +} + +class _CarouselControllerHook extends Hook { + const _CarouselControllerHook({ + required this.initialItem, + super.keys, + }); + + final int initialItem; + + @override + HookState> createState() => + _CarouselControllerHookState(); +} + +class _CarouselControllerHookState + extends HookState { + late final controller = CarouselController( + initialItem: hook.initialItem, + ); + + @override + CarouselController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useCarouselController'; +} diff --git a/packages/flutter_hooks/lib/src/cupertino_tab_controller.dart b/packages/flutter_hooks/lib/src/cupertino_tab_controller.dart new file mode 100644 index 00000000..53e71140 --- /dev/null +++ b/packages/flutter_hooks/lib/src/cupertino_tab_controller.dart @@ -0,0 +1,46 @@ +part of 'hooks.dart'; + +/// Creates a [CupertinoTabController] that will be disposed automatically. +/// +/// See also: +/// - [CupertinoTabController] +CupertinoTabController useCupertinoTabController({ + int initialIndex = 0, + List? keys, +}) { + return use( + _CupertinoTabControllerHook( + initialIndex: initialIndex, + keys: keys, + ), + ); +} + +class _CupertinoTabControllerHook extends Hook { + const _CupertinoTabControllerHook({ + required this.initialIndex, + super.keys, + }); + + final int initialIndex; + + @override + HookState> + createState() => _CupertinoTabControllerHookState(); +} + +class _CupertinoTabControllerHookState + extends HookState { + late final controller = CupertinoTabController( + initialIndex: hook.initialIndex, + ); + + @override + CupertinoTabController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useCupertinoTabController'; +} diff --git a/packages/flutter_hooks/lib/src/debounced.dart b/packages/flutter_hooks/lib/src/debounced.dart new file mode 100644 index 00000000..f93ccd48 --- /dev/null +++ b/packages/flutter_hooks/lib/src/debounced.dart @@ -0,0 +1,88 @@ +part of 'hooks.dart'; + +/// Returns a debounced version of the provided value [toDebounce], triggering +/// widget updates accordingly after a specified [timeout] duration. +/// +/// Example: +/// ```dart +/// String userInput = ''; // Your input value +/// +/// // Create a debounced version of userInput +/// final debouncedInput = useDebounced( +/// userInput, +/// Duration(milliseconds: 500), // Set your desired timeout +/// ); +/// // Assume a fetch method fetchData(String query) exists +/// useEffect(() { +/// fetchData(debouncedInput); // Use debouncedInput as a dependency +/// return null; +/// }, [debouncedInput]); +/// ``` +T? useDebounced( + T toDebounce, + Duration timeout, +) { + return use( + _DebouncedHook( + toDebounce: toDebounce, + timeout: timeout, + ), + ); +} + +class _DebouncedHook extends Hook { + const _DebouncedHook({ + required this.toDebounce, + required this.timeout, + }); + + final T toDebounce; + final Duration timeout; + + @override + _DebouncedHookState createState() => _DebouncedHookState(); +} + +class _DebouncedHookState extends HookState> { + T? _state; + Timer? _timer; + + @override + void initHook() { + super.initHook(); + _startDebounce(hook.toDebounce); + } + + void _startDebounce(T toDebounce) { + _timer?.cancel(); + _timer = Timer(hook.timeout, () { + setState(() { + _state = toDebounce; + }); + }); + } + + @override + void didUpdateHook(_DebouncedHook oldHook) { + if (hook.toDebounce != oldHook.toDebounce || + hook.timeout != oldHook.timeout) { + _startDebounce(hook.toDebounce); + } + } + + @override + T? build(BuildContext context) => _state; + + @override + Object? get debugValue => _state; + + @override + String get debugLabel => 'useDebounced<$T>'; + + @override + void dispose() { + _timer?.cancel(); + _timer = null; + super.dispose(); + } +} diff --git a/packages/flutter_hooks/lib/src/draggable_scrollable_controller.dart b/packages/flutter_hooks/lib/src/draggable_scrollable_controller.dart new file mode 100644 index 00000000..621a3392 --- /dev/null +++ b/packages/flutter_hooks/lib/src/draggable_scrollable_controller.dart @@ -0,0 +1,34 @@ +part of 'hooks.dart'; + +/// Creates a [DraggableScrollableController] that will be disposed automatically. +/// +/// See also: +/// - [DraggableScrollableController] +DraggableScrollableController useDraggableScrollableController({ + List? keys, +}) { + return use(_DraggableScrollableControllerHook(keys: keys)); +} + +class _DraggableScrollableControllerHook + extends Hook { + const _DraggableScrollableControllerHook({super.keys}); + + @override + HookState> + createState() => _DraggableScrollableControllerHookState(); +} + +class _DraggableScrollableControllerHookState extends HookState< + DraggableScrollableController, _DraggableScrollableControllerHook> { + final controller = DraggableScrollableController(); + + @override + String get debugLabel => 'useDraggableScrollableController'; + + @override + DraggableScrollableController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); +} diff --git a/packages/flutter_hooks/lib/src/expansion_tile_controller.dart b/packages/flutter_hooks/lib/src/expansion_tile_controller.dart new file mode 100644 index 00000000..665aef49 --- /dev/null +++ b/packages/flutter_hooks/lib/src/expansion_tile_controller.dart @@ -0,0 +1,37 @@ +part of 'hooks.dart'; + +/// Creates a [ExpansibleController] that will be disposed automatically. +/// +/// See also: +/// - [ExpansibleController] +ExpansibleController useExpansibleController({List? keys}) { + return use(_ExpansibleControllerHook(keys: keys)); +} + +/// Creates a [ExpansionTileController] that will be disposed automatically. +/// +/// See also: +/// - [ExpansionTileController] +@Deprecated('Use `useExpansibleController` instead.') +ExpansionTileController useExpansionTileController({List? keys}) { + return use(_ExpansibleControllerHook(keys: keys)); +} + +class _ExpansibleControllerHook extends Hook { + const _ExpansibleControllerHook({List? keys}) : super(keys: keys); + + @override + HookState> createState() => + _ExpansibleControllerHookState(); +} + +class _ExpansibleControllerHookState + extends HookState { + final controller = ExpansibleController(); + + @override + String get debugLabel => 'useExpansibleController'; + + @override + ExpansibleController build(BuildContext context) => controller; +} diff --git a/packages/flutter_hooks/lib/src/fixed_extent_scroll_controller.dart b/packages/flutter_hooks/lib/src/fixed_extent_scroll_controller.dart new file mode 100644 index 00000000..a70a1619 --- /dev/null +++ b/packages/flutter_hooks/lib/src/fixed_extent_scroll_controller.dart @@ -0,0 +1,57 @@ +part of 'hooks.dart'; + +/// Creates [FixedExtentScrollController] that will be disposed automatically. +/// +/// See also: +/// - [FixedExtentScrollController] +FixedExtentScrollController useFixedExtentScrollController({ + int initialItem = 0, + ScrollControllerCallback? onAttach, + ScrollControllerCallback? onDetach, + List? keys, +}) { + return use( + _FixedExtentScrollControllerHook( + initialItem: initialItem, + onAttach: onAttach, + onDetach: onDetach, + keys: keys, + ), + ); +} + +class _FixedExtentScrollControllerHook + extends Hook { + const _FixedExtentScrollControllerHook({ + required this.initialItem, + this.onAttach, + this.onDetach, + super.keys, + }); + + final int initialItem; + final ScrollControllerCallback? onAttach; + final ScrollControllerCallback? onDetach; + + @override + HookState> + createState() => _FixedExtentScrollControllerHookState(); +} + +class _FixedExtentScrollControllerHookState extends HookState< + FixedExtentScrollController, _FixedExtentScrollControllerHook> { + late final controller = FixedExtentScrollController( + initialItem: hook.initialItem, + onAttach: hook.onAttach, + onDetach: hook.onDetach, + ); + + @override + FixedExtentScrollController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useFixedExtentScrollController'; +} diff --git a/packages/flutter_hooks/lib/src/focus_node.dart b/packages/flutter_hooks/lib/src/focus_node.dart new file mode 100644 index 00000000..ef41915b --- /dev/null +++ b/packages/flutter_hooks/lib/src/focus_node.dart @@ -0,0 +1,79 @@ +part of 'hooks.dart'; + +/// Creates an automatically disposed [FocusNode]. +/// +/// See also: +/// - [FocusNode] +FocusNode useFocusNode({ + String? debugLabel, + FocusOnKeyEventCallback? onKeyEvent, + bool skipTraversal = false, + bool canRequestFocus = true, + bool descendantsAreFocusable = true, + bool descendantsAreTraversable = true, +}) { + return use( + _FocusNodeHook( + debugLabel: debugLabel, + onKeyEvent: onKeyEvent, + skipTraversal: skipTraversal, + canRequestFocus: canRequestFocus, + descendantsAreFocusable: descendantsAreFocusable, + descendantsAreTraversable: descendantsAreTraversable, + ), + ); +} + +class _FocusNodeHook extends Hook { + const _FocusNodeHook({ + this.debugLabel, + this.onKeyEvent, + required this.skipTraversal, + required this.canRequestFocus, + required this.descendantsAreFocusable, + required this.descendantsAreTraversable, + }); + + final String? debugLabel; + final FocusOnKeyEventCallback? onKeyEvent; + final bool skipTraversal; + final bool canRequestFocus; + final bool descendantsAreFocusable; + final bool descendantsAreTraversable; + + @override + _FocusNodeHookState createState() { + return _FocusNodeHookState(); + } +} + +class _FocusNodeHookState extends HookState { + late final FocusNode _focusNode = FocusNode( + debugLabel: hook.debugLabel, + onKeyEvent: hook.onKeyEvent, + skipTraversal: hook.skipTraversal, + canRequestFocus: hook.canRequestFocus, + descendantsAreFocusable: hook.descendantsAreFocusable, + descendantsAreTraversable: hook.descendantsAreTraversable, + ); + + @override + void didUpdateHook(_FocusNodeHook oldHook) { + _focusNode + ..debugLabel = hook.debugLabel + ..skipTraversal = hook.skipTraversal + ..canRequestFocus = hook.canRequestFocus + ..descendantsAreFocusable = hook.descendantsAreFocusable + ..descendantsAreTraversable = hook.descendantsAreTraversable + ..onKeyEvent = hook.onKeyEvent; + } + + @override + FocusNode build(BuildContext context) => _focusNode; + + @override + void dispose() => _focusNode.dispose(); + + @override + String get debugLabel => 'useFocusNode'; +} diff --git a/packages/flutter_hooks/lib/src/focus_scope_node.dart b/packages/flutter_hooks/lib/src/focus_scope_node.dart new file mode 100644 index 00000000..8e1f84c4 --- /dev/null +++ b/packages/flutter_hooks/lib/src/focus_scope_node.dart @@ -0,0 +1,68 @@ +part of 'hooks.dart'; + +/// Creates an automatically disposed [FocusScopeNode]. +/// +/// See also: +/// - [FocusScopeNode] +FocusScopeNode useFocusScopeNode({ + String? debugLabel, + FocusOnKeyEventCallback? onKeyEvent, + bool skipTraversal = false, + bool canRequestFocus = true, +}) { + return use( + _FocusScopeNodeHook( + debugLabel: debugLabel, + onKeyEvent: onKeyEvent, + skipTraversal: skipTraversal, + canRequestFocus: canRequestFocus, + ), + ); +} + +class _FocusScopeNodeHook extends Hook { + const _FocusScopeNodeHook({ + this.debugLabel, + this.onKeyEvent, + required this.skipTraversal, + required this.canRequestFocus, + }); + + final String? debugLabel; + final FocusOnKeyEventCallback? onKeyEvent; + final bool skipTraversal; + final bool canRequestFocus; + + @override + _FocusScopeNodeHookState createState() { + return _FocusScopeNodeHookState(); + } +} + +class _FocusScopeNodeHookState + extends HookState { + late final FocusScopeNode _focusScopeNode = FocusScopeNode( + debugLabel: hook.debugLabel, + onKeyEvent: hook.onKeyEvent, + skipTraversal: hook.skipTraversal, + canRequestFocus: hook.canRequestFocus, + ); + + @override + void didUpdateHook(_FocusScopeNodeHook oldHook) { + _focusScopeNode + ..debugLabel = hook.debugLabel + ..skipTraversal = hook.skipTraversal + ..canRequestFocus = hook.canRequestFocus + ..onKeyEvent = hook.onKeyEvent; + } + + @override + FocusScopeNode build(BuildContext context) => _focusScopeNode; + + @override + void dispose() => _focusScopeNode.dispose(); + + @override + String get debugLabel => 'useFocusScopeNode'; +} diff --git a/packages/flutter_hooks/lib/src/framework.dart b/packages/flutter_hooks/lib/src/framework.dart new file mode 100644 index 00000000..1ba423a0 --- /dev/null +++ b/packages/flutter_hooks/lib/src/framework.dart @@ -0,0 +1,644 @@ +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// Whether to behave like in release mode or allow hot-reload for hooks. +/// +/// `true` by default. It has no impact on release builds. +bool debugHotReloadHooksEnabled = true; + +/// Registers a [Hook] and returns its value. +/// +/// [use] must be called within the `build` method of either [HookWidget] or [StatefulHookWidget]. +/// All calls of [use] must be made outside of conditional checks and always in the same order. +/// +/// See [Hook] for more explanations. +// ignore: deprecated_member_use, deprecated_member_use_from_same_package +R use(Hook hook) => Hook.use(hook); + +/// [Hook] is similar to a [StatelessWidget], but is not associated +/// to an [Element]. +/// +/// A [Hook] is typically the equivalent of [State] for [StatefulWidget], +/// with the notable difference that a [HookWidget] can have more than one [Hook]. +/// A [Hook] is created within the [HookState.build] method of a [HookWidget] and the creation +/// must be made unconditionally, always in the same order. +/// +/// ### Good: +/// ``` +/// class Good extends HookWidget { +/// @override +/// Widget build(BuildContext context) { +/// final name = useState(""); +/// // ... +/// } +/// } +/// ``` +/// +/// ### Bad: +/// ``` +/// class Bad extends HookWidget { +/// @override +/// Widget build(BuildContext context) { +/// if (condition) { +/// final name = useState(""); +/// // ... +/// } +/// } +/// } +/// ``` +/// +/// The reason for such restrictions is that [HookState] are obtained based on their index. +/// So the index must never ever change, or it will lead to undesired behavior. +/// +/// ## Usage +/// +/// [Hook] is a powerful tool which enables the reuse of [State] logic between multiple [Widget]. +/// They are used to extract logic that depends on a [Widget] life-cycle (such as [HookState.dispose]). +/// +/// While mixins are a good candidate too, they do not allow sharing values. A mixin cannot reasonably +/// define a variable, as this can lead to variable conflicts in bigger widgets. +/// +/// Hooks are designed so that they get the benefits of mixins, but are totally independent from each other. +/// This means that hooks can store and expose values without needing to check if the name is already taken by another mixin. +/// +/// ## Example +/// +/// A common use-case is to handle disposable objects such as [AnimationController]. +/// +/// With the usual [StatefulWidget], we would typically have the following: +/// +/// ``` +/// class Usual extends StatefulWidget { +/// @override +/// _UsualState createState() => _UsualState(); +/// } +/// +/// class _UsualState extends State +/// with SingleTickerProviderStateMixin { +/// late final _controller = AnimationController( +/// vsync: this, +/// duration: const Duration(seconds: 1), +/// ); +/// +/// @override +/// void dispose() { +/// _controller.dispose(); +/// super.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) { +/// return Container(); +/// } +/// } +/// ``` +/// +/// This is undesired because every single widget that wants to use an [AnimationController] will have to +/// rewrite this exact piece of code. +/// +/// With hooks, it is possible to extract that exact piece of code into a reusable one. +/// +/// This means that with [HookWidget] the following code is functionally equivalent to the previous example: +/// +/// ``` +/// class Usual extends HookWidget { +/// @override +/// Widget build(BuildContext context) { +/// final animationController = useAnimationController(duration: const Duration(seconds: 1)); +/// return Container(); +/// } +/// } +/// ``` +/// +/// This is visibly less code then before, but in this example, the `animationController` is still +/// guaranteed to be disposed when the widget is removed from the tree. +/// +/// In fact, this has a secondary bonus: `duration` is kept updated with the latest value. +/// If we were to pass a variable as `duration` instead of a constant, then on value change the [AnimationController] will be updated. +@immutable +abstract class Hook with Diagnosticable { + /// Allows subclasses to have a `const` constructor + const Hook({this.keys}); + + /// Registers a [Hook] and returns its value. + /// + /// [use] must be called within the `build` method of either [HookWidget] or [StatefulHookWidget]. + /// All calls to [use] must be made outside of conditional statements and always on the same order. + /// + /// See [Hook] for more explanations. + @Deprecated('Use `use` instead of `Hook.use`') + static R use(Hook hook) { + assert(HookElement._currentHookElement != null, ''' +Hooks can only be called from the build method of a widget that mix-in `Hooks`. + +Hooks should only be called within the build method of a widget. +Calling them outside of build method leads to an unstable state and is therefore prohibited. +'''); + return HookElement._currentHookElement!._use(hook); + } + + /// A list of objects that specify if a [HookState] should be reused or a new one should be created. + /// + /// When a new [Hook] is created, the framework checks if keys matches using [Hook.shouldPreserveState]. + /// If they don't, the previously created [HookState] is disposed, and a new one is created + /// using [Hook.createState], followed by [HookState.initHook]. + final List? keys; + + /// The algorithm to determine if a [HookState] should be reused or disposed. + /// + /// This compares [Hook.keys] to see if they contains any difference. + /// A state is preserved when: + /// + /// - `hook1.keys == hook2.keys` (typically if the list is immutable) + /// - If there's any difference in the content of [Hook.keys], using `operator==`. + /// + /// There are exceptions when comparing [Hook.keys] before using `operator==`: + /// - A state is preserved when one of the [Hook.keys] is [double.nan]. + /// - A state is NOT preserved when one of the [Hook.keys] is changed from 0.0 to -0.0. + static bool shouldPreserveState(Hook hook1, Hook hook2) { + final p1 = hook1.keys; + final p2 = hook2.keys; + + if (p1 == p2) { + return true; + } + // if one list is null and the other one isn't, or if they have different sizes + if (p1 == null || p2 == null || p1.length != p2.length) { + return false; + } + + final i1 = p1.iterator; + final i2 = p2.iterator; + // ignore: literal_only_boolean_expressions, returns will abort the loop + while (true) { + if (!i1.moveNext() || !i2.moveNext()) { + return true; + } + + final curr1 = i1.current; + final curr2 = i2.current; + + if (curr1 is num && curr2 is num) { + // Checks if both are NaN + if (curr1.isNaN && curr2.isNaN) { + continue; + } + + // Checks if one is 0.0 and the other is -0.0 + if (curr1 == 0 && curr2 == 0) { + if (curr1.isNegative != curr2.isNegative) { + return false; + } + continue; + } + } + + if (curr1 != curr2) { + return false; + } + } + } + + /// Creates the mutable state for this [Hook] linked to its widget creator. + /// + /// Subclasses should override this method to return a newly created instance of their associated [State] subclass: + /// + /// ``` + /// @override + /// HookState createState() => _MyHookState(); + /// ``` + /// + /// The framework can call this method multiple times over the lifetime of a [HookWidget]. For example, + /// if the hook is used multiple times, a separate [HookState] must be created for each usage. + @protected + HookState> createState(); +} + +/// The logic and internal state for a [HookWidget] +abstract class HookState> with Diagnosticable { + /// Equivalent of [State.context] for [HookState] + @protected + BuildContext get context => _element!; + HookElement? _element; + + R? _debugLastBuiltValue; + + /// The value shown in the devtool. + /// + /// Defaults to the last value returned by [build]. + Object? get debugValue => _debugLastBuiltValue; + + /// A flag to prevent showing [debugValue] in the devtool for a [Hook] that returns nothing. + bool get debugSkipValue => false; + + /// A label used by the devtool to show the state of a [Hook]. + String? get debugLabel => null; + + /// Whether or not the devtool description should skip [debugFillProperties]. + bool get debugHasShortDescription => true; + + /// Equivalent of [State.widget] for [HookState]. + T get hook => _hook!; + T? _hook; + + /// Equivalent of [State.initState] for [HookState]. + @protected + void initHook() {} + + /// Equivalent of [State.dispose] for [HookState]. + @protected + void dispose() {} + + /// Called everytime the [HookState] is requested. + /// + /// [build] is where a [HookState] may use other hooks. This restriction is made to ensure that hooks are always unconditionally requested. + @protected + R build(BuildContext context); + + /// Equivalent of [State.didUpdateWidget] for [HookState]. + @protected + void didUpdateHook(T oldHook) {} + + /// Equivalent of [State.deactivate] for [HookState]. + void deactivate() {} + + /// {@macro flutter.widgets.reassemble} + /// + /// In addition to this method being invoked, it is guaranteed that the + /// [build] method will be invoked when a reassemble is signaled. Most + /// widgets therefore do not need to do anything in the [reassemble] method. + /// + /// See also: + /// + /// * [State.reassemble] + void reassemble() {} + + /// Called before a [build] triggered by [markMayNeedRebuild]. + /// + /// If [shouldRebuild] returns `false` on all the hooks that called [markMayNeedRebuild] + /// then this aborts the rebuild of the associated [HookWidget]. + /// + /// There is no guarantee that this method will be called after [markMayNeedRebuild] + /// was called. + /// Some situations where [shouldRebuild] will not be called: + /// + /// - [setState] was called + /// - a previous hook's [shouldRebuild] returned `true` + /// - the associated [HookWidget] changed. + bool shouldRebuild() => true; + + /// Mark the associated [HookWidget] as **potentially** needing to rebuild. + /// + /// As opposed to [setState], the rebuild is optional and can be cancelled right + /// before `build` is called, by having [shouldRebuild] return false. + void markMayNeedRebuild() { + if (_element!._isOptionalRebuild != false) { + _element! + .._isOptionalRebuild = true + .._shouldRebuildQueue.add(_Entry(shouldRebuild)) + ..markNeedsBuild(); + } + assert(_element!.dirty, 'Bad state'); + } + + /// Equivalent of [State.setState] for [HookState]. + @protected + void setState(VoidCallback fn) { + fn(); + _element! + .._isOptionalRebuild = false + ..markNeedsBuild(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + final value = debugValue; + if (value != this) { + properties.add(DiagnosticsProperty(null, value)); + } + hook.debugFillProperties(properties); + } +} + +class _Entry extends LinkedListEntry<_Entry> { + _Entry(this.value); + T value; +} + +extension on HookElement { + HookState> _createHookState(Hook hook) { + assert(() { + _debugIsInitHook = true; + return true; + }(), ''); + + final state = hook.createState() + .._element = this + .._hook = hook + ..initHook(); + + assert(() { + _debugIsInitHook = false; + return true; + }(), ''); + + return state; + } + + void _appendHook(Hook hook) { + final result = _createHookState(hook); + _currentHookState = _Entry(result); + _hooks.add(_currentHookState!); + } + + void _unmountAllRemainingHooks() { + if (_currentHookState != null) { + _needDispose ??= LinkedList(); + // Mark all hooks >= this one as needing dispose + while (_currentHookState != null) { + final previousHookState = _currentHookState!; + _currentHookState = _currentHookState!.next; + previousHookState.unlink(); + _needDispose!.add(previousHookState); + } + } + } +} + +/// An [Element] that uses a [HookWidget] as its configuration. +mixin HookElement on ComponentElement { + static HookElement? _currentHookElement; + + _Entry>>? _currentHookState; + final _hooks = LinkedList<_Entry>>>(); + final _shouldRebuildQueue = LinkedList<_Entry>(); + LinkedList<_Entry>>>? _needDispose; + bool? _isOptionalRebuild = false; + Widget? _buildCache; + + bool _debugIsInitHook = false; + bool _debugDidReassemble = false; + + /// A read-only list of all available hooks. + /// + /// In release mode, returns `null`. + List>>? get debugHooks { + if (!kDebugMode) { + return null; + } + return [ + for (final hook in _hooks) hook.value, + ]; + } + + @override + void update(Widget newWidget) { + _isOptionalRebuild = false; + super.update(newWidget); + } + + @override + void didChangeDependencies() { + _isOptionalRebuild = false; + super.didChangeDependencies(); + } + + @override + void reassemble() { + super.reassemble(); + _isOptionalRebuild = false; + _debugDidReassemble = true; + for (final hook in _hooks) { + hook.value.reassemble(); + } + } + + @override + Widget build() { + // Check whether we can cancel the rebuild (caused by HookState.mayNeedRebuild). + final mustRebuild = _isOptionalRebuild != true || + _shouldRebuildQueue.any((cb) => cb.value()); + + _isOptionalRebuild = null; + _shouldRebuildQueue.clear(); + + if (!mustRebuild) { + return _buildCache!; + } + + if (kDebugMode) { + _debugIsInitHook = false; + } + _currentHookState = _hooks.isEmpty ? null : _hooks.first; + HookElement._currentHookElement = this; + try { + _buildCache = super.build(); + } finally { + _isOptionalRebuild = null; + _unmountAllRemainingHooks(); + HookElement._currentHookElement = null; + if (_needDispose != null && _needDispose!.isNotEmpty) { + for (_Entry>>? toDispose = + _needDispose!.last; + toDispose != null; + toDispose = toDispose.previous) { + toDispose.value.dispose(); + } + _needDispose = null; + } + } + + return _buildCache!; + } + + R _use(Hook hook) { + /// At the end of the hooks list + if (_currentHookState == null) { + _appendHook(hook); + } else if (hook.runtimeType != _currentHookState!.value.hook.runtimeType) { + final previousHookType = _currentHookState!.value.hook.runtimeType; + _unmountAllRemainingHooks(); + if (kDebugMode && _debugDidReassemble) { + _appendHook(hook); + } else { + throw StateError(''' +Type mismatch between hooks: +- previous hook: $previousHookType +- new hook: ${hook.runtimeType} +'''); + } + } else if (hook != _currentHookState!.value.hook) { + final previousHook = _currentHookState!.value.hook; + if (Hook.shouldPreserveState(previousHook, hook)) { + _currentHookState!.value + .._hook = hook + ..didUpdateHook(previousHook); + } else { + _needDispose ??= LinkedList(); + _needDispose!.add(_Entry(_currentHookState!.value)); + _currentHookState!.value = _createHookState(hook); + } + } + + final result = _currentHookState!.value.build(this) as R; + assert(() { + _currentHookState!.value._debugLastBuiltValue = result; + return true; + }(), ''); + _currentHookState = _currentHookState!.next; + return result; + } + + @override + T? dependOnInheritedWidgetOfExactType({ + Object? aspect, + }) { + assert( + !_debugIsInitHook, + 'Cannot listen to inherited widgets inside HookState.initState.' + ' Use HookState.build instead', + ); + return super.dependOnInheritedWidgetOfExactType(aspect: aspect); + } + + @override + void unmount() { + super.unmount(); + if (_hooks.isNotEmpty) { + for (_Entry>>? hook = _hooks.last; + hook != null; + hook = hook.previous) { + try { + hook.value.dispose(); + } catch (exception, stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'hooks library', + context: DiagnosticsNode.message( + 'while disposing ${hook.runtimeType}', + ), + ), + ); + } + } + } + } + + @override + void deactivate() { + for (final hook in _hooks) { + try { + hook.value.deactivate(); + } catch (exception, stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'hooks library', + context: DiagnosticsNode.message( + 'while deactivating ${hook.runtimeType}', + ), + ), + ); + } + } + super.deactivate(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + for (final hookState in debugHooks!) { + if (hookState.debugHasShortDescription) { + if (hookState.debugSkipValue) { + properties.add( + StringProperty(hookState.debugLabel!, '', ifEmpty: ''), + ); + } else { + properties.add( + DiagnosticsProperty( + hookState.debugLabel, + hookState.debugValue, + ), + ); + } + } else { + properties.add( + DiagnosticsProperty(hookState.debugLabel, hookState), + ); + } + } + } +} + +/// A [Widget] that can use a [Hook]. +/// +/// Its usage is very similar to [StatelessWidget]. +/// [HookWidget] does not have any life cycle and only implements +/// the [build] method. +/// +/// The difference is that it can use a [Hook], which allows a +/// [HookWidget] to store mutable data without implementing a [State]. +abstract class HookWidget extends StatelessWidget { + /// Initializes [key] for subclasses. + const HookWidget({Key? key}) : super(key: key); + + @override + _StatelessHookElement createElement() => _StatelessHookElement(this); +} + +class _StatelessHookElement extends StatelessElement with HookElement { + _StatelessHookElement(HookWidget hooks) : super(hooks); +} + +/// A [StatefulWidget] that can use a [Hook]. +/// +/// Its usage is very similar to that of [StatefulWidget], but uses hooks inside [State.build]. +/// +/// The difference is that it can use a [Hook], which allows a +/// [HookWidget] to store mutable data without implementing a [State]. +abstract class StatefulHookWidget extends StatefulWidget { + /// Initializes [key] for subclasses. + const StatefulHookWidget({Key? key}) : super(key: key); + + @override + _StatefulHookElement createElement() => _StatefulHookElement(this); +} + +class _StatefulHookElement extends StatefulElement with HookElement { + _StatefulHookElement(StatefulHookWidget hooks) : super(hooks); +} + +/// Obtains the [BuildContext] of the building [HookWidget]. +BuildContext useContext() { + assert( + HookElement._currentHookElement != null, + '`useContext` can only be called from the build method of HookWidget', + ); + return HookElement._currentHookElement!; +} + +/// A [HookWidget] that delegates its `build` to a callback. +class HookBuilder extends HookWidget { + /// Creates a widget that delegates its build to a callback. + /// + /// The [builder] argument must not be null. + const HookBuilder({ + required this.builder, + Key? key, + }) : super(key: key); + + /// The callback used by [HookBuilder] to create a [Widget]. + /// + /// If a [Hook] requests a rebuild, [builder] will be called again. + /// [builder] must not return `null`. + final Widget Function(BuildContext context) builder; + + @override + Widget build(BuildContext context) => builder(context); +} diff --git a/packages/flutter_hooks/lib/src/hooks.dart b/packages/flutter_hooks/lib/src/hooks.dart new file mode 100644 index 00000000..f9b04a9b --- /dev/null +++ b/packages/flutter_hooks/lib/src/hooks.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart' show CupertinoTabController; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' + show + Brightness, + CarouselController, + DraggableScrollableController, + // ignore: deprecated_member_use + ExpansionTileController, + SearchController, + TabController, + WidgetState, + WidgetStatesController, + kTabScrollDuration; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'framework.dart'; + +part 'animation.dart'; +part 'async.dart'; +part 'carousel_controller.dart'; +part 'cupertino_tab_controller.dart'; +part 'debounced.dart'; +part 'draggable_scrollable_controller.dart'; +part 'expansion_tile_controller.dart'; +part 'fixed_extent_scroll_controller.dart'; +part 'focus_node.dart'; +part 'focus_scope_node.dart'; +part 'keep_alive.dart'; +part 'listenable.dart'; +part 'listenable_selector.dart'; +part 'misc.dart'; +part 'page_controller.dart'; +part 'platform_brightness.dart'; +part 'primitives.dart'; +part 'scroll_controller.dart'; +part 'search_controller.dart'; +part 'tab_controller.dart'; +part 'text_controller.dart'; +part 'transformation_controller.dart'; +part 'tree_sliver_controller.dart'; +part 'widget_states_controller.dart'; +part 'widgets_binding_observer.dart'; +part 'snapshot_controller.dart'; diff --git a/packages/flutter_hooks/lib/src/keep_alive.dart b/packages/flutter_hooks/lib/src/keep_alive.dart new file mode 100644 index 00000000..f7c5fba1 --- /dev/null +++ b/packages/flutter_hooks/lib/src/keep_alive.dart @@ -0,0 +1,70 @@ +part of 'hooks.dart'; + +/// Mark a widget using this hook as needing to stay alive even when it's in a +/// lazy list that would otherwise remove it. +/// +/// See also: +/// - [AutomaticKeepAlive] +/// - [KeepAlive] +void useAutomaticKeepAlive({ + bool wantKeepAlive = true, +}) { + use(_AutomaticKeepAliveHook( + wantKeepAlive: wantKeepAlive, + )); +} + +class _AutomaticKeepAliveHook extends Hook { + const _AutomaticKeepAliveHook({required this.wantKeepAlive}); + + final bool wantKeepAlive; + + @override + HookState createState() => + _AutomaticKeepAliveHookState(); +} + +class _AutomaticKeepAliveHookState + extends HookState { + KeepAliveHandle? _keepAliveHandle; + + void _ensureKeepAlive() { + _keepAliveHandle = KeepAliveHandle(); + KeepAliveNotification(_keepAliveHandle!).dispatch(context); + } + + void _releaseKeepAlive() { + _keepAliveHandle?.dispose(); + _keepAliveHandle = null; + } + + @override + void initHook() { + super.initHook(); + + if (hook.wantKeepAlive) { + _ensureKeepAlive(); + } + } + + @override + void build(BuildContext context) { + if (hook.wantKeepAlive && _keepAliveHandle == null) { + _ensureKeepAlive(); + } + } + + @override + void deactivate() { + if (_keepAliveHandle != null) { + _releaseKeepAlive(); + } + super.deactivate(); + } + + @override + Object? get debugValue => _keepAliveHandle; + + @override + String get debugLabel => 'useAutomaticKeepAlive'; +} diff --git a/packages/flutter_hooks/lib/src/listenable.dart b/packages/flutter_hooks/lib/src/listenable.dart new file mode 100644 index 00000000..39ce2edf --- /dev/null +++ b/packages/flutter_hooks/lib/src/listenable.dart @@ -0,0 +1,198 @@ +part of 'hooks.dart'; + +/// Subscribes to a [ValueListenable] and returns its value. +/// +/// See also: +/// * [ValueListenable], the created object +/// * [useListenable] +T useValueListenable(ValueListenable valueListenable) { + use(_UseValueListenableHook(valueListenable)); + return valueListenable.value; +} + +class _UseValueListenableHook extends _ListenableHook { + const _UseValueListenableHook(ValueListenable animation) + : super(animation); + + @override + _UseValueListenableStateHook createState() { + return _UseValueListenableStateHook(); + } +} + +class _UseValueListenableStateHook extends _ListenableStateHook { + @override + String get debugLabel => 'useValueListenable'; + + @override + Object? get debugValue => (hook.listenable as ValueListenable?)?.value; +} + +/// Subscribes to a [Listenable] and marks the widget as needing build +/// whenever the listener is called. +/// +/// See also: +/// * [Listenable] +/// * [useValueListenable], [useAnimation] +T useListenable(T listenable) { + use(_ListenableHook(listenable)); + return listenable; +} + +class _ListenableHook extends Hook { + const _ListenableHook(this.listenable); + + final Listenable? listenable; + + @override + _ListenableStateHook createState() => _ListenableStateHook(); +} + +class _ListenableStateHook extends HookState { + @override + void initHook() { + super.initHook(); + hook.listenable?.addListener(_listener); + } + + @override + void didUpdateHook(_ListenableHook oldHook) { + super.didUpdateHook(oldHook); + if (hook.listenable != oldHook.listenable) { + oldHook.listenable?.removeListener(_listener); + hook.listenable?.addListener(_listener); + } + } + + @override + void build(BuildContext context) {} + + void _listener() { + setState(() {}); + } + + @override + void dispose() { + hook.listenable?.removeListener(_listener); + } + + @override + String get debugLabel => 'useListenable'; + + @override + Object? get debugValue => hook.listenable; +} + +/// Creates a [ValueNotifier] that is automatically disposed. +/// +/// As opposed to `useState`, this hook does not subscribe to [ValueNotifier]. +/// This allows a more granular rebuild. +/// +/// See also: +/// * [ValueNotifier] +/// * [useValueListenable] +ValueNotifier useValueNotifier(T initialData, [List? keys]) { + return use( + _ValueNotifierHook( + initialData: initialData, + keys: keys, + ), + ); +} + +class _ValueNotifierHook extends Hook> { + const _ValueNotifierHook({List? keys, required this.initialData}) + : super(keys: keys); + + final T initialData; + + @override + _UseValueNotifierHookState createState() => + _UseValueNotifierHookState(); +} + +class _UseValueNotifierHookState + extends HookState, _ValueNotifierHook> { + late final notifier = ValueNotifier(hook.initialData); + + @override + ValueNotifier build(BuildContext context) { + return notifier; + } + + @override + void dispose() { + notifier.dispose(); + } + + @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 { + const _OnListenableChangeHook( + this.listenable, + this.listener, + ); + + final Listenable? listenable; + final VoidCallback listener; + + @override + _OnListenableChangeHookState createState() => _OnListenableChangeHookState(); +} + +class _OnListenableChangeHookState + extends HookState { + @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; +} diff --git a/packages/flutter_hooks/lib/src/listenable_selector.dart b/packages/flutter_hooks/lib/src/listenable_selector.dart new file mode 100644 index 00000000..0f0bf428 --- /dev/null +++ b/packages/flutter_hooks/lib/src/listenable_selector.dart @@ -0,0 +1,94 @@ +part of 'hooks.dart'; + +/// Rebuild only when there is a change in the selector result. +/// +/// The following example showcases If no text is entered, you will not be able to press the button. +/// ```dart +/// class Example extends HookWidget { +/// @override +/// Widget build(BuildContext context) { +/// final listenable = useTextEditingController(); +/// final bool textIsEmpty = +/// useListenableSelector(listenable, () => listenable.text.isEmpty); +/// return Column( +/// children: [ +/// TextField(controller: listenable), +/// ElevatedButton( +/// // If no text is entered, the button cannot be pressed +/// onPressed: textIsEmpty ? null : () => print("Button can be pressed!"), +/// child: Text("Button")), +/// ], +/// ); +/// } +/// } +/// ``` +/// + +R useListenableSelector( + Listenable? listenable, + R Function() selector, +) { + return use(_ListenableSelectorHook(listenable, selector)); +} + +class _ListenableSelectorHook extends Hook { + const _ListenableSelectorHook(this.listenable, this.selector); + + final Listenable? listenable; + final R Function() selector; + + @override + _ListenableSelectorHookState createState() => + _ListenableSelectorHookState(); +} + +class _ListenableSelectorHookState + extends HookState> { + late R _selectorResult = hook.selector(); + + @override + void initHook() { + super.initHook(); + hook.listenable?.addListener(_listener); + } + + @override + void didUpdateHook(_ListenableSelectorHook oldHook) { + super.didUpdateHook(oldHook); + + if (hook.selector != oldHook.selector) { + setState(() { + _selectorResult = hook.selector(); + }); + } + + if (hook.listenable != oldHook.listenable) { + oldHook.listenable?.removeListener(_listener); + hook.listenable?.addListener(_listener); + _selectorResult = hook.selector(); + } + } + + @override + R build(BuildContext context) => _selectorResult; + + void _listener() { + final latestSelectorResult = hook.selector(); + if (_selectorResult != latestSelectorResult) { + setState(() { + _selectorResult = latestSelectorResult; + }); + } + } + + @override + void dispose() { + hook.listenable?.removeListener(_listener); + } + + @override + String get debugLabel => 'useListenableSelector<$R>'; + + @override + bool get debugSkipValue => true; +} diff --git a/packages/flutter_hooks/lib/src/misc.dart b/packages/flutter_hooks/lib/src/misc.dart new file mode 100644 index 00000000..b461ce3d --- /dev/null +++ b/packages/flutter_hooks/lib/src/misc.dart @@ -0,0 +1,260 @@ +part of 'hooks.dart'; + +/// A store of mutable state that allows mutations by dispatching actions. +abstract class Store { + /// The current state. + /// + /// This value may change after a call to [dispatch]. + StateT get state; + + /// Dispatches an action. + /// + /// Actions are dispatched synchronously. + /// It is impossible to try to dispatch actions during `build`. + void dispatch(ActionT action); +} + +/// Composes an [Action] and a [State] to create a new [State]. +/// +/// [Reducer] must never return `null`, even if [state] or [action] are `null`. +typedef Reducer = State Function(State state, Action action); + +/// An alternative to [useState] for more complex states. +/// +/// [useReducer] manages a read only instance of state that can be updated +/// by dispatching actions which are interpreted by a [Reducer]. +/// +/// [reducer] is immediately called on first build with [initialAction] +/// and [initialState] as parameter. +/// +/// It is possible to change the [reducer] by calling [useReducer] +/// with a new [Reducer]. +/// +/// See also: +/// * [Reducer] +/// * [Store] +Store useReducer( + Reducer reducer, { + required StateT initialState, + required ActionT initialAction, +}) { + return use( + _ReducerHook( + reducer, + initialAction: initialAction, + initialState: initialState, + ), + ); +} + +class _ReducerHook extends Hook> { + const _ReducerHook( + this.reducer, { + required this.initialState, + required this.initialAction, + }); + + final Reducer reducer; + final StateT initialState; + final ActionT initialAction; + + @override + _ReducerHookState createState() => + _ReducerHookState(); +} + +class _ReducerHookState + extends HookState, _ReducerHook> + implements Store { + @override + late StateT state = hook.reducer(hook.initialState, hook.initialAction); + + @override + void initHook() { + super.initHook(); + // ignore: unnecessary_statements, Force the late variable to compute + state; + } + + @override + void dispatch(ActionT action) { + final newState = hook.reducer(state, action); + + if (state != newState) { + setState(() => state = newState); + } + } + + @override + Store build(BuildContext context) { + return this; + } + + @override + String get debugLabel => 'useReducer'; + + @override + Object? get debugValue => state; +} + +/// Returns the previous value passed to [usePrevious] (from the previous widget `build`). +T? usePrevious(T val) { + return use(_PreviousHook(val)); +} + +class _PreviousHook extends Hook { + const _PreviousHook(this.value); + + final T value; + + @override + _PreviousHookState createState() => _PreviousHookState(); +} + +class _PreviousHookState extends HookState> { + T? previous; + + @override + void didUpdateHook(_PreviousHook old) { + previous = old.value; + } + + @override + T? build(BuildContext context) => previous; + + @override + String get debugLabel => 'usePrevious'; + + @override + Object? get debugValue => previous; +} + +/// Runs the callback on every hot reload, +/// similar to `reassemble` in the stateful widgets. +/// +/// See also: +/// +/// * [State.reassemble] +void useReassemble(VoidCallback callback) { + assert(() { + use(_ReassembleHook(callback)); + return true; + }(), ''); +} + +class _ReassembleHook extends Hook { + const _ReassembleHook(this.callback); + + final VoidCallback callback; + + @override + _ReassembleHookState createState() => _ReassembleHookState(); +} + +class _ReassembleHookState extends HookState { + @override + void reassemble() { + super.reassemble(); + hook.callback(); + } + + @override + void build(BuildContext context) {} + + @override + String get debugLabel => 'useReassemble'; + + @override + bool get debugSkipValue => true; +} + +/// Returns an [IsMounted] object that you can use +/// to check if the [State] is mounted. +/// +/// ```dart +/// final isMounted = useIsMounted(); +/// useEffect((){ +/// myFuture.then((){ +/// if (isMounted()) { +/// // Do something +/// } +/// }); +/// }, []); +/// ``` +/// +/// See also: +/// * The [State.mounted] property. +@Deprecated( + "Use BuildContext.mounted instead if you're on Flutter 3.7.0 or greater", +) +IsMounted useIsMounted() { + return use(const _IsMountedHook()); +} + +// ignore: deprecated_member_use_from_same_package +class _IsMountedHook extends Hook { + const _IsMountedHook(); + + @override + _IsMountedHookState createState() => _IsMountedHookState(); +} + +// ignore: deprecated_member_use_from_same_package +class _IsMountedHookState extends HookState { + bool _mounted = true; + + @override + // ignore: deprecated_member_use_from_same_package + IsMounted build(BuildContext context) => _isMounted; + + bool _isMounted() => _mounted; + + @override + void dispose() { + _mounted = false; + super.dispose(); + } + + @override + String get debugLabel => 'useIsMounted'; + + @override + Object? get debugValue => _mounted; +} + +// ignore: deprecated_member_use_from_same_package +/// Used by [useIsMounted] to allow widgets to determine if the [Widget] is still +/// in the widget tree or not. +@Deprecated( + "Use BuildContext.mounted instead if you're on Flutter 3.7.0 or greater", +) +typedef IsMounted = bool Function(); + +/// Creates a [OverlayPortalController] that will be disposed automatically. +/// +/// See also: +/// - [OverlayPortalController] +OverlayPortalController useOverlayPortalController({ + List? keys, +}) { + return use(_OverlayPortalControllerHook(keys: keys)); +} + +class _OverlayPortalControllerHook extends Hook { + const _OverlayPortalControllerHook({List? keys}) : super(keys: keys); + + @override + HookState> + createState() => _OverlayPortalControllerHookState(); +} + +class _OverlayPortalControllerHookState + extends HookState { + final controller = OverlayPortalController(); + + @override + OverlayPortalController build(BuildContext context) => controller; + + @override + String get debugLabel => 'useOverlayPortalController'; +} diff --git a/packages/flutter_hooks/lib/src/page_controller.dart b/packages/flutter_hooks/lib/src/page_controller.dart new file mode 100644 index 00000000..bbe16734 --- /dev/null +++ b/packages/flutter_hooks/lib/src/page_controller.dart @@ -0,0 +1,66 @@ +part of 'hooks.dart'; + +/// Creates a [PageController] that will be disposed automatically. +/// +/// See also: +/// - [PageController] +PageController usePageController({ + int initialPage = 0, + bool keepPage = true, + double viewportFraction = 1.0, + ScrollControllerCallback? onAttach, + ScrollControllerCallback? onDetach, + List? keys, +}) { + return use( + _PageControllerHook( + initialPage: initialPage, + keepPage: keepPage, + viewportFraction: viewportFraction, + onAttach: onAttach, + onDetach: onDetach, + keys: keys, + ), + ); +} + +class _PageControllerHook extends Hook { + const _PageControllerHook({ + required this.initialPage, + required this.keepPage, + required this.viewportFraction, + this.onAttach, + this.onDetach, + List? keys, + }) : super(keys: keys); + + final int initialPage; + final bool keepPage; + final double viewportFraction; + final ScrollControllerCallback? onAttach; + final ScrollControllerCallback? onDetach; + + @override + HookState> createState() => + _PageControllerHookState(); +} + +class _PageControllerHookState + extends HookState { + late final controller = PageController( + initialPage: hook.initialPage, + keepPage: hook.keepPage, + viewportFraction: hook.viewportFraction, + onAttach: hook.onAttach, + onDetach: hook.onDetach, + ); + + @override + PageController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'usePageController'; +} diff --git a/packages/flutter_hooks/lib/src/platform_brightness.dart b/packages/flutter_hooks/lib/src/platform_brightness.dart new file mode 100644 index 00000000..291aff2f --- /dev/null +++ b/packages/flutter_hooks/lib/src/platform_brightness.dart @@ -0,0 +1,69 @@ +part of 'hooks.dart'; + +/// A callback triggered when the platform brightness changes. +typedef BrightnessCallback = FutureOr Function( + Brightness previous, + Brightness current, +); + +/// Returns the current platform [Brightness] value and rebuilds the widget when it changes. +Brightness usePlatformBrightness() { + return use(const _PlatformBrightnessHook(rebuildOnChange: true)); +} + +/// Listens to the platform [Brightness]. +void useOnPlatformBrightnessChange(BrightnessCallback onBrightnessChange) { + return use(_PlatformBrightnessHook(onBrightnessChange: onBrightnessChange)); +} + +class _PlatformBrightnessHook extends Hook { + const _PlatformBrightnessHook({ + this.rebuildOnChange = false, + this.onBrightnessChange, + }) : super(); + + final bool rebuildOnChange; + final BrightnessCallback? onBrightnessChange; + + @override + _PlatformBrightnessState createState() => _PlatformBrightnessState(); +} + +class _PlatformBrightnessState + extends HookState + with + // ignore: prefer_mixin + WidgetsBindingObserver { + late Brightness _brightness; + + @override + String? get debugLabel => 'usePlatformBrightness'; + + @override + void initHook() { + super.initHook(); + _brightness = WidgetsBinding.instance.platformDispatcher.platformBrightness; + WidgetsBinding.instance.addObserver(this); + } + + @override + Brightness build(BuildContext context) => _brightness; + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangePlatformBrightness() { + super.didChangePlatformBrightness(); + final _previous = _brightness; + _brightness = WidgetsBinding.instance.platformDispatcher.platformBrightness; + hook.onBrightnessChange?.call(_previous, _brightness); + + if (hook.rebuildOnChange) { + setState(() {}); + } + } +} diff --git a/packages/flutter_hooks/lib/src/primitives.dart b/packages/flutter_hooks/lib/src/primitives.dart new file mode 100644 index 00000000..70ba0d90 --- /dev/null +++ b/packages/flutter_hooks/lib/src/primitives.dart @@ -0,0 +1,301 @@ +part of 'hooks.dart'; + +/// A class that stores a single value. +/// +/// It is typically created by [useRef]. +class ObjectRef { + /// A class that stores a single value. + /// + /// It is typically created by [useRef]. + ObjectRef(this.value); + + /// A mutable property that will be preserved across rebuilds. + /// + /// Updating this property will not cause widgets to rebuild. + T value; +} + +/// Creates an object that contains a single mutable property. +/// +/// Mutating the object's property has no effect. +/// This is useful for sharing state across `build` calls, without causing +/// unnecessary rebuilds. +ObjectRef useRef(T initialValue) { + return useMemoized(() => ObjectRef(initialValue)); +} + +/// Cache a function across rebuilds based on a list of keys. +/// +/// This is syntax sugar for [useMemoized], so that instead of: +/// +/// ```dart +/// final cachedFunction = useMemoized(() => () { +/// print('doSomething'); +/// }, [key]); +/// ``` +/// +/// we can directly do: +/// +/// ```dart +/// final cachedFunction = useCallback(() { +/// print('doSomething'); +/// }, [key]); +/// ``` +T useCallback( + T callback, [ + List keys = const [], +]) { + return useMemoized(() => callback, keys); +} + +/// Caches the instance of a complex object. +/// +/// [useMemoized] will immediately call [valueBuilder] on first call and store its result. +/// Later, when the [HookWidget] rebuilds, the call to [useMemoized] will return the previously created instance without calling [valueBuilder]. +/// +/// A subsequent call of [useMemoized] with different [keys] will re-invoke the function to create a new instance. +T useMemoized( + T Function() valueBuilder, [ + List keys = const [], +]) { + return use( + _MemoizedHook( + valueBuilder, + keys: keys, + ), + ); +} + +class _MemoizedHook extends Hook { + const _MemoizedHook( + this.valueBuilder, { + required List keys, + }) : super(keys: keys); + + final T Function() valueBuilder; + + @override + _MemoizedHookState createState() => _MemoizedHookState(); +} + +class _MemoizedHookState extends HookState> { + late final T value = hook.valueBuilder(); + + @override + T build(BuildContext context) { + return value; + } + + @override + String get debugLabel => 'useMemoized<$T>'; +} + +/// Watches a value and triggers a callback whenever the value changed. +/// +/// [useValueChanged] takes a [valueChange] callback and calls it whenever [value] changed. +/// [valueChange] will _not_ be called on the first [useValueChanged] call. +/// +/// [useValueChanged] can also be used to interpolate +/// Whenever [useValueChanged] is called with a different [value], calls [valueChange]. +/// The value returned by [useValueChanged] is the latest returned value of [valueChange] or `null`. +/// +/// The following example calls [AnimationController.forward] whenever `color` changes +/// +/// ```dart +/// AnimationController controller; +/// Color color; +/// +/// useValueChanged(color, (_, __) { +/// controller.forward(); +/// }); +/// ``` +R? useValueChanged( + T value, + R? Function(T oldValue, R? oldResult) valueChange, +) { + return use(_ValueChangedHook(value, valueChange)); +} + +class _ValueChangedHook extends Hook { + const _ValueChangedHook(this.value, this.valueChanged); + + final R? Function(T oldValue, R? oldResult) valueChanged; + final T value; + + @override + _ValueChangedHookState createState() => _ValueChangedHookState(); +} + +class _ValueChangedHookState + extends HookState> { + R? _result; + + @override + void didUpdateHook(_ValueChangedHook oldHook) { + super.didUpdateHook(oldHook); + if (hook.value != oldHook.value) { + _result = hook.valueChanged(oldHook.value, _result); + } + } + + @override + R? build(BuildContext context) { + return _result; + } + + @override + String get debugLabel => 'useValueChanged'; + + @override + bool get debugHasShortDescription => false; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('value', hook.value)); + properties.add(DiagnosticsProperty('result', _result)); + } +} + +/// A function called when the state of a widget is destroyed. +typedef Dispose = void Function(); + +/// Useful for side-effects and optionally canceling them. +/// +/// [useEffect] is called synchronously on every `build`, unless +/// [keys] is specified. In which case [useEffect] is called again only if +/// any value inside [keys] has changed. +/// +/// It takes an [effect] callback and calls it synchronously. +/// That [effect] may optionally return a function, which will be called when the [effect] is called again or if the widget is disposed. +/// +/// By default [effect] is called on every `build` call, unless [keys] is specified. +/// In which case, [effect] is called once on the first [useEffect] call and whenever something within [keys] change/ +/// +/// The following example call [useEffect] to subscribes to a [Stream] and cancels the subscription when the widget is disposed. +/// Also if the [Stream] changes, it will cancel the listening on the previous [Stream] and listen to the new one. +/// +/// ```dart +/// Stream stream; +/// useEffect(() { +/// final subscription = stream.listen(print); +/// // This will cancel the subscription when the widget is disposed +/// // or if the callback is called again. +/// return subscription.cancel; +/// }, +/// // when the stream changes, useEffect will call the callback again. +/// [stream], +/// ); +/// ``` +void useEffect(Dispose? Function() effect, [List? keys]) { + use(_EffectHook(effect, keys)); +} + +class _EffectHook extends Hook { + const _EffectHook(this.effect, [List? keys]) : super(keys: keys); + + final Dispose? Function() effect; + + @override + _EffectHookState createState() => _EffectHookState(); +} + +class _EffectHookState extends HookState { + Dispose? disposer; + + @override + void initHook() { + super.initHook(); + scheduleEffect(); + } + + @override + void didUpdateHook(_EffectHook oldHook) { + super.didUpdateHook(oldHook); + + if (hook.keys == null) { + disposer?.call(); + scheduleEffect(); + } + } + + @override + void build(BuildContext context) {} + + @override + void dispose() => disposer?.call(); + + void scheduleEffect() { + disposer = hook.effect(); + } + + @override + String get debugLabel => 'useEffect'; + + @override + bool get debugSkipValue => true; +} + +/// Creates a variable and subscribes to it. +/// +/// Whenever [ValueNotifier.value] updates, it will mark the caller [HookWidget] +/// as needing a build. +/// On the first call, it initializes [ValueNotifier] to [initialData]. [initialData] is ignored +/// on subsequent calls. +/// +/// The following example showcases a basic counter application: +/// +/// ```dart +/// class Counter extends HookWidget { +/// @override +/// Widget build(BuildContext context) { +/// final counter = useState(0); +/// +/// return GestureDetector( +/// // automatically triggers a rebuild of the Counter widget +/// onTap: () => counter.value++, +/// child: Text(counter.value.toString()), +/// ); +/// } +/// } +/// ``` +/// +/// See also: +/// +/// * [ValueNotifier] +/// * [useStreamController], an alternative to [ValueNotifier] for state. +ValueNotifier useState(T initialData) { + return use(_StateHook(initialData: initialData)); +} + +class _StateHook extends Hook> { + const _StateHook({required this.initialData}); + + final T initialData; + + @override + _StateHookState createState() => _StateHookState(); +} + +class _StateHookState extends HookState, _StateHook> { + late final _state = ValueNotifier(hook.initialData) + ..addListener(_listener); + + @override + void dispose() { + _state.dispose(); + } + + @override + ValueNotifier build(BuildContext context) => _state; + + void _listener() { + setState(() {}); + } + + @override + Object? get debugValue => _state.value; + + @override + String get debugLabel => 'useState<$T>'; +} diff --git a/packages/flutter_hooks/lib/src/scroll_controller.dart b/packages/flutter_hooks/lib/src/scroll_controller.dart new file mode 100644 index 00000000..1d182cf6 --- /dev/null +++ b/packages/flutter_hooks/lib/src/scroll_controller.dart @@ -0,0 +1,66 @@ +part of 'hooks.dart'; + +/// Creates [ScrollController] that will be disposed automatically. +/// +/// See also: +/// - [ScrollController] +ScrollController useScrollController({ + double initialScrollOffset = 0.0, + bool keepScrollOffset = true, + String? debugLabel, + ScrollControllerCallback? onAttach, + ScrollControllerCallback? onDetach, + List? keys, +}) { + return use( + _ScrollControllerHook( + initialScrollOffset: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + debugLabel: debugLabel, + onAttach: onAttach, + onDetach: onDetach, + keys: keys, + ), + ); +} + +class _ScrollControllerHook extends Hook { + const _ScrollControllerHook({ + required this.initialScrollOffset, + required this.keepScrollOffset, + this.debugLabel, + this.onAttach, + this.onDetach, + List? keys, + }) : super(keys: keys); + + final double initialScrollOffset; + final bool keepScrollOffset; + final String? debugLabel; + final ScrollControllerCallback? onAttach; + final ScrollControllerCallback? onDetach; + + @override + HookState> createState() => + _ScrollControllerHookState(); +} + +class _ScrollControllerHookState + extends HookState { + late final controller = ScrollController( + initialScrollOffset: hook.initialScrollOffset, + keepScrollOffset: hook.keepScrollOffset, + debugLabel: hook.debugLabel, + onAttach: hook.onAttach, + onDetach: hook.onDetach, + ); + + @override + ScrollController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useScrollController'; +} diff --git a/packages/flutter_hooks/lib/src/search_controller.dart b/packages/flutter_hooks/lib/src/search_controller.dart new file mode 100644 index 00000000..454ea6b0 --- /dev/null +++ b/packages/flutter_hooks/lib/src/search_controller.dart @@ -0,0 +1,31 @@ +part of 'hooks.dart'; + +/// Creates a [SearchController] that will be disposed automatically. +/// +/// See also: +/// - [SearchController] +SearchController useSearchController({List? keys}) { + return use(_SearchControllerHook(keys: keys)); +} + +class _SearchControllerHook extends Hook { + const _SearchControllerHook({List? keys}) : super(keys: keys); + + @override + HookState> createState() => + _SearchControllerHookState(); +} + +class _SearchControllerHookState + extends HookState { + final controller = SearchController(); + + @override + String get debugLabel => 'useSearchController'; + + @override + SearchController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); +} diff --git a/packages/flutter_hooks/lib/src/snapshot_controller.dart b/packages/flutter_hooks/lib/src/snapshot_controller.dart new file mode 100644 index 00000000..d5b69ec7 --- /dev/null +++ b/packages/flutter_hooks/lib/src/snapshot_controller.dart @@ -0,0 +1,62 @@ +part of 'hooks.dart'; + +/// Creates and disposes a [SnapshotController]. +/// +/// Note that [allowSnapshotting] must be set to `true` +/// in order for this controller to actually do anything. +/// This is consistent with [SnapshotController.new]. +/// +/// If [allowSnapshotting] changes on subsequent calls to [useSnapshotController], +/// [SnapshotController.allowSnapshotting] will be called to update accordingly. +/// +/// ```dart +/// final controller = useSnapshotController(allowSnapshotting: true); +/// // is equivalent to +/// final controller = useSnapshotController(); +/// controller.allowSnapshotting = true; +/// ``` +/// +/// See also: +/// - [SnapshotController] +SnapshotController useSnapshotController({ + bool allowSnapshotting = false, +}) { + return use( + _SnapshotControllerHook( + allowSnapshotting: allowSnapshotting, + ), + ); +} + +class _SnapshotControllerHook extends Hook { + const _SnapshotControllerHook({ + required this.allowSnapshotting, + }); + + final bool allowSnapshotting; + + @override + HookState> createState() => + _SnapshotControllerHookState(); +} + +class _SnapshotControllerHookState + extends HookState { + late final controller = + SnapshotController(allowSnapshotting: hook.allowSnapshotting); + + @override + void didUpdateHook(_SnapshotControllerHook oldHook) { + super.didUpdateHook(oldHook); + controller.allowSnapshotting = hook.allowSnapshotting; + } + + @override + SnapshotController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useSnapshotController'; +} diff --git a/packages/flutter_hooks/lib/src/tab_controller.dart b/packages/flutter_hooks/lib/src/tab_controller.dart new file mode 100644 index 00000000..eef5f0ac --- /dev/null +++ b/packages/flutter_hooks/lib/src/tab_controller.dart @@ -0,0 +1,63 @@ +part of 'hooks.dart'; + +/// Creates a [TabController] that will be disposed automatically. +/// +/// See also: +/// - [TabController] +TabController useTabController({ + required int initialLength, + Duration? animationDuration = kTabScrollDuration, + TickerProvider? vsync, + int initialIndex = 0, + List? keys, +}) { + vsync ??= useSingleTickerProvider(keys: keys); + + return use( + _TabControllerHook( + vsync: vsync, + length: initialLength, + initialIndex: initialIndex, + animationDuration: animationDuration, + keys: keys, + ), + ); +} + +class _TabControllerHook extends Hook { + const _TabControllerHook({ + required this.length, + required this.vsync, + required this.initialIndex, + required this.animationDuration, + super.keys, + }); + + final int length; + final TickerProvider vsync; + final int initialIndex; + final Duration? animationDuration; + + @override + HookState> createState() => + _TabControllerHookState(); +} + +class _TabControllerHookState + extends HookState { + late final controller = TabController( + length: hook.length, + initialIndex: hook.initialIndex, + animationDuration: hook.animationDuration, + vsync: hook.vsync, + ); + + @override + TabController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useTabController'; +} diff --git a/packages/flutter_hooks/lib/src/text_controller.dart b/packages/flutter_hooks/lib/src/text_controller.dart new file mode 100644 index 00000000..e8f8310d --- /dev/null +++ b/packages/flutter_hooks/lib/src/text_controller.dart @@ -0,0 +1,94 @@ +part of 'hooks.dart'; + +class _TextEditingControllerHookCreator { + const _TextEditingControllerHookCreator(); + + /// Creates a [TextEditingController] that will be disposed automatically. + /// + /// The [text] parameter can be used to set the initial value of the + /// controller. + TextEditingController call({String? text, List? keys}) { + return use(_TextEditingControllerHook(text, keys)); + } + + /// Creates a [TextEditingController] from the initial [value] that will + /// be disposed automatically. + TextEditingController fromValue( + TextEditingValue value, [ + List? keys, + ]) { + return use(_TextEditingControllerHook.fromValue(value, keys)); + } +} + +/// Creates a [TextEditingController], either via an initial text or an initial +/// [TextEditingValue]. +/// +/// To use a [TextEditingController] with an optional initial text, use: +/// ```dart +/// final controller = useTextEditingController(text: 'initial text'); +/// ``` +/// +/// To use a [TextEditingController] with an optional initial value, use: +/// ```dart +/// final controller = useTextEditingController +/// .fromValue(TextEditingValue.empty); +/// ``` +/// +/// Changing the text or initial value after the widget has been built has no +/// effect whatsoever. To update the value in a callback, for instance after a +/// button was pressed, use the [TextEditingController.text] or +/// [TextEditingController.value] setters. To have the [TextEditingController] +/// reflect changing values, you can use [useEffect]. This example will update +/// the [TextEditingController.text] whenever a provided [ValueListenable] +/// changes: +/// ```dart +/// final controller = useTextEditingController(); +/// final update = useValueListenable(myTextControllerUpdates); +/// +/// useEffect(() { +/// controller.text = update; +/// }, [update]); +/// ``` +/// +/// See also: +/// - [TextEditingController], which this hook creates. +const useTextEditingController = _TextEditingControllerHookCreator(); + +class _TextEditingControllerHook extends Hook { + const _TextEditingControllerHook( + this.initialText, [ + List? keys, + ]) : initialValue = null, + super(keys: keys); + + const _TextEditingControllerHook.fromValue( + TextEditingValue this.initialValue, [ + List? keys, + ]) : initialText = null, + super(keys: keys); + + final String? initialText; + final TextEditingValue? initialValue; + + @override + _TextEditingControllerHookState createState() { + return _TextEditingControllerHookState(); + } +} + +class _TextEditingControllerHookState + extends HookState { + late final _controller = hook.initialValue != null + ? TextEditingController.fromValue(hook.initialValue) + : TextEditingController(text: hook.initialText); + + @override + TextEditingController build(BuildContext context) => _controller; + + @override + void dispose() => _controller.dispose(); + + @override + String get debugLabel => 'useTextEditingController'; +} diff --git a/packages/flutter_hooks/lib/src/transformation_controller.dart b/packages/flutter_hooks/lib/src/transformation_controller.dart new file mode 100644 index 00000000..d563d17d --- /dev/null +++ b/packages/flutter_hooks/lib/src/transformation_controller.dart @@ -0,0 +1,44 @@ +part of 'hooks.dart'; + +/// Creates and disposes a [TransformationController]. +/// +/// See also: +/// - [TransformationController] +TransformationController useTransformationController({ + Matrix4? initialValue, + List? keys, +}) { + return use( + _TransformationControllerHook( + initialValue: initialValue, + keys: keys, + ), + ); +} + +class _TransformationControllerHook extends Hook { + const _TransformationControllerHook({ + required this.initialValue, + List? keys, + }) : super(keys: keys); + + final Matrix4? initialValue; + + @override + HookState> + createState() => _TransformationControllerHookState(); +} + +class _TransformationControllerHookState + extends HookState { + late final controller = TransformationController(hook.initialValue); + + @override + TransformationController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useTransformationController'; +} diff --git a/packages/flutter_hooks/lib/src/tree_sliver_controller.dart b/packages/flutter_hooks/lib/src/tree_sliver_controller.dart new file mode 100644 index 00000000..0accf1ab --- /dev/null +++ b/packages/flutter_hooks/lib/src/tree_sliver_controller.dart @@ -0,0 +1,28 @@ +part of 'hooks.dart'; + +/// Creates a [TreeSliverController] that will be disposed automatically. +/// +/// See also: +/// - [TreeSliverController] +TreeSliverController useTreeSliverController() { + return use(const _TreeSliverControllerHook()); +} + +class _TreeSliverControllerHook extends Hook { + const _TreeSliverControllerHook(); + + @override + HookState> createState() => + _TreeSliverControllerHookState(); +} + +class _TreeSliverControllerHookState + extends HookState { + final controller = TreeSliverController(); + + @override + String get debugLabel => 'useTreeSliverController'; + + @override + TreeSliverController build(BuildContext context) => controller; +} diff --git a/packages/flutter_hooks/lib/src/widget_states_controller.dart b/packages/flutter_hooks/lib/src/widget_states_controller.dart new file mode 100644 index 00000000..eab0ea2b --- /dev/null +++ b/packages/flutter_hooks/lib/src/widget_states_controller.dart @@ -0,0 +1,44 @@ +part of 'hooks.dart'; + +/// Creates a [WidgetStatesController] that will be disposed automatically. +/// +/// See also: +/// - [WidgetStatesController] +WidgetStatesController useWidgetStatesController({ + Set? values, + List? keys, +}) { + return use( + _WidgetStatesControllerHook( + values: values, + keys: keys, + ), + ); +} + +class _WidgetStatesControllerHook extends Hook { + const _WidgetStatesControllerHook({ + required this.values, + super.keys, + }); + + final Set? values; + + @override + HookState> + createState() => _WidgetStateControllerHookState(); +} + +class _WidgetStateControllerHookState + extends HookState { + late final controller = WidgetStatesController(hook.values); + + @override + WidgetStatesController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useWidgetStatesController'; +} diff --git a/packages/flutter_hooks/lib/src/widgets_binding_observer.dart b/packages/flutter_hooks/lib/src/widgets_binding_observer.dart new file mode 100644 index 00000000..2ff9bb0d --- /dev/null +++ b/packages/flutter_hooks/lib/src/widgets_binding_observer.dart @@ -0,0 +1,65 @@ +part of 'hooks.dart'; + +/// A callback triggered when the app life cycle changes. +typedef LifecycleCallback = FutureOr Function( + AppLifecycleState? previous, + AppLifecycleState current, +); + +/// Returns the current [AppLifecycleState] value and rebuilds the widget when it changes. +AppLifecycleState? useAppLifecycleState() { + return use(const _AppLifecycleHook(rebuildOnChange: true)); +} + +/// Listens to the [AppLifecycleState]. +void useOnAppLifecycleStateChange(LifecycleCallback? onStateChanged) { + use(_AppLifecycleHook(onStateChanged: onStateChanged)); +} + +class _AppLifecycleHook extends Hook { + const _AppLifecycleHook({ + this.rebuildOnChange = false, + this.onStateChanged, + }) : super(); + + final bool rebuildOnChange; + final LifecycleCallback? onStateChanged; + + @override + __AppLifecycleStateState createState() => __AppLifecycleStateState(); +} + +class __AppLifecycleStateState + extends HookState + with + // ignore: prefer_mixin + WidgetsBindingObserver { + AppLifecycleState? _state; + + @override + void initHook() { + super.initHook(); + _state = WidgetsBinding.instance.lifecycleState; + WidgetsBinding.instance.addObserver(this); + } + + @override + AppLifecycleState? build(BuildContext context) => _state; + + @override + void dispose() { + super.dispose(); + WidgetsBinding.instance.removeObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final previous = _state; + _state = state; + hook.onStateChanged?.call(previous, state); + + if (hook.rebuildOnChange) { + setState(() {}); + } + } +} diff --git a/packages/flutter_hooks/pubspec.yaml b/packages/flutter_hooks/pubspec.yaml new file mode 100644 index 00000000..c2731f78 --- /dev/null +++ b/packages/flutter_hooks/pubspec.yaml @@ -0,0 +1,19 @@ +name: flutter_hooks +description: A flutter implementation of React hooks. It adds a new kind of widget with enhanced code reuse. +homepage: https://github.com/rrousselGit/flutter_hooks +repository: https://github.com/rrousselGit/flutter_hooks/tree/master/packages/flutter_hooks +issue_tracker: https://github.com/rrousselGit/flutter_hooks/issues +version: 0.21.3+1 + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=3.32.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^5.0.16 diff --git a/packages/flutter_hooks/resources/translations/ja_jp/README.md b/packages/flutter_hooks/resources/translations/ja_jp/README.md new file mode 100644 index 00000000..f05f58c0 --- /dev/null +++ b/packages/flutter_hooks/resources/translations/ja_jp/README.md @@ -0,0 +1,378 @@ +[English](https://github.com/rrousselGit/flutter_hooks/blob/master/README.md) | [Português](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/pt_br/README.md) | [한국어](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/ko_kr/README.md) | [简体中文](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/zh_cn/README.md) | [日本語](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/ja_jp/README.md) + +[![Build](https://github.com/rrousselGit/flutter_hooks/workflows/Build/badge.svg)](https://github.com/rrousselGit/flutter_hooks/actions?query=workflow%3ABuild) [![codecov](https://codecov.io/gh/rrousselGit/flutter_hooks/branch/master/graph/badge.svg)](https://codecov.io/gh/rrousselGit/flutter_hooks) [![pub package](https://img.shields.io/pub/v/flutter_hooks.svg)](https://pub.dev/packages/flutter_hooks) [![pub package](https://img.shields.io/badge/Awesome-Flutter-blue.svg?longCache=true&style=flat-square)](https://github.com/Solido/awesome-flutter) +Discord + + + +# Flutter Hooks + +React hooksのFlutter実装: https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889 + +Hooksは、`Widget`のライフサイクルを管理する新しい種類のオブジェクトです。これらは、ウィジェット間のコード共有を増やし、重複を排除するために存在します。 + +## 動機 + +`StatefulWidget`には大きな問題があります。それは、`initState`や`dispose`のロジックを再利用するのが非常に難しいことです。明らかな例は`AnimationController`です: + +```dart +class Example extends StatefulWidget { + const Example({super.key, required this.duration}); + + final Duration duration; + + @override + _ExampleState createState() => _ExampleState(); +} + +class _ExampleState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: widget.duration); + } + + @override + void didUpdateWidget(Example oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.duration != oldWidget.duration) { + _controller.duration = widget.duration; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container(); + } +} +``` + +`AnimationController`を使用したいすべてのウィジェットは、このロジックのほとんどを最初から再実装する必要があります。これはもちろん望ましくありません。 + +Dartのミックスインはこの問題を部分的に解決できますが、他の問題も抱えています: + +- 特定のミックスインはクラスごとに1回しか使用できません。 +- ミックスインとクラスは同じオブジェクトを共有します。\ + これは、2つのミックスインが同じ名前の変数を定義した場合、結果がコンパイルエラーから未知の動作までさまざまであることを意味します。 + +--- + +このライブラリは第三の解決策を提案します: + +```dart +class Example extends HookWidget { + const Example({super.key, required this.duration}); + + final Duration duration; + + @override + Widget build(BuildContext context) { + final controller = useAnimationController(duration: duration); + return Container(); + } +} +``` + +このコードは前の例と機能的に同等です。`AnimationController`をdisposeし、`Example.duration`が変更されたときにその`duration`を更新します。 +しかし、あなたはおそらくこう思っているでしょう: + +> すべてのロジックはどこに行ったのですか? + +そのロジックは、このライブラリに直接含まれている関数`useAnimationController`に移動されました([既存のフック](https://github.com/rrousselGit/flutter_hooks#existing-hooks)を参照)。これが私たちが呼ぶ_Hook_です。 + +Hooksは、いくつかの特性を持つ新しい種類のオブジェクトです: + +- それらは、`Hooks`をミックスインしたウィジェットの`build`メソッドでのみ使用できます。 +- 同じフックを任意の回数再利用できます。 + 次のコードは、2つの独立した`AnimationController`を定義し、ウィジェットが再構築されるときに正しく保持されます。 + + ```dart + Widget build(BuildContext context) { + final controller = useAnimationController(); + final controller2 = useAnimationController(); + return Container(); + } + ``` + +- フックは互いに完全に独立しており、ウィジェットからも独立しています。\ + これは、それらを簡単にパッケージに抽出し、他の人が使用できるように[pub](https://pub.dev/)に公開できることを意味します。 + +## 原則 + +`State`と同様に、フックは`Widget`の`Element`に保存されます。ただし、1つの`State`を持つ代わりに、`Element`は`List`を保存します。次に、`Hook`を使用するためには、`Hook.use`を呼び出す必要があります。 + +`use`によって返されるフックは、呼び出された回数に基づいています。 +最初の呼び出しは最初のフックを返し、2回目の呼び出しは2番目のフックを返し、3回目の呼び出しは3番目のフックを返します。 + +このアイデアがまだ不明確な場合、フックの単純な実装は次のようになります: + +```dart +class HookElement extends Element { + List _hooks; + int _hookIndex; + + T use(Hook hook) => _hooks[_hookIndex++].build(this); + + @override + performRebuild() { + _hookIndex = 0; + super.performRebuild(); + } +} +``` + +フックがどのように実装されているかについての詳細な説明については、Reactでの実装方法に関する素晴らしい記事があります:https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e + +## 規則 + +フックがそのインデックスから取得されるため、いくつかの規則を守る必要があります: + +### フックの名前を常に`use`で始める: + +```dart +Widget build(BuildContext context) { + // `use`で始まる、良い名前 + useMyHook(); + // `use`で始まらない、これはフックではないと誤解される可能性があります + myHook(); + // .... +} +``` + +### フックを無条件に呼び出す + +```dart +Widget build(BuildContext context) { + useMyHook(); + // .... +} +``` + +### `use`を条件にラップしない + +```dart +Widget build(BuildContext context) { + if (condition) { + useMyHook(); + } + // .... +} +``` + +--- + +### ホットリロードについて + +フックがそのインデックスから取得されるため、リファクタリング中のホットリロードがアプリケーションを壊すと考えるかもしれません。 + +しかし心配しないでください、`HookWidget`はフックで動作するようにデフォルトのホットリロード動作をオーバーライドします。それでも、フックの状態がリセットされる場合があります。 + +次のフックのリストを考えてみましょう: + +```dart +useA(); +useB(0); +useC(); +``` + +次に、ホットリロードを実行した後に`HookB`のパラメータを編集したと考えます: + +```dart +useA(); +useB(42); +useC(); +``` + +ここではすべてが正常に動作し、すべてのフックがその状態を保持します。 + +次に、`HookB`を削除したと考えます。次のようになります: + +```dart +useA(); +useC(); +``` + +この場合、`HookA`はその状態を保持しますが、`HookC`はハードリセットされます。 +これは、リファクタリング後にホットリロードが実行されると、最初の影響を受けた行の後のすべてのフックがdisposeされるためです。 +したがって、`HookC`が`HookB`の後に配置されていたため、disposeされます。 + +## フックの作成方法 + +フックを作成する方法は2つあります: + +- 関数 + + 関数はフックを書く最も一般的な方法です。フックが本質的に合成可能であるため、関数は他のフックを組み合わせてより複雑なカスタムフックを作成できます。慣例として、これらの関数は`use`で始まります。 + + 次のコードは、変数を作成し、その値が変更されるたびにコンソールにログを記録するカスタムフックを定義します: + + ```dart + ValueNotifier useLoggedState([T initialData]) { + final result = useState(initialData); + useValueChanged(result.value, (_, __) { + print(result.value); + }); + return result; + } + ``` + +- クラス + + フックが複雑すぎる場合は、`Hook`を拡張するクラスに変換することができます。これを使用して`Hook.use`を使用できます。\ + クラスとして、フックは`State`クラスと非常に似ており、ウィジェットのライフサイクルや`initHook`、`dispose`、`setState`などのメソッドにアクセスできます。 + + 通常、クラスを次のように関数の下に隠すのが良いプラクティスです: + + ```dart + Result useMyHook() { + return use(const _TimeAlive()); + } + ``` + + 次のコードは、`State`が生存していた合計時間をそのdispose時に出力するフックを定義します。 + + ```dart + class _TimeAlive extends Hook { + const _TimeAlive(); + + @override + _TimeAliveState createState() => _TimeAliveState(); + } + + class _TimeAliveState extends HookState { + DateTime start; + + @override + void initHook() { + super.initHook(); + start = DateTime.now(); + } + + @override + void build(BuildContext context) {} + + @override + void dispose() { + print(DateTime.now().difference(start)); + super.dispose(); + } + } + ``` + +## 既存のフック + +Flutter_Hooksには、再利用可能なフックのリストが既に含まれており、さまざまな種類に分かれています: + +### プリミティブ + +ウィジェットのさまざまなライフサイクルと対話する低レベルのフックのセット + +| 名前 | 説明 | +| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| [useEffect](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useEffect.html) | 副作用に役立ち、オプションでそれらをキャンセルします。 | +| [useState](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useState.html) | 変数を作成し、それを購読します。 | +| [useMemoized](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useMemoized.html) | 複雑なオブジェクトのインスタンスをキャッシュします。 | +| [useRef](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useRef.html) | 単一の可変プロパティを含むオブジェクトを作成します。 | +| [useCallback](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useCallback.html) | 関数インスタンスをキャッシュします。 | +| [useContext](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useContext.html) | ビルド中の`HookWidget`の`BuildContext`を取得します。 | +| [useValueChanged](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueChanged.html) | 値を監視し、その値が変更されるたびにコールバックをトリガーします。 | + +### オブジェクトバインディング + +このカテゴリのフックは、既存のFlutter/Dartオブジェクトをフックで操作します。 +それらはオブジェクトの作成/更新/破棄を担当します。 + +#### dart:async関連のフック: + +| 名前 | 説明 | +| ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| [useStream](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useStream.html) | `Stream`を購読し、その現在の状態を`AsyncSnapshot`として返します。 | +| [useStreamController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useStreamController.html) | 自動的に破棄される`StreamController`を作成します。 | +| [useOnStreamChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnStreamChange.html) | `Stream`を購読し、ハンドラを登録し、`StreamSubscription`を返します。 | +| [useFuture](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFuture.html) | `Future`を購読し、その現在の状態を`AsyncSnapshot`として返します。 | + +#### アニメーション関連のフック: + +| 名前 | 説明 | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| [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`を購読し、その値を返します。 | + +#### Listenable関連のフック: + +| 名前 | 説明 | +| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| [useListenable](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useListenable.html) | `Listenable`を購読し、リスナーが呼び出されるたびにウィジェットをビルドが必要なものとしてマークします。 | +| [useListenableSelector](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useListenableSelector.html) | `useListenable`に似ていますが、UIの再構築をフィルタリングできます。 | +| [useValueNotifier](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueNotifier.html) | 自動的に破棄される`ValueNotifier`を作成します。 | +| [useValueListenable](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueListenable.html) | `ValueListenable`を購読し、その値を返します。 | +| [useOnListenableChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnListenableChange.html) | 指定されたリスナーコールバックを`Listenable`に追加し、自動的に削除されます。 | + +#### その他のフック: + +特定のテーマを持たない一連のフック。 + +| 名前 | 説明 | +| -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [useReducer](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useReducer.html) | より複雑な状態のための`useState`の代替。 | +| [usePrevious](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/usePrevious.html) | [usePrevious]に渡された前の引数を返します。 | +| [useTextEditingController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTextEditingController-constant.html) | `TextEditingController`を作成します。 | +| [useFocusNode](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFocusNode.html) | `FocusNode`を作成します。 | +| [useTabController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTabController.html) | `TabController`を作成し、破棄します。 | +| [useScrollController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useScrollController.html) | `ScrollController`を作成し、破棄します。 | +| [usePageController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/usePageController.html) | `PageController`を作成し、破棄します。 | +| [useFixedExtentScrollController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFixedExtentScrollController.html) | `FixedExtentScrollController`を作成し、破棄します。 | +| [useAppLifecycleState](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAppLifecycleState.html) | 現在の`AppLifecycleState`を返し、変更時にウィジェットを再構築します。 | +| [useOnAppLifecycleStateChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnAppLifecycleStateChange.html) | `AppLifecycleState`の変更を監視し、変更時にコールバックをトリガーします。 | +| [useTransformationController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTransformationController.html) | `TransformationController`を作成し、破棄します。 | +| [useIsMounted](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useIsMounted.html) | フックのための`State.mounted`の同等物。 | +| [useAutomaticKeepAlive](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAutomaticKeepAlive.html) | フックのための`AutomaticKeepAlive`ウィジェットの同等物。 | +| [useOnPlatformBrightnessChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnPlatformBrightnessChange.html) | プラットフォームの`Brightness`の変更を監視し、変更時にコールバックをトリガーします。 | +| [useSearchController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useSearchController.html) | `SearchController`を作成し、破棄します。 | +| [useWidgetStatesController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useWidgetStatesController.html) | `WidgetStatesController`を作成し、破棄します。 | +| [useExpansionTileController](https://api.flutter.dev/flutter/material/ExpansionTileController-class.html) | `ExpansionTileController`を作成します。 | +| [useDebounced](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useDebounced.html) | 指定されたタイムアウト期間後にウィジェットの更新をトリガーする、提供された値のデバウンスバージョンを返します。 | +| [useDraggableScrollableController](https://api.flutter.dev/flutter/widgets/DraggableScrollableController-class.html) | `DraggableScrollableController`を作成します。 | +| [useCarouselController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useCarouselController.html) | **`CarouselController`**を作成し、破棄します。 | +| [useTreeSliverController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTreeSliverController.html) | `TreeSliverController`を作成します。 | +| [useOverlayPortalController](https://api.flutter.dev/flutter/widgets/OverlayPortalController-class.html) | オーバーレイコンテンツの表示を制御するための`OverlayPortalController`を作成および管理します。コントローラーは、不要になったときに自動的に破棄されます。 | + +## 貢献 + +貢献は歓迎されます! + +フックが不足していると感じた場合は、プルリクエストを開いてください。 + +カスタムフックをマージするには、次のことを行う必要があります: + +- 使用例を説明します。 + + このフックがなぜ必要なのか、どのように使用するのかを説明する問題を開きます。... + これは重要です。フックが多くの人にアピールしない場合、そのフックはマージされません。 + + フックが拒否された場合でも心配しないでください!拒否されたからといって、将来的により多くの人が関心を示した場合にマージされないわけではありません。 + その間、https://pub.devにフックをパッケージとして公開してください。 + +- フックのテストを書く + + フックが将来誤って壊れるのを防ぐために、完全にテストされない限り、フックはマージされません。 + +- READMEに追加し、そのためのドキュメントを書く。 + +## スポンサー + +

+ + + +

diff --git a/packages/flutter_hooks/resources/translations/ko_kr/README.md b/packages/flutter_hooks/resources/translations/ko_kr/README.md new file mode 100644 index 00000000..4137b120 --- /dev/null +++ b/packages/flutter_hooks/resources/translations/ko_kr/README.md @@ -0,0 +1,369 @@ +[English](https://github.com/rrousselGit/flutter_hooks/blob/master/README.md) | [Português](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/pt_br/README.md) | [한국어](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/ko_kr/README.md) | [简体中文](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/zh_cn/README.md) | [日本語](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/ja_jp/README.md) + +[![Build](https://github.com/rrousselGit/flutter_hooks/workflows/Build/badge.svg)](https://github.com/rrousselGit/flutter_hooks/actions?query=workflow%3ABuild) [![codecov](https://codecov.io/gh/rrousselGit/flutter_hooks/branch/master/graph/badge.svg)](https://codecov.io/gh/rrousselGit/flutter_hooks) [![pub package](https://img.shields.io/pub/v/flutter_hooks.svg)](https://pub.dartlang.org/packages/flutter_hooks) [![pub package](https://img.shields.io/badge/Awesome-Flutter-blue.svg?longCache=true&style=flat-square)](https://github.com/Solido/awesome-flutter) +Discord + + + +# 플러터 훅 + +리액트 훅을 플러터에서 구현했을때 생기는 일: https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889 + +훅은 `widget` 의 생명주기를 관리하는 새로운 종류의 객체입니다. 훅이 존재하는 이유: 중복을 제거함으로써 위젯간 코드 생산성을 증가시킵니다. + +## 제작 동기 + +`StatefulWidget`은 아래와 같은 문제점이 있습니다: `initState` 나 `dispose`에서 사용된 로직을 재사용하기가 매우 어렵습니다. 적절한 예시는 `AnimationController`입니다: + +```dart +class Example extends StatefulWidget { + const Example({super.key, required this.duration}); + + final Duration duration; + + @override + _ExampleState createState() => _ExampleState(); +} + +class _ExampleState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: widget.duration); + } + + @override + void didUpdateWidget(Example oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.duration != oldWidget.duration) { + _controller.duration = widget.duration; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container(); + } +} +``` + +`AnimationController` 를 사용하고 싶다면 사용하려는 모든 위젯에서 이 로직을 반복해야 합니다. 하지만 대부분 이를 원치 않을겁니다. + +Dart Mixins 으로 이 문제를 해결할 수 있지만, 다른 문제점들이 있습니다: + +- Mixin은 한 클래스 당 한번만 사용할 수 있습니다. +- Mixin 과 클래스는 같은 객체를 공유합니다. + 예를 들어 두개의 Mixin 이 같은 이름일 때, 컴파일 에러에서부터 알 수 없는 결과까지 다양한 결과를 가져올 수 있음을 의미합니다. + +--- + +이 라이브러리는 세번째 해결책을 제안합니다: + +```dart +class Example extends HookWidget { + const Example({super.key, required this.duration}); + + final Duration duration; + + @override + Widget build(BuildContext context) { + final controller = useAnimationController(duration: duration); + return Container(); + } +} +``` + +이 코드는 위 예제와 기능적으로 동일합니다. 여전히 `AnimationController` 를 dispose 하고, `Example.duration` 이 변경될 때 `duration` 을 업데이트합니다. +당신은 아마도 다음과 같은 생각을 하고 있을 것입니다: + +> 다른 로직들은 어디에 있지? + +그 로직들은 `useAnimationController` 함수로 옮겨 졌습니다. 이 함수는 이 라이브러리에 내장되어 있습니다. ( [기본적인 훅들](https://github.com/rrousselGit/flutter_hooks#existing-hooks) 보기) - 이것이 훅 입니다. + +훅은 몇가지의 특별함(Sepcification)을 가지고 있는 새로운 종류의 객체입니다. + +- Mixin한 위젯의 `build` 메소드 안에서만 사용할 수 있습니다. +- 동일한 훅이라도 여러번 재사용될 수 있습니다. 아래에는 두개의 `AnimationController` 가 있습니다. 각각의 훅은 위젯이 리빌드 될 때 다른 훅의 상태를 보존합니다: + + ```dart + Widget build(BuildContext context) { + final controller = useAnimationController(); + final controller2 = useAnimationController(); + return Container(); + } + ``` + +- 훅과 훅, 훅과 위젯은 완전하게 독립적입니다. + 이것은 훅을 패키지로 추출하고 [pub](https://pub.dartlang.org/) 에서 다른 사람들이 사용할 수 있도록 쉽게 만들어 줍니다. + +## 원리 + +`State`와 유사한 점은, 훅은 `Element` 라는 `Widget` 에 저장됩니다. 다른 점은 `State` 하나만 갖는 것 대신에, `Element`는 `List`에 저장합니다. 그리고 훅을 사용하기 위해서는 `Hook.use`이라고 호출합니다. + +`use` 함수에 의해 반환된 훅은 `use`가 호출된 횟수에 기반합니다. +첫번째 호출은 첫번째 훅을 반환하고, 두번째 호출은 두번째 훅을 반환하고, 세번째 호출은 세번째 훅을 반환하며 이런식으로 진행됩니다. + +만약 이 개념이 이해가 안된다면, 아래를 보고 훅이 어떻게 구현되었는지 확인해보세요: + +```dart +class HookElement extends Element { + List _hooks; + int _hookIndex; + + T use(Hook hook) => _hooks[_hookIndex++].build(this); + + @override + performRebuild() { + _hookIndex = 0; + super.performRebuild(); + } +} +``` + +훅을 구현하는 더 다양한 예시를 보기위해 React 에서 훅이 어떻게 구현되어 있는지 훌륭한 글이 있습니다: https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e + +## 규칙 + +훅은 리스트안에 존재하고, 인덱스로 불러오기 때문에, 몇가지 규칙을 지켜야합니다.: + +### 이름을 지을 때는 `use` 로 시작하시오: + +```dart +Widget build(BuildContext context) { + // `use`로 시작했다면, 굿입니다. + useMyHook(); + // `use`로 시작하지 않기 때문에, 훅을 사용하는 사람들 사이에서 헷갈릴 수 있습니다. + myHook(); + // .... +} +``` + +### 조건문 없이 호출하시오: + +```dart +Widget build(BuildContext context) { + useMyHook(); + // .... +} +``` + +### `use`를 조건문 안에 넣지 마시오: + +```dart +Widget build(BuildContext context) { + if (condition) { + useMyHook(); + } + // .... +} +``` + +--- + +### 핫리로드에 대해서 + +훅은 인덱스로부터 얻어지기 때문에, 코드를 수정 하고 핫 리로드를 실행하면 앱이 멈춘다고 생각할 수도 있습니다. + +걱정하지 마세요, `HookWidget` 은 핫 리로드 시에도 훅의 상태들이 유지될 수 있도록 재정의 합니다.. 그럼에도, 훅의 상태가 리셋될 수 있는 상황이 있습니다. + +아래의 훅 리스트를 보세요: + +```dart +useA(); +useB(0); +useC(); +``` + +그 다음, 핫 리로드가 실행 된 후에 `HookB` 의 값을 수정했다고 가정해봅시다: + +```dart +useA(); +useB(42); +useC(); +``` + +모든 훅이 잘 작동하고, 모든 훅의 상태가 유지됩니다. + +이제 `HookB`가 제거해 봅시다. 그러면: + +```dart +useA(); +useC(); +``` + +이 상황에서 `HookA` 는 상태를 유지하지만 `HookC` 는 리셋됩니다. +이유는 코드를 수정 한 후 핫 리로드가 실행되면, 첫번째로 영향을 받은 행 이후의 모든 훅이 제거되기 때문입니다. +그래서 `HookC` 는 `HookB` 뒤에 있기 때문에 상태가 리셋됩니다. + +## 훅을 생성하는 법 + +훅을 생성하기위한 두가지 방법이 있습니다: + +- 함수 + + 함수는 훅을 작성하는 가장 일반적인 방법입니다. 훅이 자연스럽게 합성 가능한 덕분에, 함수는 다른 훅을 결합하여 더 복잡한 커스텀 훅을 만들 수 있습니다. 관례상, 이러한 함수는 `use`로 시작됩니다. + + 아래의 코드는 변수를 생성하고, 값이 변경될 때마다 콘솔에 로그를 남기는 커스텀 훅을 정의합니다: + + ```dart + ValueNotifier useLoggedState([T initialData]) { + final result = useState(initialData); + useValueChanged(result.value, (_, __) { + print(result.value); + }); + return result; + } + ``` + +- 클래스 + + 훅이 너무 복잡해지면, `Hook` 을 확장하는 클래스로 변환할 수 있습니다. 이 클래스는 `Hook.use` 함수로 사용할 수 있습니다. + 클래스로 훅을 정의하면, 훅은 `State` 클래스와 매우 유사하게 보일 것이며 `initHook`, `dispose` 및 `setState`와 같은 위젯의 라이프 사이클 및 메서드에 액세스 할 수 있습니다. + + 이와같이 함수 내에 클래스를 숨기는것은 좋은 예시입니다: + + ```dart + Result useMyHook() { + return use(const _TimeAlive()); + } + ``` + + 아래의 코드는 `State`가 생성되있었던 시간을 콘솔에 출력하는 훅을 정의합니다: + + ```dart + class _TimeAlive extends Hook { + const _TimeAlive(); + + @override + _TimeAliveState createState() => _TimeAliveState(); + } + + class _TimeAliveState extends HookState { + DateTime start; + + @override + void initHook() { + super.initHook(); + start = DateTime.now(); + } + + @override + void build(BuildContext context) {} + + @override + void dispose() { + print(DateTime.now().difference(start)); + super.dispose(); + } + } + ``` + +## 기본적인 훅들 + +Flutter_Hooks 는 이미 재사용 가능한 훅 목록을 제공합니다. 이 목록은 다음과 같이 구분됩니다: + +### 원시적 + +다른 위젯의 생명주기에 반응하는 기초적인 훅 입니다. + +| Name | Description | +| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| [useEffect](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useEffect.html) | 상태를 업데이트하거나 선택적으로 취소하기에 유용합니다. | +| [useState](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useState.html) | 변수를 생성하고 구독합니다. | +| [useMemoized](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useMemoized.html) | 다양한 객체의 인스턴스를 캐싱합니다. | +| [useRef](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useRef.html) | 하나의 프로퍼티를 포함하는 객체를 만듭니다. | +| [useCallback](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useCallback.html) | 함수의 인스턴스를 캐싱합니다. | +| [useContext](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useContext.html) | `HookWidget` 의 `BuildContext`를 가져옵니다. | +| [useValueChanged](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useValueChanged.html) | 값을 모니터링하고, 값이 변경될 때마다 콜백함수를 실행합니다. | + +### 객체 바인딩 + +해당 훅들은 Flutter/Dart에 이미 존재하는 객체들을 조작합니다. +이 훅은 객체를 생성/업데이트/삭제하는 역할을 합니다. + +#### dart:async 와 관련된 훅: + +| Name | Description | +| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| [useStream](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useStream.html) | `Stream`을 구독합니다. `AsyncSnapshot`으로 현재 상태를 반환합니다. | +| [useStreamController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useStreamController.html) | 알아서 dispose되는 `StreamController` 를 생성합니다. | +| [useFuture](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useFuture.html) | `Future`를 구독합니다. `AsyncSnapshot`으로 상태를 반환합니다. | + +#### Animation 에 관련된 훅: + +| 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를 반환합니다. | + +#### Listenable 에 관련된 훅: + +| Name | Description | +| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| [useListenable](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useListenable.html) | `Listenable` 을 구독합니다. 리스너가 호출될 때마다 위젯을 빌드가 필요한 것으로 표시합니다. | +| [useListenableSelector](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useListenableSelector.html) | Similar to `useListenable` 과 비슷하지만, 원하는 위젯만 변경되도록 선택할 수 있습니다.. | +| [useValueNotifier](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useValueNotifier.html) | 자동적으로 dispose 되는 `ValueNotifier`를 생성합니다. | +| [useValueListenable](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useValueListenable.html) | `ValueListenable` 를 구독합니다. 그 값을 반환합니다.. | + +#### 기타 훅: + +특별한 특징이 없는 훅들입니다. + +| Name | Description | +| --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| [useReducer](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useReducer.html) | state가 조금 더 복잡할 때, `useState` 대신 사용할 대안 입니다. | +| [usePrevious](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/usePrevious.html) | 바로 이전에 실행된 [usePrevious]의 값을 반환합니다. | +| [useTextEditingController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTextEditingController-constant.html) | `TextEditingController`를 생성합니다. | +| [useFocusNode](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useFocusNode.html) | `FocusNode`를 생성합니다. | +| [useTabController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useTabController.html) | `TabController`를 생성합니다. | +| [useScrollController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useScrollController.html) | `ScrollController`를 생성합니다. | +| [usePageController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/usePageController.html) | `PageController`를 생성합니다. | +| [useAppLifecycleState](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useAppLifecycleState.html) | 현재 `AppLifecycleState`를 반환합니다. 그리고 변화된 위젯을 다시 빌드합니다. | +| [useOnAppLifecycleStateChange](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useOnAppLifecycleStateChange.html) | Listens to `AppLifecycleState`가 변경될 때, 콜백함수를 실행합니다. | +| [useTransformationController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useTransformationController.html) | Creates and disposes a `TransformationController`를 생성합니다. | +| [useIsMounted](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useIsMounted.html) | `State.mounted` 와 동일한 기능의 훅입니다. | +| [useAutomaticKeepAlive](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useAutomaticKeepAlive.html) | `AutomaticKeepAlive`와 동일한 훅입니다. | +| [useOnPlatformBrightnessChange](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useOnPlatformBrightnessChange.html) | 플랫폼의 `Brightness` 이 변경될 때, 콜백함수를 실행합니다. | + +## Contributions + +기부를 환영합니다! + +후크가 없는 것 같으면 풀 요청을 여십시오. + +사용자 지정 후크를 병합하려면 다음을 수행해야 합니다: + +- 사용 사례를 설명합니다. + + 이 후크가 왜 필요한지, 어떻게 사용하는지 설명하는 문제를 엽니다... + 훅이 매력적이지 않으면 후크가 병합되지 않기 때문에 이것은 중요합니다 + 많은 사람들. + + 만약 여러분의 훅이 거절당하더라도, 걱정하지 마세요! 거절한다고 해서 거절당하지는 않을 것이다 + 더 많은 사람들이 그것에 관심을 보이면 나중에 합병된다. + 그동안 https://pub.dev에 당신의 후크를 패키지로 게시하세요. + +- 후크에 대한 테스트 쓰기 + + 후크가 실수로 파손되지 않도록 완전히 테스트하지 않는 한 후크는 병합되지 않습니다 + 미래에. + +- README에 추가하고 해당 문서를 작성합니다. + +## Sponsors + +

+ + + +

diff --git a/packages/flutter_hooks/resources/translations/pt_br/README.md b/packages/flutter_hooks/resources/translations/pt_br/README.md new file mode 100644 index 00000000..6d169a4c --- /dev/null +++ b/packages/flutter_hooks/resources/translations/pt_br/README.md @@ -0,0 +1,374 @@ +[English](https://github.com/rrousselGit/flutter_hooks/blob/master/README.md) | [Português](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/pt_br/README.md) | [한국어](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/ko_kr/README.md) | [简体中文](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/zh_cn/README.md) | [日本語](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/ja_jp/README.md) + +[![Build Status](https://travis-ci.org/rrousselGit/flutter_hooks.svg?branch=master)](https://travis-ci.org/rrousselGit/flutter_hooks) [![codecov](https://codecov.io/gh/rrousselGit/flutter_hooks/branch/master/graph/badge.svg)](https://codecov.io/gh/rrousselGit/flutter_hooks) [![pub package](https://img.shields.io/pub/v/flutter_hooks.svg)](https://pub.dartlang.org/packages/flutter_hooks) [![pub package](https://img.shields.io/badge/Awesome-Flutter-blue.svg?longCache=true&style=flat-square)](https://github.com/Solido/awesome-flutter) + + + +# Flutter Hooks + +Uma implementação dos React Hooks para o Flutter: https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889 + +Hooks são uma nova forma de gerenciar o ciclo de vida de um `Widget`. Eles existem +por uma razão: aumentar o compartilhamento de código _entre_ widgets, removendo código +duplicado. + +## Motivação + +`StatefulWidget` sofrem de um grande problema: é bem difícil reutilizar a lógica, +por exemplo de um `initState` ou `dispose`. Um exemplo é o `AnimationController`: + +```dart +class Example extends StatefulWidget { + const Example({super.key, required this.duration}); + + final Duration duration; + + @override + _ExampleState createState() => _ExampleState(); +} + +class _ExampleState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: widget.duration); + } + + @override + void didUpdateWidget(Example oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.duration != oldWidget.duration) { + _controller.duration = widget.duration; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container(); + } +} +``` + +Todos os widgets que desejarem usar um `AnimationController`, terão de implementar +quase tudo isso do zero, o que é obviamente indesejável. + +Os mixins do Dart resolvem parcialmente esse problema, porém outros problemas acabam +sendo gerados: + +- Um mixin só pode ser usado uma vez por uma classe +- Mixins e classes compartilham o mesmo objeto.\ + Isso significa que se dois mixins definirem uma variável com o mesmo nome, o resultado + pode variar entre um erro de compilação ou um comportamento inesperado. + +--- + +Essa biblioteca propõe uma terceira solução: + +```dart +class Example extends HookWidget { + const Example({super.key, required this.duration}); + + final Duration duration; + + @override + Widget build(BuildContext context) { + final controller = useAnimationController(duration: duration); + return Container(); + } +} +``` +Esse código é equivalente ao do exemplo anterior. Ele continua descartando o +`AnimationController` no `dispose` e continua atualizando o `duration` quando +`Example.duration` muda. +Você provavelmente está pensando: + +> Para onde foi toda a lógica? + +A lógica foi movida para o `useAnimationController`, uma funcionalidade incluida +diretamente nessa biblioteca (veja [Hooks Existentes](https://github.com/rrousselGit/flutter_hooks#existing-hooks)). +Isso é o que chamamos de _Hook_. + +Hooks são novos tipos de objetos com algumas peculiaridades: + +- Eles só podem ser usados durante o método `build` de um widgets que utiliza o mix-in + `Hooks`. +- O mesmo Hook pode ser reutilizado infinitas vezes. + O código a seguir define dois `Animation Controller` independentes, e eles são + preservados quando o widget passa por um rebuild. + + ```dart + Widget build(BuildContext context) { + final controller = useAnimationController(); + final controller2 = useAnimationController(); + return Container(); + } + ``` + +- Hooks são inteiramente independentes um do outro e do widget.\ + Isso significa que eles podem ser facilmente extraídos em um pacote + e publicados no [pub](https://pub.dartlang.org/) para outros desenvolvedores + utilizarem + +## Princípios + +Similar ao `State`, hooks são armazenados no `Element` de um `Widget`. Mas, ao invés +de ter apenas um `State`, o `Element` armazena um `List`. Então, para usar um +`Hook`, deve-se chamar `Hook.use`. + +O hook retornado pelo `use` é baseado no número de vezes que ele é chamado. +A primeira chamada retorna o primeiro hook; a segunda chamada retorna o segundo hook, +a terceira retorna o terceiro hook, ... + +Se ainda não parece tão claro, veja uma simples implementação do Hook a seguir: + +```dart +class HookElement extends Element { + List _hooks; + int _hookIndex; + + T use(Hook hook) => _hooks[_hookIndex++].build(this); + + @override + performRebuild() { + _hookIndex = 0; + super.performRebuild(); + } +} +``` + +Para maiores explicações sobre como são implementados, aqui está um ótimo artigo +sobre como eles funcionam no React: https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e + +## Regras + +Como os hooks são obtidos através do seu index, algumas regras devem ser respeitadas: + +### SEMPRE defina seus hooks com `use`: + +```dart +Widget build(BuildContext context) { + // começa com `use`, bom nome + useMyHook(); + // não começa com `use`, pode confundir outro desenvolvedor por achar que isso não é um hook + myHook(); + // .... +} +``` + +### CHAME os hooks sem nenhuma condição + +```dart +Widget build(BuildContext context) { + useMyHook(); + // .... +} +``` + +### NÃO envolva-os em uma condicional + +```dart +Widget build(BuildContext context) { + if (condition) { + useMyHook(); + } + // .... +} +``` + +--- + +### Sobre o hot-reload + +Como os hooks são obtidos pelo seu index, você pode achar que talvez um hot-reload durante uma refatoração pode quebrar a aplicação + +Felizmente não, `HookWidget` substitui o comportamento padrão do hot-reload para trabalhar com os hooks. Porém, existem algumas situações em que o estado de um Hook pode ser resetado. + +Considere a seguinte lista de hooks: + +```dart +useA(); +useB(0); +useC(); +``` + +Então considere que após o hot-reload, nós editamos o parâmetro do `HookB`: + +```dart +useA(); +useB(42); +useC(); +``` + +Tudo funciona perfeitamente bem; todos os hooks mantém seu estado. + +Agora considere que removemos o `HookB`. Agora temos: + +```dart +useA(); +useC(); +``` + +Nessa situação, `HookA` mantém seu estado, mas `HookC` é resetado. +Isso acontece por que quando uma refatoração é feita, todos os hooks _após_ a primeira linha são descartados. +Como `HookC` é colocado após `HookB`, ele acaba sendo descartado. + +## Como usar + +Existem duas formas de criar um hook: + +- Uma função + + Funções são a forma mais comum de se criar um Hook. Graças aos hooks serem + combináveis, uma função é capaz de combinar outros hooks para criar um hook + customizado. Como convenção, essas funções utilizam o prefixo `use`. + + O exemplo a seguir define um hook customizado que cria uma variável e mostra + seu valor no console sempre que o valor é modificado: + + ```dart + ValueNotifier useLoggedState(BuildContext context, [T initialData]) { + final result = useState(initialData); + useValueChanged(result.value, (_, __) { + print(result.value); + }); + return result; + } + ``` + +- Classe + + Quando um hook fica muito complexo, é possível converte-lo para uma classe que extende de `Hook`, que pode ser usado como `Hook.use`.\ + Como uma classe, o hook vai parecer bem similar ao `State` e terá acesso ao + ciclo de vida e métodos como `initHook`, `dispose` e `setState` + É uma boa prática esconder a classe em uma função, por exemplo: + + ```dart + Result useMyHook(BuildContext context) { + return use(const _TimeAlive()); + } + ``` + + Este exemplo defina um hook que mostra a hora quando o `State` é posto como alive. + + ```dart + class _TimeAlive extends Hook { + const _TimeAlive(); + + @override + _TimeAliveState createState() => _TimeAliveState(); + } + + class _TimeAliveState extends HookState { + DateTime start; + + @override + void initHook() { + super.initHook(); + start = DateTime.now(); + } + + @override + void build(BuildContext context) {} + + @override + void dispose() { + print(DateTime.now().difference(start)); + super.dispose(); + } + } + ``` + +## Hooks exisentes + +Flutter_hooks possui uma lista de hooks reutilizáveis. + +Eles são divididos em diferentes tipos: + +### Primitivos + +Um conjunto de hooks que interagem com diferentes ciclos de vida de um widget + +| Nome | descrição | +| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| [useEffect](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useEffect.html) | Útil para side-effects e opcionalmente, cancelá-los. | +| [useState](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useState.html) | Cria uma variável e escuta suas mudanças. | +| [useMemoized](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useMemoized.html) | Guarda a instância de um objeto complexo. | +| [useContext](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useContext.html) | Obtém o `BuildContext` do `HookWidget`. | +| [useValueChanged](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useValueChanged.html) | Observa o value e chama um callback sempre que o valor muda. | + +### Object binding + +Essa categoria de hooks permite manipular objetos existentes do Flutter/Dart com hooks. +Eles serão responsáveis por criar/atualizar/descartar o objeto. + +#### dart:async: + +| nome | descrição | +| ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [useStream](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useStream.html) | Inscreve em uma `Stream` e retorna o estado atual num `AsyncSnapshot`. | +| [useStreamController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useStreamController.html) | Cria um `StreamController` automaticamente descartado. | +| [useFuture](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useFuture.html) | Inscreve em uma `Future` e retorna o estado atual num `AsyncSnapshot`. | + +#### Animação: + +| 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. | + +#### Listenable: + +| nome | descrição | +| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| [useListenable](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useListenable.html) | Inscreve em um `Listenable` e marca o widget para um rebuild quando o listener é chamado. | +| [useValueNotifier](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useValueNotifier.html) | Cria um `ValueNotifier` automaticamente descartado. | +| [useValueListenable](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useValueListenable.html) | Inscreve em um `ValueListenable` e retorna seu valor. | + +#### Misc + +São vários hooks sem um tema particular. + +| nome | descrição | +| ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| [useReducer](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useReducer.html) | Uma alternativa `useState` para estados complexos. | +| [usePrevious](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/usePrevious.html) | Retorna o argumento anterior chamado [usePrevious]. | +| [useTextEditingController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTextEditingController-constant.html) | Cria um `TextEditingController` | +| [useFocusNode](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useFocusNode.html) | Cria um `FocusNode` | +| [useTabController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useTabController.html) | Cria e descarta um `TabController`. | +| [useScrollController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useScrollController.html) | Cria e descarta um `ScrollController`. | +| [useTransformationController](https://pub.dartlang.org/documentation/flutter_hooks/latest/flutter_hooks/useTransformationController.html) | Cria e descarta um `TransformationController`. | + +## Contribuições + +Contribuições são bem vindas! + +Se você acha que está faltando algum hook, sinta-se livre para abrir um pull-request. + +Para um hook customizado ser mergeado, você precisa fazer o seguinte: + +- Descrever o caso de uso + + Abrir uma issue explicando por que precisamos desse hook, como usar, ... + Isso é importante por que um hook não será mergeado se não atender um + grande número de pessoas + + Se o seu hook foi rejeitado, não se preocupe! A rejeição não significa que + ele não pode ser mergeado no future se mais pessoas se interessarem nele. + Sinta-se livre para publicar seu próprio hook como um pacote no [pub](https://pub.dev) + +- Escreva testes para o seu hook + + Um hook não será mergeado a não ser que esteja completamente testado, para evitar + quebras no futuro. + +- Adicione-o ao Readme e escreva uma documentação para ele. diff --git a/packages/flutter_hooks/resources/translations/zh_cn/README.md b/packages/flutter_hooks/resources/translations/zh_cn/README.md new file mode 100644 index 00000000..2e2d15a6 --- /dev/null +++ b/packages/flutter_hooks/resources/translations/zh_cn/README.md @@ -0,0 +1,389 @@ +[English](https://github.com/rrousselGit/flutter_hooks/blob/master/README.md) | [Português](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/pt_br/README.md) | [한국어](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/ko_kr/README.md) | [简体中文](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/zh_cn/README.md) | [日本語](https://github.com/rrousselGit/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/ja_jp/README.md) + +[![Build](https://github.com/rrousselGit/flutter_hooks/workflows/Build/badge.svg)](https://github.com/rrousselGit/flutter_hooks/actions?query=workflow%3ABuild) [![codecov](https://codecov.io/gh/rrousselGit/flutter_hooks/branch/master/graph/badge.svg)](https://codecov.io/gh/rrousselGit/flutter_hooks) [![pub package](https://img.shields.io/pub/v/flutter_hooks.svg)](https://pub.dev/packages/flutter_hooks) [![pub package](https://img.shields.io/badge/Awesome-Flutter-blue.svg?longCache=true&style=flat-square)](https://github.com/Solido/awesome-flutter) + +Discord + +

+ +

+ +# Flutter Hooks + +一个 React 钩子在 Flutter 上的实现:[Making Sense of React Hooks](https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889) + +钩子是一种用来管理 `Widget` 生命周期的新对象,以减少重复代码、增加组件间复用性。 + +## 动机 + +`StatefulWidget` 有个大问题,它很难减少 `initState` 或 `dispose` 的调用,一个简明的例子就是 `AnimationController`: + +```dart +class Example extends StatefulWidget { + const Example({super.key, required this.duration}); + + final Duration duration; + + @override + _ExampleState createState() => _ExampleState(); +} + +class _ExampleState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: widget.duration); + } + + @override + void didUpdateWidget(Example oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.duration != oldWidget.duration) { + _controller.duration = widget.duration; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container(); + } +} +``` + +所有想要使用 `AnimationController` 的组件都几乎必须从头开始重新实现这些逻辑,这当然不是我们想要的。 + +Dart 的 mixin 能部分解决这个问题,但随之又有其它问题: + +- 一个给定的 mixin 只能被一个类使用一次 +- Mixin 和类共用一个对象\ + 这意味着如果两个 mixin 用一个变量名分别定义自己的变量,结果要么是编译失败,要么行为诡异。 + +--- + +这个库提供了另一个解决方法: + +```dart +class Example extends HookWidget { + const Example({Key? key, required this.duration}) + : super(key: key); + + final Duration duration; + + @override + Widget build(BuildContext context) { + final controller = useAnimationController(duration: duration); + return Container(); + } +} +``` + +这段代码和之前的例子有一样的功能。\ +它仍然会 dispose `AnimationController`,并在 `Example.duration` 改变时更新它的 `duration`。 + +但猜你在想: + +> 那些逻辑都哪去了? + +那些逻辑都已经被移入了 `useAnimationController` 里,这是这个库直接带有的(见 [已有的钩子](https://github.com/Cierra-Runis/flutter_hooks/blob/master/packages/flutter_hooks/resources/translations/zh_cn/README.md#%E5%B7%B2%E6%9C%89%E7%9A%84%E9%92%A9%E5%AD%90) )——这就是我们所说的 _钩子_。 + +钩子是一种有着如下部分特性的新对象: + +- 只能在混入了 `Hooks` 的组件的 `build` 方法内使用 +- 同类的钩子能复用任意多次\ + 如下的代码定义了两个独立的 `AnimationController`,并且都在组件重建时被正确的保留 + + ```dart + Widget build(BuildContext context) { + final controller = useAnimationController(); + final controller2 = useAnimationController(); + return Container(); + } + ``` + +- 钩子和其它钩子与组件完全独立\ + 这说明他们能被很简单的抽离到一个包并发布到 [pub](https://pub.dev/) 上去给其他人用 + +## 原理 + +与 `State` 类似,钩子被存在 `Widget` 的 `Element` 里。但和存个 `State` 不一样,`Element` 存的是 `List`。\ +再就是想要使用 `Hook` 的话,就必须调用 `Hook.use`。 + +由 `use` 返回的钩子由其被调用的次数决定。\ +第一次调用返回第一个钩子,第二次返回第二个,第三次返回第三个这样。 + +如果还是不太能理解的话,钩子的一个雏形可能长下面这样: + +```dart +class HookElement extends Element { + List _hooks; + int _hookIndex; + + T use(Hook hook) => _hooks[_hookIndex++].build(this); + + @override + performRebuild() { + _hookIndex = 0; + super.performRebuild(); + } +} +``` + +想要更多有关钩子是怎么实现的解释的话,这里有篇讲钩子在 React 是怎么实现的挺不错的 [文章](https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e)。 + +## 约定 + +由于钩子由它们的 index 保留,有些约定是必须要遵守的: + +### _要_ 一直使用 `use` 作为你钩子的前缀 + +```dart +Widget build(BuildContext context) { + // 以 `use` 开头,非常好名字 + useMyHook(); + // 不以 `use` 开头,会让人以为这不是一个钩子 + myHook(); + // .... +} +``` + +### _要_ 直接调用钩子 + +```dart +Widget build(BuildContext context) { + useMyHook(); + // .... +} +``` + +### _不要_ 将 `use` 包到条件语句里 + +```dart +Widget build(BuildContext context) { + if (condition) { + useMyHook(); + } + // .... +} +``` + +--- + +### 有关热重载 + +由于钩子由它们的 index 保留,可能有人认为在重构时热重载会搞崩程序。 + +冇问题的,为了能使用钩子,`HookWidget` 覆写了默认的热重载行为,但还有一些情况下钩子的状态会被重置。 + +设想如下三个钩子: + +```dart +useA(); +useB(0); +useC(); +``` + +接下来我们在热重载后修改 `HookB` 的参数: + +```dart +useA(); +useB(42); +useC(); +``` + +那么一切正常,所有的钩子都保留了他们的状态。 + +现在再删掉 `HookB` 试试: + +```dart +useA(); +useC(); +``` + +在这种情况下,`HookA` 会保留它的状态,但 `HookC` 会被强制重置。\ +这是因为重构并热重载后,在第一个被影响的钩子 _之后_ 的所有钩子都会被 dispose 掉。\ +因此,由于 `HookC` 在 `HookB` _之后_,所以它会被 dispose 掉。 + +## 如何创建钩子 + +这有两种方法: + +- 函数式钩子 + + 函数是目前用来写钩子的最常用方法。\ + 多亏钩子能被自然的组合,一个函数就能将其他的钩子组合为一个复杂的自定义钩子。\ + 而且我们约定好了这些函数都以 `use` 为前缀。 + + 如下代码构建了一个自定义钩子,其创建了一个变量,并在变量改变时在终端显示日志。 + + ```dart + ValueNotifier useLoggedState([T initialData]) { + final result = useState(initialData); + useValueChanged(result.value, (_, __) { + print(result.value); + }); + return result; + } + ``` + +- 类钩子 + + 当一个钩子变得过于复杂时,可以将其转化为一个继承 `Hook` 的类——然后就能拿来调用 `Hook.use`。\ + 作为一个类,钩子看起来和 `State` 类差不多,有着组件的生命周期和方法,比如 `initHook`、`dispose`和`setState`。 + + 而且一个好的实践是将类藏在一个函数后面: + + ```dart + Result useMyHook() { + return use(const _TimeAlive()); + } + ``` + + 如下代码构建了一个自定义钩子,其能在其被 dispose 时打印其状态存在的总时长。 + + ```dart + class _TimeAlive extends Hook { + const _TimeAlive(); + + @override + _TimeAliveState createState() => _TimeAliveState(); + } + + class _TimeAliveState extends HookState { + DateTime start; + + @override + void initHook() { + super.initHook(); + start = DateTime.now(); + } + + @override + void build(BuildContext context) {} + + @override + void dispose() { + print(DateTime.now().difference(start)); + super.dispose(); + } + } + ``` + +## 已有的 Hook + +Flutter_Hooks 已经包含一些不同类别的可复用的钩子: + +### 基础类别 + +与组件不同生命周期交互的低级钩子。 + +| 名称 | 描述 | +| -------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| [useEffect](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useEffect.html) | 用于处理副作用,并可选择性地进行清理 | +| [useState](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useState.html) | 创建一个变量并订阅它的变化 | +| [useMemoized](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useMemoized.html) | 缓存复杂对象的实例 | +| [useRef](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useRef.html) | 创建一个包含单个可变属性的对象 | +| [useCallback](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useCallback.html) | 缓存函数实例 | +| [useContext](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useContext.html) | 获取当前 `HookWidget` 的 `BuildContext` | +| [useValueChanged](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueChanged.html) | 监听某个值的变化,并在值发生变化时触发回调 | + +### 对象绑定(Object-binding) + +这类钩子用于操作现有的 Flutter 及 Dart 对象\ +它们负责创建、更新以及 dispose 对象 + +#### dart:async 相关 + +| 名称 | 描述 | +| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| [useStream](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useStream.html) | 订阅一个 `Stream` 并返回其当前状态(`AsyncSnapshot`) | +| [useStreamController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useStreamController.html) | 创建一个 `StreamController`,会在不再使用时自动释放 | +| [useOnStreamChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnStreamChange.html) | 订阅 `Stream`,注册处理函数,并返回 `StreamSubscription` | +| [useFuture](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFuture.html) | 订阅一个 `Future` 并返回其当前状态(`AsyncSnapshot`) | + +#### Animation 相关 + +| 名称 | 描述 | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | +| [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` 并返回其当前值 | + +#### Listenable 相关 + +| 名称 | 描述 | +| -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| [useListenable](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useListenable.html) | 订阅一个 `Listenable`,当回调触发时标记组件需要重建 | +| [useListenableSelector](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useListenableSelector.html) | 类似于 `useListenable`,但允许过滤 UI 重建 | +| [useValueNotifier](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueNotifier.html) | 创建一个 `ValueNotifier`,并在不再使用时自动释放 | +| [useValueListenable](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueListenable.html) | 订阅一个 `ValueListenable` 并返回其值 | +| [useOnListenableChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnListenableChange.html) | 为 `Listenable` 添加回调,并在不再需要时自动移除 | + +#### 杂项 + +一组无明确主题的钩子 + +| 名称 | 描述 | +| -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| [useReducer](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useReducer.html) | `useState` 的替代方案,适用于更复杂的状态管理 | +| [usePrevious](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/usePrevious.html) | 返回上一次调用 `usePrevious` 时的参数 | +| [useTextEditingController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTextEditingController-constant.html) | 创建一个 `TextEditingController` | +| [useFocusNode](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFocusNode.html) | 创建一个 `FocusNode` | +| [useTabController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTabController.html) | 创建并自动释放一个 `TabController` | +| [useScrollController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useScrollController.html) | 创建并自动释放一个 `ScrollController` | +| [usePageController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/usePageController.html) | 创建并自动释放一个 `PageController` | +| [useFixedExtentScrollController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFixedExtentScrollController.html) | 创建并自动释放一个 `FixedExtentScrollController` | +| [useAppLifecycleState](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAppLifecycleState.html) | 返回当前的 `AppLifecycleState`,并在其变化时触发组件重建 | +| [useOnAppLifecycleStateChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnAppLifecycleStateChange.html) | 监听 `AppLifecycleState` 的变化,并在变化时触发回调 | +| [useTransformationController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTransformationController.html) | 创建并自动释放一个 `TransformationController` | +| [useIsMounted](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useIsMounted.html) | Hook 版本的 `State.mounted` | +| [useAutomaticKeepAlive](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAutomaticKeepAlive.html) | Hook 版本的 `AutomaticKeepAlive` 组件 | +| [useOnPlatformBrightnessChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnPlatformBrightnessChange.html) | 监听平台亮度(`Brightness`)变化,并在变化时触发回调 | +| [useSearchController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useSearchController.html) | 创建并自动释放一个 `SearchController` | +| [useWidgetStatesController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useWidgetStatesController.html) | 创建并自动释放一个 `WidgetStatesController` | +| [useExpansibleController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useExpansibleController.html) | 创建一个 `ExpansibleController` | +| [useDebounced](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useDebounced.html) | 返回一个防抖后的值,在指定延时后触发更新 | +| [useDraggableScrollableController](https://api.flutter.dev/flutter/widgets/DraggableScrollableController-class.html) | 创建一个 `DraggableScrollableController` | +| [useCarouselController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useCarouselController.html) | 创建并自动释放一个 `CarouselController` | +| [useTreeSliverController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTreeSliverController.html) | 创建一个 `TreeSliverController` | +| [useOverlayPortalController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOverlayPortalController.html) | 创建并管理一个 `OverlayPortalController`,用于控制覆盖层内容的可见性,会在不再需要时自动释放 | +| [useSnapshotController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useSnapshotController.html) | 创建并管理一个 `SnapshotController` | +| [useCupertinoController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useCupertinoController.html) | 创建并管理一个 `CupertinoController` | + +## 贡献 + +欢迎贡献! + +如果你觉得少了某个钩子,别多想直接开个 Pull Request ~ + +为了合并新的自定义钩子,你需要按如下规则办事: + +- 介绍使用例 + + 开个 issue 解释一下为什么我们需要这个钩子,怎么用它……\ + 这很重要,如果这个钩子对很多人没有吸引力,那么它就不会被合并。 + + 如果你被拒了也没关系!这并不意味着以后也被拒绝,如果越来越多的人感兴趣。\ + 在这之前,你也可以把你的钩子发布到 [pub](https://pub.dev/) 上~ + +- 为你的钩子写测试 + + 除非钩子被完全测试好,不然不会合并,以防未来不经意破坏了它也没法发现。 + +- 把它加到 README 并写介绍 + +## 赞助 + +

+ + + +

diff --git a/packages/flutter_hooks/test/carousel_controller_test.dart b/packages/flutter_hooks/test/carousel_controller_test.dart new file mode 100644 index 00000000..b75c90df --- /dev/null +++ b/packages/flutter_hooks/test/carousel_controller_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useCarouselController(); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useCarouselController: CarouselController#00000(no clients)\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useCarouselController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late CarouselController controller; + late CarouselController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = CarouselController(); + controller = useCarouselController(); + return Container(); + }), + ); + + expect(controller.initialItem, controller2.initialItem); + expect(controller.initialScrollOffset, controller2.initialScrollOffset); + expect(controller.keepScrollOffset, controller2.keepScrollOffset); + expect(controller.onAttach, controller2.onAttach); + expect(controller.onDetach, controller2.onDetach); + }); + + testWidgets("returns a CarouselController that doesn't change", + (tester) async { + late CarouselController controller; + late CarouselController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useCarouselController(); + return Container(); + }), + ); + + expect(controller, isA()); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = useCarouselController(); + return Container(); + }), + ); + + expect(identical(controller, controller2), isTrue); + }); + + testWidgets('passes hook parameters to the CarouselController', + (tester) async { + late CarouselController controller; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = useCarouselController( + initialItem: 42, + ); + + return Container(); + }, + ), + ); + + expect(controller.initialItem, 42); + }); + + testWidgets('disposes the CarouselController on unmount', (tester) async { + late CarouselController controller; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = useCarouselController(); + return Container(); + }, + ), + ); + + // pump another widget so that the old one gets disposed + await tester.pumpWidget(Container()); + + expect( + () => controller.addListener(() {}), + throwsA(isFlutterError.having( + (e) => e.message, 'message', contains('disposed'))), + ); + }); + }); +} diff --git a/packages/flutter_hooks/test/hook_builder_test.dart b/packages/flutter_hooks/test/hook_builder_test.dart new file mode 100644 index 00000000..4fac1d8b --- /dev/null +++ b/packages/flutter_hooks/test/hook_builder_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('simple build', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + final state = useState(42).value; + return Text('$state', textDirection: TextDirection.ltr); + }), + ); + + expect(find.text('42'), findsOneWidget); + }); +} diff --git a/packages/flutter_hooks/test/hook_widget_test.dart b/packages/flutter_hooks/test/hook_widget_test.dart new file mode 100644 index 00000000..f0365d6e --- /dev/null +++ b/packages/flutter_hooks/test/hook_widget_test.dart @@ -0,0 +1,1310 @@ +// ignore_for_file: invalid_use_of_protected_member, only_throw_errors +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +class InheritedInitHook extends Hook { + @override + InheritedInitHookState createState() => InheritedInitHookState(); +} + +class InheritedInitHookState extends HookState { + @override + void initHook() { + context.dependOnInheritedWidgetOfExactType(); + } + + @override + void build(BuildContext context) {} +} + +void main() { + final build = MockBuild(); + final dispose = MockDispose(); + final deactivate = MockDeactivate(); + final initHook = MockInitHook(); + final didUpdateHook = MockDidUpdateHook(); + final reassemble = MockReassemble(); + + HookTest createHook() { + return HookTest( + build: build, + dispose: dispose, + didUpdateHook: didUpdateHook, + reassemble: reassemble, + initHook: initHook, + deactivate: deactivate, + ); + } + + void verifyNoMoreHookInteraction() { + verifyNoMoreInteractions(build); + verifyNoMoreInteractions(dispose); + verifyNoMoreInteractions(initHook); + verifyNoMoreInteractions(didUpdateHook); + } + + tearDown(() { + reset(build); + reset(dispose); + reset(deactivate); + reset(initHook); + reset(didUpdateHook); + reset(reassemble); + }); + + testWidgets('hooks are disposed in reverse order when their keys changes', + (tester) async { + final first = MockDispose(); + final second = MockDispose(); + + await tester.pumpWidget( + HookBuilder(builder: (c) { + useEffect(() => first, [0]); + useEffect(() => second, [0]); + return Container(); + }), + ); + + verifyZeroInteractions(first); + verifyZeroInteractions(second); + + await tester.pumpWidget( + HookBuilder(builder: (c) { + useEffect(() => first, [1]); + useEffect(() => second, [1]); + return Container(); + }), + ); + + verifyInOrder([ + second(), + first(), + ]); + verifyNoMoreInteractions(first); + verifyNoMoreInteractions(second); + + await tester.pumpWidget(Container()); + + verifyInOrder([ + second(), + first(), + ]); + verifyNoMoreInteractions(first); + verifyNoMoreInteractions(second); + }); + testWidgets('hooks are disposed in reverse order on unmount', (tester) async { + final first = MockDispose(); + final second = MockDispose(); + + await tester.pumpWidget( + HookBuilder(builder: (c) { + useEffect(() => first); + useEffect(() => second); + return Container(); + }), + ); + + verifyNoMoreInteractions(first); + verifyNoMoreInteractions(second); + + await tester.pumpWidget(Container()); + + verifyInOrder([ + second(), + first(), + ]); + verifyNoMoreInteractions(first); + verifyNoMoreInteractions(second); + }); + + testWidgets('StatefulHookWidget', (tester) async { + final notifier = ValueNotifier(0); + + await tester.pumpWidget(MyStatefulHook(value: 0, notifier: notifier)); + + expect(find.text('0 0'), findsOneWidget); + + await tester.pumpWidget(MyStatefulHook(value: 1, notifier: notifier)); + + expect(find.text('1 0'), findsOneWidget); + + notifier.value++; + await tester.pump(); + + expect(find.text('1 1'), findsOneWidget); + }); + + testWidgets( + 'should call deactivate when removed from and inserted into another place', + (tester) async { + final _key1 = GlobalKey(); + final _key2 = GlobalKey(); + final state = ValueNotifier(false); + final deactivate1 = MockDeactivate(); + final deactivate2 = MockDeactivate(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: ValueListenableBuilder( + valueListenable: state, + builder: (context, value, _) { + return Stack(children: [ + Container( + key: const Key('1'), + child: HookBuilder( + key: value ? _key2 : _key1, + builder: (context) { + use(HookTest(deactivate: deactivate1)); + return Container(); + }, + ), + ), + HookBuilder( + key: !value ? _key2 : _key1, + builder: (context) { + use(HookTest(deactivate: deactivate2)); + return Container(); + }, + ), + ]); + }, + ), + ), + ); + + await tester.pump(); + + verifyNever(deactivate1()); + verifyNever(deactivate2()); + state.value = true; + + await tester.pump(); + + verifyInOrder([ + deactivate1(), + deactivate2(), + ]); + + await tester.pump(); + + verifyNoMoreInteractions(deactivate1); + verifyNoMoreInteractions(deactivate2); + }); + + testWidgets('should call other deactivates even if one fails', + (tester) async { + final onError = MockOnError(); + final oldOnError = FlutterError.onError; + FlutterError.onError = onError; + + final errorBuilder = ErrorWidget.builder; + ErrorWidget.builder = MockErrorBuilder(); + final mockError = MockFlutterErrorDetails(); + when(ErrorWidget.builder(mockError)).thenReturn(Container()); + + final deactivate = MockDeactivate(); + when(deactivate()).thenThrow(42); + final deactivate2 = MockDeactivate(); + + final _key = GlobalKey(); + + final widget = HookBuilder( + key: _key, + builder: (context) { + use(HookTest(deactivate: deactivate)); + use(HookTest(deactivate: deactivate2)); + return Container(); + }, + ); + + try { + await tester.pumpWidget(SizedBox(child: widget)); + + verifyNoMoreInteractions(deactivate); + verifyNoMoreInteractions(deactivate2); + + await tester.pumpWidget(widget); + + verifyInOrder([ + deactivate(), + deactivate2(), + ]); + + verify(onError(any)).called(1); + verifyNoMoreInteractions(deactivate); + verifyNoMoreInteractions(deactivate2); + } finally { + // reset the exception because after the test + // flutter tries to deactivate the widget and it causes + // and exception + when(deactivate()).thenAnswer((_) {}); + FlutterError.onError = oldOnError; + ErrorWidget.builder = errorBuilder; + } + }); + + testWidgets('should not allow using inheritedwidgets inside initHook', + (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (_) { + use(InheritedInitHook()); + return Container(); + }), + ); + + expect(tester.takeException(), isAssertionError); + }); + + testWidgets('allows using inherited widgets outside of initHook', + (tester) async { + when(build(any)).thenAnswer((invocation) { + final context = invocation.positionalArguments.first as BuildContext; + context.dependOnInheritedWidgetOfExactType(); + return null; + }); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + use(HookTest(build: build)); + return Container(); + }), + ); + }); + testWidgets("release mode don't crash", (tester) async { + late ValueNotifier notifier; + debugHotReloadHooksEnabled = false; + addTearDown(() => debugHotReloadHooksEnabled = true); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + notifier = useState(0); + + return Text(notifier.value.toString(), + textDirection: TextDirection.ltr); + }), + ); + + expect(find.text('0'), findsOneWidget); + + notifier.value++; + await tester.pump(); + + expect(find.text('1'), findsOneWidget); + }); + + testWidgets('HookElement exposes an immutable list of hooks', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (_) { + use(HookTest()); + use(HookTest()); + return Container(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)) as HookElement; + + expect(element.debugHooks, [ + isA>(), + isA>(), + ]); + }); + testWidgets( + 'until one build finishes without crashing, it is possible to add hooks', + (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (_) { + throw 0; + }), + ); + expect(tester.takeException(), 0); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + use(HookTest()); + throw 1; + }), + ); + expect(tester.takeException(), 1); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + use(HookTest()); + use(HookTest()); + throw 2; + }), + ); + expect(tester.takeException(), 2); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + use(HookTest()); + use(HookTest()); + use(HookTest()); + return Container(); + }), + ); + }); + testWidgets( + 'until one build finishes without crashing, it is possible to add hooks #2', + (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (_) { + throw 0; + }), + ); + expect(tester.takeException(), 0); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + use(HookTest()); + throw 1; + }), + ); + expect(tester.takeException(), 1); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + use(HookTest()); + use(HookTest()); + use(HookTest()); + throw 2; + }), + ); + expect(tester.takeException(), 2); + }); + + testWidgets( + "After hot-reload that throws it's still possible to add hooks until one build succeeds", + (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (_) { + return Container(); + }), + ); + + hotReload(tester); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + throw 0; + }), + ); + expect(tester.takeException(), 0); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + use(HookTest()); + return Container(); + }), + ); + }); + + testWidgets( + 'After hot-reload that throws, hooks are correctly disposed when build succeeds with less hooks', + (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (_) { + use(createHook()); + return Container(); + }), + ); + + hotReload(tester); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + throw 0; + }), + ); + + expect(tester.takeException(), 0); + + verify(dispose()).called(1); + verifyNoMoreInteractions(dispose); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + return Container(); + }), + ); + + verifyNoMoreInteractions(dispose); + }); + + testWidgets('hooks can be disposed independently with keys', (tester) async { + final dispose2 = MockDispose(); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(HookTest(dispose: dispose)); + use(HookTest(dispose: dispose2)); + return Container(); + }), + ); + + verifyZeroInteractions(dispose); + verifyZeroInteractions(dispose2); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(HookTest(dispose: dispose, keys: const [])); + use(HookTest(dispose: dispose2)); + return Container(); + }), + ); + + verify(dispose()).called(1); + verifyZeroInteractions(dispose2); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(HookTest(dispose: dispose, keys: const [])); + use(HookTest(dispose: dispose2, keys: const [])); + return Container(); + }), + ); + + verify(dispose2()).called(1); + verifyNoMoreInteractions(dispose); + }); + testWidgets('keys recreate hookstate', (tester) async { + List? keys; + + final createState = + MockCreateState>(HookStateTest()); + // when(createState()).thenReturn(HookStateTest()); + + late HookTest hookTest; + + Widget $build() { + return HookBuilder(builder: (context) { + hookTest = HookTest( + build: build, + dispose: dispose, + didUpdateHook: didUpdateHook, + initHook: initHook, + keys: keys, + createStateFn: createState, + ); + use(hookTest); + return Container(); + }); + } + + await tester.pumpWidget($build()); + + final context = tester.element(find.byType(HookBuilder)); + + verifyInOrder([ + createState(), + initHook(), + build(context), + ]); + verifyNoMoreHookInteraction(); + + await tester.pumpWidget($build()); + + verifyInOrder([ + didUpdateHook(any), + build(context), + ]); + verifyNoMoreHookInteraction(); + + // from null to array + keys = []; + await tester.pumpWidget($build()); + + verifyInOrder([ + createState(), + initHook(), + build(context), + dispose(), + ]); + verifyNoMoreHookInteraction(); + + // array immutable + keys.add(42); + + await tester.pumpWidget($build()); + + verifyInOrder([ + didUpdateHook(any), + build(context), + ]); + verifyNoMoreHookInteraction(); + + // new array but content equal + keys = [42]; + + await tester.pumpWidget($build()); + + verifyInOrder([ + didUpdateHook(any), + build(context), + ]); + verifyNoMoreHookInteraction(); + + // new array new content + keys = [44]; + + await tester.pumpWidget($build()); + + verifyInOrder([ + createState(), + initHook(), + build(context), + dispose(), + ]); + verifyNoMoreHookInteraction(); + }); + + testWidgets('hook & setState', (tester) async { + final setState = MockSetState(); + final hook = MyHook(); + late HookElement hookContext; + late MyHookState state; + + await tester.pumpWidget(HookBuilder( + builder: (context) { + hookContext = context as HookElement; + state = use(hook); + return Container(); + }, + )); + + expect(state.hook, hook); + expect(state.context, hookContext); + expect(hookContext.dirty, false); + + state.setState(setState); + verify(setState()).called(1); + + expect(hookContext.dirty, true); + }); + + testWidgets('life-cycles in order', (tester) async { + late int? result; + late HookTest hook; + + when(build(any)).thenReturn(42); + + await tester.pumpWidget(HookBuilder( + builder: (context) { + hook = createHook(); + result = use(hook); + return Container(); + }, + )); + + final context = tester.firstElement(find.byType(HookBuilder)); + expect(result, 42); + verifyInOrder([ + initHook(), + build(any), + ]); + verifyNoMoreHookInteraction(); + + when(build(context)).thenReturn(24); + var previousHook = hook; + + await tester.pumpWidget(HookBuilder( + builder: (context) { + hook = createHook(); + result = use(hook); + return Container(); + }, + )); + + expect(result, 24); + verifyInOrder([ + didUpdateHook(previousHook), + build(context), + ]); + verifyNoMoreHookInteraction(); + + previousHook = hook; + await tester.pump(); + + verifyNoMoreHookInteraction(); + + await tester.pumpWidget(const SizedBox()); + + verify(dispose()).called(1); + verifyNoMoreHookInteraction(); + }); + + testWidgets('dispose all called even on failed', (tester) async { + final dispose2 = MockDispose(); + + when(build(any)).thenReturn(42); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(createHook()); + use(HookTest(dispose: dispose2)); + return Container(); + }), + ); + + when(dispose()).thenThrow(24); + await tester.pumpWidget(const SizedBox()); + + expect(tester.takeException(), 24); + + verifyInOrder([ + dispose2(), + dispose(), + ]); + }); + + testWidgets('hook update with same instance do not call didUpdateHook', + (tester) async { + final hook = createHook(); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(hook); + return Container(); + }), + ); + + verifyInOrder([ + initHook(), + build(any), + ]); + verifyZeroInteractions(didUpdateHook); + verifyZeroInteractions(dispose); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(hook); + return Container(); + }), + ); + + verifyInOrder([ + build(any), + ]); + verifyNever(didUpdateHook(hook)); + verifyNever(initHook()); + verifyNever(dispose()); + }); + + testWidgets('rebuild with different hooks crash', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(HookTest()); + return Container(); + }), + ); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(HookTest()); + return Container(); + }), + ); + + expect(tester.takeException(), isStateError); + }); + testWidgets('rebuilds can add new hooks', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + final a = useState(false).value; + return Text('$a', textDirection: TextDirection.ltr); + }), + ); + + expect(find.text('false'), findsOneWidget); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + final a = useState(true).value; + final b = useState(42).value; + + return Text('$a $b', textDirection: TextDirection.ltr); + }), + ); + + expect(find.text('false 42'), findsOneWidget); + }); + + testWidgets('rebuild can remove hooks', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + final a = useState(false).value; + final b = useState(42).value; + + return Text('$a $b', textDirection: TextDirection.ltr); + }), + ); + + expect(find.text('false 42'), findsOneWidget); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + final a = useState(true).value; + return Text('$a', textDirection: TextDirection.ltr); + }), + ); + + expect(find.text('false'), findsOneWidget); + }); + + testWidgets('use call outside build crash', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + return Container(); + }), + ); + + expect(() => use(HookTest()), throwsAssertionError); + }); + + testWidgets('hot-reload triggers a build', (tester) async { + late int? result; + late HookTest previousHook; + + when(build(any)).thenReturn(42); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + previousHook = createHook(); + result = use(previousHook); + return Container(); + }), + ); + + expect(result, 42); + verifyInOrder([ + initHook(), + build(any), + ]); + verifyZeroInteractions(didUpdateHook); + verifyZeroInteractions(dispose); + + when(build(any)).thenReturn(24); + + hotReload(tester); + await tester.pump(); + + expect(result, 24); + verifyInOrder([ + didUpdateHook(any), + build(any), + ]); + verifyNever(initHook()); + verifyNever(dispose()); + }); + + testWidgets('hot-reload calls reassemble', (tester) async { + final reassemble2 = MockReassemble(); + final didUpdateHook2 = MockDidUpdateHook(); + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(createHook()); + use(HookTest( + reassemble: reassemble2, + didUpdateHook: didUpdateHook2, + )); + return Container(); + }), + ); + + verifyNoMoreInteractions(reassemble); + + hotReload(tester); + await tester.pump(); + + verifyInOrder([ + reassemble(), + reassemble2(), + didUpdateHook(any), + didUpdateHook2(any), + ]); + verifyNoMoreInteractions(reassemble); + }); + + testWidgets("hot-reload don't reassemble newly added hooks", (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(HookTest()); + return Container(); + }), + ); + + verifyNoMoreInteractions(reassemble); + + hotReload(tester); + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(HookTest()); + use(createHook()); + return Container(); + }), + ); + + verifyNoMoreInteractions(didUpdateHook); + verifyNoMoreInteractions(reassemble); + }); + + testWidgets('hot-reload can add hooks at the end of the list', + (tester) async { + late HookTest hook1; + + final dispose2 = MockDispose(); + final initHook2 = MockInitHook(); + final didUpdateHook2 = MockDidUpdateHook(); + final build2 = MockBuild(); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(hook1 = createHook()); + return Container(); + }), + ); + + final context = tester.element(find.byType(HookBuilder)); + + verifyInOrder([ + initHook(), + build(any), + ]); + verifyZeroInteractions(dispose); + verifyZeroInteractions(didUpdateHook); + + hotReload(tester); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(createHook()); + use( + HookTest( + initHook: initHook2, + build: build2, + didUpdateHook: didUpdateHook2, + dispose: dispose2, + ), + ); + return Container(); + }), + ); + + verifyInOrder([ + didUpdateHook(hook1), + build(any), + initHook2(), + build2(context), + ]); + verifyNoMoreInteractions(initHook); + verifyZeroInteractions(dispose); + verifyZeroInteractions(dispose2); + verifyZeroInteractions(didUpdateHook2); + }); + + testWidgets('hot-reload can add hooks in the middle of the list', + (tester) async { + final dispose2 = MockDispose(); + final initHook2 = MockInitHook(); + final didUpdateHook2 = MockDidUpdateHook(); + final build2 = MockBuild(); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(createHook()); + return Container(); + }), + ); + + final context = tester.element(find.byType(HookBuilder)); + + verifyInOrder([ + initHook(), + build(any), + ]); + verifyZeroInteractions(dispose); + verifyZeroInteractions(didUpdateHook); + + hotReload(tester); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(HookTest( + initHook: initHook2, + build: build2, + didUpdateHook: didUpdateHook2, + dispose: dispose2, + )); + use(createHook()); + return Container(); + }), + ); + + verifyInOrder([ + initHook2(), + build2(context), + initHook(), + build(any), + dispose(), + ]); + verifyNoMoreInteractions(didUpdateHook); + verifyNoMoreInteractions(dispose); + verifyZeroInteractions(dispose2); + verifyZeroInteractions(didUpdateHook2); + }); + testWidgets('hot-reload can remove hooks', (tester) async { + final dispose2 = MockDispose(); + final initHook2 = MockInitHook(); + final didUpdateHook2 = MockDidUpdateHook(); + final build2 = MockBuild(); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(createHook()); + use( + HookTest( + initHook: initHook2, + build: build2, + didUpdateHook: didUpdateHook2, + dispose: dispose2, + ), + ); + return Container(); + }), + ); + final context = tester.element(find.byType(HookBuilder)); + + verifyInOrder([ + initHook(), + build(any), + initHook2(), + build2(context), + ]); + + verifyZeroInteractions(dispose); + verifyZeroInteractions(didUpdateHook); + verifyZeroInteractions(dispose2); + verifyZeroInteractions(didUpdateHook2); + + hotReload(tester); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + return Container(); + }), + ); + + verifyInOrder([ + dispose2(), + dispose(), + ]); + + verifyNoMoreInteractions(initHook); + verifyNoMoreInteractions(initHook2); + verifyNoMoreInteractions(build2); + verifyNoMoreInteractions(build); + + verifyZeroInteractions(didUpdateHook); + verifyZeroInteractions(didUpdateHook2); + }); + testWidgets('hot-reload disposes hooks when type change', (tester) async { + late HookTest hook1; + + final dispose2 = MockDispose(); + final initHook2 = MockInitHook(); + final didUpdateHook2 = MockDidUpdateHook(); + final build2 = MockBuild(); + + final dispose3 = MockDispose(); + final initHook3 = MockInitHook(); + final didUpdateHook3 = MockDidUpdateHook(); + final build3 = MockBuild(); + + final dispose4 = MockDispose(); + final initHook4 = MockInitHook(); + final didUpdateHook4 = MockDidUpdateHook(); + final build4 = MockBuild(); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(hook1 = createHook()); + use(HookTest(dispose: dispose2)); + use(HookTest(dispose: dispose3)); + use(HookTest(dispose: dispose4)); + return Container(); + }), + ); + + final context = tester.element(find.byType(HookBuilder)); + + // We don't care about the data from the first render + clearInteractions(initHook); + clearInteractions(didUpdateHook); + clearInteractions(dispose); + clearInteractions(build); + + clearInteractions(initHook2); + clearInteractions(didUpdateHook2); + clearInteractions(dispose2); + clearInteractions(build2); + + clearInteractions(initHook3); + clearInteractions(didUpdateHook3); + clearInteractions(dispose3); + clearInteractions(build3); + + clearInteractions(initHook4); + clearInteractions(didUpdateHook4); + clearInteractions(dispose4); + clearInteractions(build4); + + hotReload(tester); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(createHook()); + // changed type from HookTest + use( + HookTest( + initHook: initHook2, + build: build2, + didUpdateHook: didUpdateHook2, + ), + ); + use( + HookTest( + initHook: initHook3, + build: build3, + didUpdateHook: didUpdateHook3, + ), + ); + use( + HookTest( + initHook: initHook4, + build: build4, + didUpdateHook: didUpdateHook4, + ), + ); + return Container(); + }), + ); + + verifyInOrder([ + didUpdateHook(hook1), + build(any), + initHook2(), + build2(context), + initHook3(), + build3(context), + initHook4(), + build4(context), + dispose4(), + dispose3(), + dispose2(), + ]); + verifyZeroInteractions(initHook); + verifyZeroInteractions(dispose); + verifyZeroInteractions(didUpdateHook2); + verifyZeroInteractions(didUpdateHook3); + verifyZeroInteractions(didUpdateHook4); + }); + + testWidgets('hot-reload disposes hooks when type change', (tester) async { + late HookTest hook1; + + final dispose2 = MockDispose(); + final initHook2 = MockInitHook(); + final didUpdateHook2 = MockDidUpdateHook(); + final build2 = MockBuild(); + + final dispose3 = MockDispose(); + final initHook3 = MockInitHook(); + final didUpdateHook3 = MockDidUpdateHook(); + final build3 = MockBuild(); + + final dispose4 = MockDispose(); + final initHook4 = MockInitHook(); + final didUpdateHook4 = MockDidUpdateHook(); + final build4 = MockBuild(); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(hook1 = createHook()); + use(HookTest(dispose: dispose2)); + use(HookTest(dispose: dispose3)); + use(HookTest(dispose: dispose4)); + return Container(); + }), + ); + + final context = tester.element(find.byType(HookBuilder)); + + // We don't care about the data from the first render + clearInteractions(initHook); + clearInteractions(didUpdateHook); + clearInteractions(dispose); + clearInteractions(build); + + clearInteractions(initHook2); + clearInteractions(didUpdateHook2); + clearInteractions(dispose2); + clearInteractions(build2); + + clearInteractions(initHook3); + clearInteractions(didUpdateHook3); + clearInteractions(dispose3); + clearInteractions(build3); + + clearInteractions(initHook4); + clearInteractions(didUpdateHook4); + clearInteractions(dispose4); + clearInteractions(build4); + + hotReload(tester); + await tester.pumpWidget( + HookBuilder(builder: (context) { + use(createHook()); + // changed type from HookTest + use(HookTest( + initHook: initHook2, + build: build2, + didUpdateHook: didUpdateHook2, + )); + use(HookTest( + initHook: initHook3, + build: build3, + didUpdateHook: didUpdateHook3, + )); + use(HookTest( + initHook: initHook4, + build: build4, + didUpdateHook: didUpdateHook4, + )); + return Container(); + }), + ); + + verifyInOrder([ + didUpdateHook(hook1), + build(any), + initHook2(), + build2(context), + initHook3(), + build3(context), + initHook4(), + build4(context), + dispose4(), + dispose3(), + dispose2(), + ]); + verifyZeroInteractions(initHook); + verifyZeroInteractions(dispose); + verifyZeroInteractions(didUpdateHook2); + verifyZeroInteractions(didUpdateHook3); + verifyZeroInteractions(didUpdateHook4); + }); + + testWidgets('hot-reload without hooks do not crash', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (c) { + return Container(); + }), + ); + + hotReload(tester); + await tester.pump(); + }); + + testWidgets('refreshes identical widgets on hot-reload', (tester) async { + var value = 0; + final child = HookBuilder(builder: (context) { + use(MayHaveChangedOnReassemble()); + + return Text('$value', textDirection: TextDirection.ltr); + }); + + await tester.pumpWidget(child); + + expect(find.text('0'), findsOneWidget); + + value = 1; + + // ignore: unawaited_futures + tester.binding.reassembleApplication(); + await tester.pump(); + + expect(find.text('1'), findsOneWidget); + }); +} + +class MayHaveChangedOnReassemble extends Hook { + @override + MayHaveChangedOnReassembleState createState() => + MayHaveChangedOnReassembleState(); +} + +class MayHaveChangedOnReassembleState + extends HookState { + @override + void reassemble() { + markMayNeedRebuild(); + } + + @override + bool shouldRebuild() { + return false; + } + + @override + void build(BuildContext context) {} +} + +class MyHook extends Hook { + @override + MyHookState createState() => MyHookState(); +} + +class MyHookState extends HookState { + @override + MyHookState build(BuildContext context) { + return this; + } +} + +class MyStatefulHook extends StatefulHookWidget { + const MyStatefulHook({Key? key, this.value, this.notifier}) : super(key: key); + + final int? value; + final ValueNotifier? notifier; + + @override + _MyStatefulHookState createState() => _MyStatefulHookState(); +} + +class _MyStatefulHookState extends State { + int? value; + + @override + void initState() { + super.initState(); + // voluntarily ues widget.value to verify that state life-cycles are called + value = widget.value; + } + + @override + void didUpdateWidget(MyStatefulHook oldWidget) { + super.didUpdateWidget(oldWidget); + value = widget.value; + } + + @override + Widget build(BuildContext context) { + return Text( + '$value ${useValueListenable(widget.notifier ?? ValueNotifier(value ?? 42))}', + textDirection: TextDirection.ltr, + ); + } +} diff --git a/packages/flutter_hooks/test/memoized_test.dart b/packages/flutter_hooks/test/memoized_test.dart new file mode 100644 index 00000000..deeb9392 --- /dev/null +++ b/packages/flutter_hooks/test/memoized_test.dart @@ -0,0 +1,350 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + final valueBuilder = MockValueBuilder(); + + tearDown(() { + reset(valueBuilder); + }); + + testWidgets('useRef with null initial value', (tester) async { + late ObjectRef ref; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + ref = useRef(null); + return Container(); + }), + ); + + expect(ref.value, null, reason: 'The ref value has the initial set value.'); + ref.value = 42; + + late ObjectRef ref2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + ref2 = useRef(null); + return Container(); + }), + ); + + expect(ref2, ref, reason: 'The ref value remains the same after rebuild.'); + expect(ref2.value, 42, + reason: 'The ref value has the last assigned value.'); + }); + + testWidgets('useRef with non-null initial value', (tester) async { + late ObjectRef ref; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + ref = useRef(41); + return Container(); + }), + ); + + expect(ref.value, 41, reason: 'The ref value has the initial set value.'); + ref.value = 42; + + late ObjectRef ref2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + ref2 = useRef(43); + return Container(); + }), + ); + + expect(ref2, ref, reason: 'The ref value remains the same after rebuild.'); + expect(ref2.value, 42, + reason: 'The ref value has the last assigned value.'); + }); + + testWidgets('memoized without parameter calls valueBuilder once', + (tester) async { + late int result; + + when(valueBuilder()).thenReturn(42); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder); + return Container(); + }), + ); + + verify(valueBuilder()).called(1); + verifyNoMoreInteractions(valueBuilder); + expect(result, 42); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder); + return Container(); + }), + ); + + verifyNoMoreInteractions(valueBuilder); + expect(result, 42); + + await tester.pumpWidget(const SizedBox()); + + verifyNoMoreInteractions(valueBuilder); + }); + + testWidgets( + 'memoized with parameter call valueBuilder again on parameter change', + (tester) async { + late int result; + + when(valueBuilder()).thenReturn(0); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, []); + return Container(); + }), + ); + + verify(valueBuilder()).called(1); + verifyNoMoreInteractions(valueBuilder); + expect(result, 0); + + /* No change */ + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, []); + return Container(); + }), + ); + + verifyNoMoreInteractions(valueBuilder); + expect(result, 0); + + /* Add parameter */ + + when(valueBuilder()).thenReturn(1); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, ['foo']); + return Container(); + }), + ); + + expect(result, 1); + verify(valueBuilder()).called(1); + verifyNoMoreInteractions(valueBuilder); + + /* No change */ + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, ['foo']); + return Container(); + }), + ); + + verifyNoMoreInteractions(valueBuilder); + expect(result, 1); + + /* Remove parameter */ + + when(valueBuilder()).thenReturn(2); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, []); + return Container(); + }), + ); + + expect(result, 2); + verify(valueBuilder()).called(1); + verifyNoMoreInteractions(valueBuilder); + + /* No change */ + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, []); + return Container(); + }), + ); + + verifyNoMoreInteractions(valueBuilder); + expect(result, 2); + + /* DISPOSE */ + + await tester.pumpWidget(const SizedBox()); + + verifyNoMoreInteractions(valueBuilder); + }); + + testWidgets('memoized parameters compared in order', (tester) async { + late int result; + + when(valueBuilder()).thenReturn(0); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, ['foo', 42, 24.0]); + return Container(); + }), + ); + + verify(valueBuilder()).called(1); + verifyNoMoreInteractions(valueBuilder); + expect(result, 0); + + /* Array reference changed but content didn't */ + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, ['foo', 42, 24.0]); + return Container(); + }), + ); + + verifyNoMoreInteractions(valueBuilder); + expect(result, 0); + + /* reader */ + + when(valueBuilder()).thenReturn(1); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, [42, 'foo', 24.0]); + return Container(); + }), + ); + + verify(valueBuilder()).called(1); + verifyNoMoreInteractions(valueBuilder); + expect(result, 1); + + when(valueBuilder()).thenReturn(2); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, [42, 24.0, 'foo']); + return Container(); + }), + ); + + verify(valueBuilder()).called(1); + verifyNoMoreInteractions(valueBuilder); + expect(result, 2); + + /* value change */ + + when(valueBuilder()).thenReturn(3); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, [43, 24.0, 'foo']); + return Container(); + }), + ); + + verify(valueBuilder()).called(1); + verifyNoMoreInteractions(valueBuilder); + expect(result, 3); + + /* Comparison is done using operator== */ + + // type change + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, [43, 24.0, 'foo']); + return Container(); + }), + ); + + verifyNoMoreInteractions(valueBuilder); + expect(result, 3); + + /* DISPOSE */ + + await tester.pumpWidget(const SizedBox()); + + verifyNoMoreInteractions(valueBuilder); + }); + + testWidgets( + "memoized parameter reference do not change don't call valueBuilder", + (tester) async { + late int result; + final parameters = []; + + when(valueBuilder()).thenReturn(0); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, parameters); + return Container(); + }), + ); + + verify(valueBuilder()).called(1); + verifyNoMoreInteractions(valueBuilder); + expect(result, 0); + + /* Array content but reference didn't */ + parameters.add(42); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + result = useMemoized(valueBuilder, parameters); + return Container(); + }), + ); + + verifyNoMoreInteractions(valueBuilder); + + /* DISPOSE */ + + await tester.pumpWidget(const SizedBox()); + + verifyNoMoreInteractions(valueBuilder); + }); + + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useMemoized>(() => Future.value(10)); + useMemoized(() => 43); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + " │ useMemoized>: Instance of 'Future'\n" + ' │ useMemoized: 43\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); +} + +class MockValueBuilder extends Mock { + int call() => super.noSuchMethod( + Invocation.getter(#call), + returnValue: 42, + ) as int; +} diff --git a/packages/flutter_hooks/test/mock.dart b/packages/flutter_hooks/test/mock.dart new file mode 100644 index 00000000..6b68671e --- /dev/null +++ b/packages/flutter_hooks/test/mock.dart @@ -0,0 +1,159 @@ +// ignore_for_file: one_member_abstracts + +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +export 'package:flutter_test/flutter_test.dart' + hide + Func0, + Func1, + Func2, + Func3, + Func4, + Func5, + Func6, + // ignore: undefined_hidden_name, Fake is only available in master + Fake; +export 'package:mockito/mockito.dart'; + +class HookTest extends Hook { + // ignore: prefer_const_constructors_in_immutables + HookTest({ + this.build, + this.dispose, + this.initHook, + this.didUpdateHook, + this.reassemble, + this.createStateFn, + this.didBuild, + this.deactivate, + List? keys, + }) : super(keys: keys); + + final R Function(BuildContext context)? build; + final void Function()? dispose; + final void Function()? didBuild; + final void Function()? initHook; + final void Function()? deactivate; + final void Function(HookTest previousHook)? didUpdateHook; + final void Function()? reassemble; + final HookStateTest Function()? createStateFn; + + @override + HookStateTest createState() => + createStateFn != null ? createStateFn!() : HookStateTest(); +} + +class HookStateTest extends HookState> { + @override + void initHook() { + super.initHook(); + hook.initHook?.call(); + } + + @override + void dispose() { + hook.dispose?.call(); + } + + @override + void didUpdateHook(HookTest oldHook) { + super.didUpdateHook(oldHook); + hook.didUpdateHook?.call(oldHook); + } + + @override + void reassemble() { + super.reassemble(); + hook.reassemble?.call(); + } + + @override + void deactivate() { + super.deactivate(); + hook.deactivate?.call(); + } + + @override + R? build(BuildContext context) { + if (hook.build != null) { + return hook.build!(context); + } + return null; + } +} + +Element _rootOf(Element element) { + late Element root; + element.visitAncestorElements((e) { + root = e; + return true; + }); + return root; +} + +void hotReload(WidgetTester tester) { + final root = _rootOf(tester.allElements.first); + + TestWidgetsFlutterBinding.ensureInitialized().buildOwner?.reassemble(root); +} + +class MockSetState extends Mock { + void call(); +} + +class MockInitHook extends Mock { + void call(); +} + +class MockCreateState>> + extends Mock { + MockCreateState(this.value); + final T value; + + T call() { + return super.noSuchMethod( + Invocation.method(#call, []), + returnValue: value, + returnValueForMissingStub: value, + ) as T; + } +} + +class MockBuild extends Mock { + T call(BuildContext? context); +} + +class MockDeactivate extends Mock { + void call(); +} + +class MockFlutterErrorDetails extends Mock implements FlutterErrorDetails { + @override + String toString({DiagnosticLevel? minLevel}) => super.toString(); +} + +class MockErrorBuilder extends Mock { + Widget call(FlutterErrorDetails error) => super.noSuchMethod( + Invocation.getter(#call), + returnValue: Container(), + ) as Widget; +} + +class MockOnError extends Mock { + void call(FlutterErrorDetails? error); +} + +class MockReassemble extends Mock { + void call(); +} + +class MockDidUpdateHook extends Mock { + void call(HookTest? hook); +} + +class MockDispose extends Mock { + void call(); +} diff --git a/packages/flutter_hooks/test/pre_build_abort_test.dart b/packages/flutter_hooks/test/pre_build_abort_test.dart new file mode 100644 index 00000000..63764fed --- /dev/null +++ b/packages/flutter_hooks/test/pre_build_abort_test.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets( + 'setState during build still cause mayHaveChange to rebuild the element', + (tester) async { + final number = ValueNotifier(0); + + await tester.pumpWidget( + HookBuilder(builder: (c) { + final state = useState(false); + state.value = true; + final isPositive = use(IsPositiveHook(number)); + return Text('$isPositive', textDirection: TextDirection.ltr); + }), + ); + + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + + number.value = -1; + await tester.pump(); + + expect(find.text('false'), findsOneWidget); + expect(find.text('true'), findsNothing); + }); + + testWidgets('setState during build still allow mayHaveChange to abort builds', + (tester) async { + final number = ValueNotifier(0); + + var buildCount = 0; + + await tester.pumpWidget( + HookBuilder(builder: (c) { + buildCount++; + final state = useState(false); + state.value = true; + final isPositive = use(IsPositiveHook(number)); + return Text('$isPositive', textDirection: TextDirection.ltr); + }), + ); + + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + expect(buildCount, 1); + + number.value = 10; + await tester.pump(); + + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + expect(buildCount, 1); + }); + + testWidgets('shouldRebuild defaults to true', (tester) async { + late MayRebuildState first; + var buildCount = 0; + + await tester.pumpWidget( + HookBuilder(builder: (c) { + buildCount++; + first = use(const MayRebuild()); + + return Container(); + }), + ); + + expect(buildCount, 1); + + first.markMayNeedRebuild(); + await tester.pump(); + + expect(buildCount, 2); + }); + testWidgets('can queue multiple mayRebuilds at once', (tester) async { + final firstSpy = ShouldRebuildMock(); + final secondSpy = ShouldRebuildMock(); + late MayRebuildState first; + late MayRebuildState second; + var buildCount = 0; + + await tester.pumpWidget( + HookBuilder(builder: (c) { + buildCount++; + first = use(MayRebuild(firstSpy)); + second = use(MayRebuild(secondSpy)); + + return Container(); + }), + ); + + verifyNoMoreInteractions(firstSpy); + verifyNoMoreInteractions(secondSpy); + expect(buildCount, 1); + + first.markMayNeedRebuild(); + when(firstSpy()).thenReturn(false); + second.markMayNeedRebuild(); + when(secondSpy()).thenReturn(false); + + await tester.pump(); + + expect(buildCount, 1); + verifyInOrder([ + firstSpy(), + secondSpy(), + ]); + verifyNoMoreInteractions(firstSpy); + verifyNoMoreInteractions(secondSpy); + + first.markMayNeedRebuild(); + when(firstSpy()).thenReturn(true); + second.markMayNeedRebuild(); + when(secondSpy()).thenReturn(false); + + await tester.pump(); + + expect(buildCount, 2); + verify(firstSpy()).called(1); + verifyNoMoreInteractions(firstSpy); + verifyNoMoreInteractions(secondSpy); + }); + testWidgets('pre-build-abort', (tester) async { + var buildCount = 0; + final notifier = ValueNotifier(0); + + await tester.pumpWidget( + HookBuilder(builder: (c) { + buildCount++; + final value = use(IsPositiveHook(notifier)); + + return Text('$value', textDirection: TextDirection.ltr); + }), + ); + + expect(buildCount, 1); + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + + notifier.value = -10; + + await tester.pump(); + + expect(buildCount, 2); + expect(find.text('false'), findsOneWidget); + expect(find.text('true'), findsNothing); + + notifier.value = -20; + + await tester.pump(); + + expect(buildCount, 2); + expect(find.text('false'), findsOneWidget); + expect(find.text('true'), findsNothing); + + notifier.value = 20; + + await tester.pump(); + + expect(buildCount, 3); + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + }); + testWidgets('setState then markMayNeedBuild still force build', + (tester) async { + var buildCount = 0; + final notifier = ValueNotifier(0); + + await tester.pumpWidget( + HookBuilder(builder: (c) { + buildCount++; + useListenable(notifier); + final value = use(IsPositiveHook(notifier)); + + return Text('$value', textDirection: TextDirection.ltr); + }), + ); + + expect(buildCount, 1); + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + + notifier.value++; + await tester.pump(); + + expect(buildCount, 2); + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + }); + testWidgets('markMayNeedBuild then widget rebuild forces build', + (tester) async { + var buildCount = 0; + final notifier = ValueNotifier(0); + + Widget build() { + return HookBuilder(builder: (c) { + buildCount++; + final value = use(IsPositiveHook(notifier)); + + return Text('$value', textDirection: TextDirection.ltr); + }); + } + + await tester.pumpWidget(build()); + + expect(buildCount, 1); + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + + notifier.value++; + await tester.pumpWidget(build()); + + expect(buildCount, 2); + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + }); + testWidgets('markMayNeedBuild then didChangeDependencies forces build', + (tester) async { + var buildCount = 0; + final notifier = ValueNotifier(0); + + final child = HookBuilder(builder: (c) { + buildCount++; + Directionality.of(c); + final value = use(IsPositiveHook(notifier)); + + return Text('$value', textDirection: TextDirection.ltr); + }); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: child, + ), + ); + + expect(buildCount, 1); + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + + notifier.value++; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: child, + ), + ); + + expect(buildCount, 2); + expect(find.text('true'), findsOneWidget); + expect(find.text('false'), findsNothing); + }); +} + +class IsPositiveHook extends Hook { + const IsPositiveHook(this.notifier); + + final ValueNotifier notifier; + + @override + IsPositiveHookState createState() { + return IsPositiveHookState(); + } +} + +class IsPositiveHookState extends HookState { + bool dirty = true; + late bool value; + + @override + void initHook() { + super.initHook(); + value = hook.notifier.value >= 0; + hook.notifier.addListener(listener); + } + + void listener() { + dirty = true; + markMayNeedRebuild(); + } + + @override + bool shouldRebuild() { + if (dirty) { + dirty = false; + final newValue = hook.notifier.value >= 0; + if (newValue != value) { + value = newValue; + return true; + } + } + return false; + } + + @override + bool build(BuildContext context) { + if (dirty) { + dirty = false; + value = hook.notifier.value >= 0; + } + return value; + } + + @override + void dispose() { + hook.notifier.removeListener(listener); + super.dispose(); + } +} + +class MayRebuild extends Hook { + const MayRebuild([this.shouldRebuild]); + + final ShouldRebuildMock? shouldRebuild; + + @override + MayRebuildState createState() { + return MayRebuildState(); + } +} + +class MayRebuildState extends HookState { + @override + bool shouldRebuild() { + if (hook.shouldRebuild == null) { + return super.shouldRebuild(); + } + return hook.shouldRebuild!(); + } + + @override + MayRebuildState build(BuildContext context) => this; +} + +class ShouldRebuildMock extends Mock { + bool call() => super.noSuchMethod( + Invocation.getter(#call), + returnValue: false, + ) as bool; +} diff --git a/packages/flutter_hooks/test/use_animation_controller_test.dart b/packages/flutter_hooks/test/use_animation_controller_test.dart new file mode 100644 index 00000000..2c71bf32 --- /dev/null +++ b/packages/flutter_hooks/test/use_animation_controller_test.dart @@ -0,0 +1,216 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('useAnimationController basic', (tester) async { + late AnimationController controller; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useAnimationController(); + return Container(); + }), + ); + + expect(controller.duration, isNull); + expect(controller.reverseDuration, isNull); + expect(controller.lowerBound, 0); + expect(controller.upperBound, 1); + expect(controller.value, 0); + expect(controller.animationBehavior, AnimationBehavior.normal); + expect(controller.debugLabel, isNull); + + controller + ..duration = const Duration(seconds: 1) + ..reverseDuration = const Duration(seconds: 1); + + // check has a ticker + unawaited(controller.forward()); + + // dispose + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('diagnostics', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useAnimationController( + animationBehavior: AnimationBehavior.preserve, + duration: const Duration(seconds: 1), + reverseDuration: const Duration(milliseconds: 500), + initialValue: 42, + lowerBound: 24, + upperBound: 84, + debugLabel: 'Foo', + ); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useSingleTickerProvider\n' + ' │ useAnimationController:\n' + ' │ _AnimationControllerHookState#00000(AnimationController#00000(▶\n' + ' │ 42.000; paused; for Foo), duration: 0:00:01.000000,\n' + ' │ reverseDuration: 0:00:00.500000)\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('useAnimationController complex', (tester) async { + late AnimationController controller; + + TickerProvider provider; + provider = _TickerProvider(); + void onTick(Duration _) {} + when(provider.createTicker(onTick)).thenAnswer((_) { + return tester.createTicker(onTick); + }); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useAnimationController( + vsync: provider, + animationBehavior: AnimationBehavior.preserve, + duration: const Duration(seconds: 1), + reverseDuration: const Duration(milliseconds: 500), + initialValue: 42, + lowerBound: 24, + upperBound: 84, + debugLabel: 'Foo', + ); + return Container(); + }), + ); + + verify(provider.createTicker(onTick)).called(1); + verifyNoMoreInteractions(provider); + + // check has a ticker + // ignore: unawaited_futures + controller.forward(); + expect(controller.duration, const Duration(seconds: 1)); + expect(controller.reverseDuration, const Duration(milliseconds: 500)); + expect(controller.lowerBound, 24); + expect(controller.upperBound, 84); + expect(controller.value, 42); + expect(controller.animationBehavior, AnimationBehavior.preserve); + expect(controller.debugLabel, 'Foo'); + + final previousController = controller; + provider = _TickerProvider(); + when(provider.createTicker(onTick)).thenAnswer((_) { + return tester.createTicker(onTick); + }); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useAnimationController( + vsync: provider, + duration: const Duration(seconds: 2), + reverseDuration: const Duration(seconds: 1), + debugLabel: 'Bar', + ); + return Container(); + }), + ); + + verify(provider.createTicker(onTick)).called(1); + verifyNoMoreInteractions(provider); + expect(controller, previousController); + expect(controller.duration, const Duration(seconds: 2)); + expect(controller.reverseDuration, const Duration(seconds: 1)); + expect(controller.lowerBound, 24); + expect(controller.upperBound, 84); + expect(controller.value, 42); + expect(controller.animationBehavior, AnimationBehavior.preserve); + expect(controller.debugLabel, 'Foo'); + + // dispose + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('switch from uncontrolled to controlled throws', (tester) async { + await tester.pumpWidget(HookBuilder( + builder: (context) { + useAnimationController(); + return Container(); + }, + )); + + await tester.pumpWidget(HookBuilder( + builder: (context) { + useAnimationController(vsync: tester); + return Container(); + }, + )); + + expect(tester.takeException(), isStateError); + }); + testWidgets('switch from controlled to uncontrolled throws', (tester) async { + await tester.pumpWidget(HookBuilder( + builder: (context) { + useAnimationController(vsync: tester); + return Container(); + }, + )); + + await tester.pumpWidget(HookBuilder( + builder: (context) { + useAnimationController(); + return Container(); + }, + )); + + expect(tester.takeException(), isStateError); + }); + + testWidgets('useAnimationController pass down keys', (tester) async { + List? keys; + late AnimationController controller; + await tester.pumpWidget(HookBuilder( + builder: (context) { + controller = useAnimationController(keys: keys); + return Container(); + }, + )); + + final previous = controller; + keys = []; + + await tester.pumpWidget(HookBuilder( + builder: (context) { + controller = useAnimationController(keys: keys); + return Container(); + }, + )); + + expect(previous, isNot(controller)); + }); +} + +class _TickerProvider extends Mock implements TickerProvider { + @override + Ticker createTicker(TickerCallback onTick) => super.noSuchMethod( + Invocation.getter(#createTicker), + returnValue: Ticker(onTick), + ) as Ticker; +} + +class MockEffect extends Mock { + VoidCallback call(); +} diff --git a/packages/flutter_hooks/test/use_animation_test.dart b/packages/flutter_hooks/test/use_animation_test.dart new file mode 100644 index 00000000..b71bdc59 --- /dev/null +++ b/packages/flutter_hooks/test/use_animation_test.dart @@ -0,0 +1,75 @@ +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) { + useAnimation(const AlwaysStoppedAnimation(42)); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useAnimation: 42\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('useAnimation', (tester) async { + var listenable = AnimationController(vsync: tester); + late double result; + + Future pump() { + return tester.pumpWidget(HookBuilder( + builder: (context) { + result = useAnimation(listenable); + return Container(); + }, + )); + } + + await pump(); + + final element = tester.firstElement(find.byType(HookBuilder)); + + expect(result, 0); + expect(element.dirty, false); + listenable.value++; + expect(element.dirty, true); + await tester.pump(); + expect(result, 1); + expect(element.dirty, false); + + final previousListenable = listenable; + listenable = AnimationController(vsync: tester); + + await pump(); + + expect(result, 0); + expect(element.dirty, false); + previousListenable.value++; + expect(element.dirty, false); + listenable.value++; + expect(element.dirty, true); + await tester.pump(); + expect(result, 1); + expect(element.dirty, false); + + await tester.pumpWidget(const SizedBox()); + + listenable.dispose(); + previousListenable.dispose(); + }); +} diff --git a/packages/flutter_hooks/test/use_app_lifecycle_state_test.dart b/packages/flutter_hooks/test/use_app_lifecycle_state_test.dart new file mode 100644 index 00000000..17e32951 --- /dev/null +++ b/packages/flutter_hooks/test/use_app_lifecycle_state_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + group('useAppLifecycleState', () { + testWidgets('returns initial value and rebuild widgets on change', + (tester) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + final state = useAppLifecycleState(); + return Text('$state', textDirection: TextDirection.ltr); + }, + ), + ); + + expect(find.text('AppLifecycleState.resumed'), findsOneWidget); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pump(); + + expect(find.text('AppLifecycleState.inactive'), findsOneWidget); + }); + }); + + group('useOnAppLifecycleStateChange', () { + testWidgets( + 'sends previous and new value on change, without rebuilding widgets', + (tester) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + var buildCount = 0; + final listener = AppLifecycleStateListener(); + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + buildCount++; + useOnAppLifecycleStateChange(listener); + return Container(); + }, + ), + ); + + expect(buildCount, 1); + verifyZeroInteractions(listener); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + + expect(buildCount, 1); + verify(listener(AppLifecycleState.resumed, AppLifecycleState.paused)); + verifyNoMoreInteractions(listener); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + expect(buildCount, 1); + verify(listener(AppLifecycleState.paused, AppLifecycleState.resumed)); + verifyNoMoreInteractions(listener); + }); + }); +} + +class AppLifecycleStateListener extends Mock { + void call(AppLifecycleState? prev, AppLifecycleState state); +} diff --git a/packages/flutter_hooks/test/use_automatic_keep_alive_test.dart b/packages/flutter_hooks/test/use_automatic_keep_alive_test.dart new file mode 100644 index 00000000..00f92e89 --- /dev/null +++ b/packages/flutter_hooks/test/use_automatic_keep_alive_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + group('useAutomaticKeepAlive', () { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useAutomaticKeepAlive(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + " │ useAutomaticKeepAlive: Instance of 'KeepAliveHandle'\n" + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('keeps widget alive in a TabView', (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: DefaultTabController( + length: 2, + child: TabBarView( + children: [ + HookBuilder(builder: (context) { + useAutomaticKeepAlive(); + return Container(); + }), + Container(), + ], + ), + ), + ), + ); + await tester.pump(); + + final findKeepAlive = find.byType(AutomaticKeepAlive); + final keepAlive = tester.element(findKeepAlive); + + expect(findKeepAlive, findsOneWidget); + expect( + keepAlive + .toDiagnosticsNode(style: DiagnosticsTreeStyle.shallow) + .toStringDeep(), + equalsIgnoringHashCodes( + 'AutomaticKeepAlive:\n' + ' state: _AutomaticKeepAliveState#00000(keeping subtree alive,\n' + ' handles: 1 active client)\n', + ), + ); + }); + + testWidgets( + 'start keep alive when wantKeepAlive changes to true', + (tester) async { + final keepAliveNotifier = ValueNotifier(false); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: DefaultTabController( + length: 2, + child: TabBarView( + children: [ + HookBuilder(builder: (context) { + final wantKeepAlive = useValueListenable(keepAliveNotifier); + useAutomaticKeepAlive(wantKeepAlive: wantKeepAlive); + return Container(); + }), + Container(), + ], + ), + ), + ), + ); + await tester.pump(); + + final findKeepAlive = find.byType(AutomaticKeepAlive); + final keepAlive = tester.element(findKeepAlive); + + expect(findKeepAlive, findsOneWidget); + expect( + keepAlive + .toDiagnosticsNode(style: DiagnosticsTreeStyle.shallow) + .toStringDeep(), + equalsIgnoringHashCodes( + 'AutomaticKeepAlive:\n' + ' state: _AutomaticKeepAliveState#00000(handles: no notifications\n' + ' ever received)\n', + ), + ); + + keepAliveNotifier.value = true; + await tester.pump(); + + expect(findKeepAlive, findsOneWidget); + expect( + keepAlive + .toDiagnosticsNode(style: DiagnosticsTreeStyle.shallow) + .toStringDeep(), + equalsIgnoringHashCodes( + 'AutomaticKeepAlive:\n' + ' state: _AutomaticKeepAliveState#00000(keeping subtree alive,\n' + ' handles: 1 active client)\n', + ), + ); + }, + ); + }); +} diff --git a/packages/flutter_hooks/test/use_callback_test.dart b/packages/flutter_hooks/test/use_callback_test.dart new file mode 100644 index 00000000..f5ec5520 --- /dev/null +++ b/packages/flutter_hooks/test/use_callback_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('useCallback', (tester) async { + late int Function() fn; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + fn = useCallback(() => 42, []); + return Container(); + }), + ); + + expect(fn(), 42); + + late int Function() fn2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + fn2 = useCallback(() => 42, []); + return Container(); + }), + ); + + expect(fn2, fn); + + late int Function() fn3; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + fn3 = useCallback(() => 21, [42]); + return Container(); + }), + ); + + expect(fn3, isNot(fn)); + expect(fn3(), 21); + }); + + testWidgets( + 'should return same function when keys are not specified', + (tester) async { + late Function fn1; + late Function fn2; + + await tester.pumpWidget( + HookBuilder( + key: const Key('hook_builder'), + builder: (context) { + fn1 = useCallback(() {}); + return Container(); + }, + ), + ); + + await tester.pumpWidget( + HookBuilder( + key: const Key('hook_builder'), + builder: (context) { + fn2 = useCallback(() {}); + return Container(); + }, + ), + ); + + expect(fn1, same(fn2)); + }, + ); +} diff --git a/packages/flutter_hooks/test/use_context_test.dart b/packages/flutter_hooks/test/use_context_test.dart new file mode 100644 index 00000000..dcae6ad4 --- /dev/null +++ b/packages/flutter_hooks/test/use_context_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'mock.dart'; + +void main() { + group('useContext', () { + testWidgets('returns current BuildContext during build', (tester) async { + late BuildContext res; + + await tester.pumpWidget(HookBuilder(builder: (context) { + res = useContext(); + return Container(); + })); + + final context = tester.firstElement(find.byType(HookBuilder)); + + expect(res, context); + }); + + testWidgets('crashed outside of build', (tester) async { + expect(useContext, throwsAssertionError); + await tester.pumpWidget(HookBuilder( + builder: (context) { + useContext(); + return Container(); + }, + )); + expect(useContext, throwsAssertionError); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_cupertino_tab_controller_test.dart b/packages/flutter_hooks/test/use_cupertino_tab_controller_test.dart new file mode 100644 index 00000000..a57748a2 --- /dev/null +++ b/packages/flutter_hooks/test/use_cupertino_tab_controller_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useCupertinoTabController(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + " │ useCupertinoTabController: Instance of 'CupertinoTabController'\n" + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useCupertinoTabController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late CupertinoTabController controller; + late CupertinoTabController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = CupertinoTabController(); + controller = useCupertinoTabController(); + return Container(); + }), + ); + + expect(controller.index, controller2.index); + }); + testWidgets("returns a CupertinoTabController that doesn't change", + (tester) async { + late CupertinoTabController controller; + late CupertinoTabController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useCupertinoTabController(initialIndex: 1); + return Container(); + }), + ); + + expect(controller, isA()); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = useCupertinoTabController(initialIndex: 1); + return Container(); + }), + ); + + expect(identical(controller, controller2), isTrue); + }); + + testWidgets('passes hook parameters to the CupertinoTabController', + (tester) async { + late CupertinoTabController controller; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = useCupertinoTabController(initialIndex: 2); + + return Container(); + }, + ), + ); + + expect(controller.index, 2); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_debounce_test.dart b/packages/flutter_hooks/test/use_debounce_test.dart new file mode 100644 index 00000000..7cd4a811 --- /dev/null +++ b/packages/flutter_hooks/test/use_debounce_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useDebounced(42, const Duration(milliseconds: 500)); + return const SizedBox(); + }), + ); + + // await the debouncer timeout. + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useDebounced: 42\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useDebounced', () { + testWidgets('default value is null', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + final debounced = useDebounced( + 'test', + const Duration(milliseconds: 500), + ); + return Text( + debounced.toString(), + textDirection: TextDirection.ltr, + ); + }), + ); + expect(find.text('null'), findsOneWidget); + }); + testWidgets('basic', (tester) async { + await tester.pumpWidget( + HookBuilder( + builder: (context) { + final textValueNotifier = useState('Hello'); + final debounced = useDebounced( + textValueNotifier.value, + const Duration(milliseconds: 500), + ); + + useEffect(() { + textValueNotifier.value = 'World'; + return null; + }, [textValueNotifier.value]); + + return Text( + debounced.toString(), + textDirection: TextDirection.ltr, + ); + }, + ), + ); + + // Ensure that the initial value displayed is 'null' + expect(find.text('null'), findsOneWidget); + + // Ensure that after a 500ms delay, the value 'Hello' of 'textValueNotifier' + // is reflected in 'debounced' and displayed + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + expect(find.text('Hello'), findsOneWidget); + + // Ensure that after another 500ms delay, the value 'World' assigned to + // 'textValueNotifier' in the useEffect is reflected in 'debounced' + // and displayed + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + expect(find.text('World'), findsOneWidget); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_draggable_scrollable_controller_test.dart b/packages/flutter_hooks/test/use_draggable_scrollable_controller_test.dart new file mode 100644 index 00000000..9c2e32d5 --- /dev/null +++ b/packages/flutter_hooks/test/use_draggable_scrollable_controller_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useDraggableScrollableController(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes('HookBuilder\n' + ' │ useDraggableScrollableController: Instance of\n' + " │ 'DraggableScrollableController'\n" + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n'), + ); + }); + + group('useDraggableScrollableController', () { + testWidgets( + 'controller functions correctly and initial values matches with real constructor', + (tester) async { + late DraggableScrollableController controller; + final controller2 = DraggableScrollableController(); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HookBuilder(builder: (context) { + return Column( + children: [ + ElevatedButton( + onPressed: () { + showBottomSheet( + context: context, + builder: (context) { + // Using a builder here to ensure that the controller is + // disposed when the sheet is closed. + return HookBuilder(builder: (context) { + controller = useDraggableScrollableController(); + return DraggableScrollableSheet( + controller: controller, + builder: (context, scrollController) { + return ListView.builder( + controller: scrollController, + itemCount: 100, + itemBuilder: (context, index) { + return ListTile( + title: Text('Item $index on Sheet 1'), + ); + }, + ); + }, + ); + }); + }); + }, + child: const Text('Open Sheet 1'), + ), + ElevatedButton( + onPressed: () { + showBottomSheet( + context: context, + builder: (context) { + return DraggableScrollableSheet( + controller: controller2, + builder: (context, scrollController) { + return ListView.builder( + controller: scrollController, + itemCount: 100, + itemBuilder: (context, index) { + return ListTile( + title: Text('Item $index on Sheet 2'), + ); + }, + ); + }, + ); + }, + ); + }, + child: const Text('Open Sheet 2'), + ) + ], + ); + }), + ), + )); + + // Open Sheet 1 and get the initial values + await tester.tap(find.text('Open Sheet 1')); + await tester.pumpAndSettle(); + final controllerPixels = controller.pixels; + final controllerSize = controller.size; + final controllerIsAttached = controller.isAttached; + // Close Sheet 1 by dragging it down + await tester.fling( + find.byType(DraggableScrollableSheet), const Offset(0, 500), 300); + await tester.pumpAndSettle(); + + // Open Sheet 2 and get the initial values + await tester.tap(find.text('Open Sheet 2')); + await tester.pumpAndSettle(); + final controller2Pixels = controller2.pixels; + final controller2Size = controller2.size; + final controller2IsAttached = controller2.isAttached; + // Close Sheet 2 by dragging it down + await tester.fling( + find.byType(DraggableScrollableSheet), + const Offset(0, 500), + 300, + ); + await tester.pumpAndSettle(); + + // Compare the initial values of the two controllers + expect(controllerPixels, controller2Pixels); + expect(controllerSize, controller2Size); + expect(controllerIsAttached, controller2IsAttached); + + // Open Sheet 1 again and use the controller to scroll + await tester.tap(find.text('Open Sheet 1')); + await tester.pumpAndSettle(); + const targetSize = 1.0; + controller.jumpTo(targetSize); + expect(targetSize, controller.size); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_effect_test.dart b/packages/flutter_hooks/test/use_effect_test.dart new file mode 100644 index 00000000..a6c42aa4 --- /dev/null +++ b/packages/flutter_hooks/test/use_effect_test.dart @@ -0,0 +1,355 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + final effect = MockEffect(); + final unrelated = MockWidgetBuild(); + List? parameters; + + Widget builder() { + return HookBuilder(builder: (context) { + useEffect(effect, parameters); + unrelated(); + return Container(); + }); + } + + tearDown(() { + parameters = null; + reset(unrelated); + reset(effect); + }); + + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useEffect(() { + return null; + }, []); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useEffect\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('useEffect calls callback on every build', (tester) async { + final effect = MockEffect(); + final dispose = MockDispose(); + + when(effect()).thenReturn(dispose); + + Widget builder() { + return HookBuilder(builder: (context) { + useEffect(effect); + unrelated(); + return Container(); + }); + } + + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(dispose); + verifyNoMoreInteractions(effect); + + await tester.pumpWidget(builder()); + + verifyInOrder([ + dispose(), + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(dispose); + verifyNoMoreInteractions(effect); + }); + + testWidgets( + 'useEffect with parameters calls callback when changing from null to something', + (tester) async { + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters = ['foo']; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + }); + + testWidgets('useEffect adding parameters call callback', (tester) async { + parameters = ['foo']; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters = ['foo', 42]; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + }); + + testWidgets('useEffect removing parameters call callback', (tester) async { + parameters = ['foo']; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters = []; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + }); + testWidgets('useEffect changing parameters call callback', (tester) async { + parameters = ['foo']; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters = ['bar']; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + }); + testWidgets( + 'useEffect with same parameters but different arrays don t call callback', + (tester) async { + parameters = ['foo']; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters = ['foo']; + await tester.pumpWidget(builder()); + + verifyNoMoreInteractions(effect); + }); + testWidgets( + 'useEffect with same array but different parameters don t call callback', + (tester) async { + parameters = ['foo']; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters!.add('bar'); + await tester.pumpWidget(builder()); + + verifyNoMoreInteractions(effect); + }); + + testWidgets('useEffect disposer called whenever callback called', + (tester) async { + final effect = MockEffect(); + List? parameters; + + Widget builder() { + return HookBuilder(builder: (context) { + useEffect(effect, parameters); + return Container(); + }); + } + + parameters = ['foo']; + final disposerA = MockDispose(); + when(effect()).thenReturn(disposerA); + + await tester.pumpWidget(builder()); + + verify(effect()).called(1); + verifyNoMoreInteractions(effect); + verifyZeroInteractions(disposerA); + + await tester.pumpWidget(builder()); + + verifyNoMoreInteractions(effect); + verifyZeroInteractions(disposerA); + + parameters = ['bar']; + final disposerB = MockDispose(); + when(effect()).thenReturn(disposerB); + + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + disposerA(), + ]); + verifyNoMoreInteractions(disposerA); + verifyNoMoreInteractions(effect); + verifyZeroInteractions(disposerB); + + await tester.pumpWidget(builder()); + + verifyNoMoreInteractions(disposerA); + verifyNoMoreInteractions(effect); + verifyZeroInteractions(disposerB); + + await tester.pumpWidget(Container()); + + verify(disposerB()).called(1); + verifyNoMoreInteractions(disposerB); + verifyNoMoreInteractions(disposerA); + verifyNoMoreInteractions(effect); + }); + + testWidgets( + 'does NOT call effect when one of keys is NaN and others are same', + (tester) async { + parameters = [double.nan]; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters = [double.nan]; + await tester.pumpWidget(builder()); + + verifyNoMoreInteractions(effect); + }); + + testWidgets( + 'calls effect when first key is two matching NaN but second key is different', + (tester) async { + // Regression test for https://github.com/rrousselGit/flutter_hooks/issues/384 + parameters = [double.nan, 0]; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters = [double.nan, 1]; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + }); + + testWidgets( + 'calls effect when one of keys is changed from 0.0 to -0.0 and vice versa', + (tester) async { + parameters = [0.0]; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters = [-0.0]; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters = [0.0]; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + }); + + testWidgets( + 'calls effect when first key is two matching 0 with same sign, but second key is different', + (tester) async { + // Regression test for https://github.com/rrousselGit/flutter_hooks/issues/384 + parameters = [-0, 42]; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + + parameters = [-0, 43]; + await tester.pumpWidget(builder()); + + verifyInOrder([ + effect(), + unrelated(), + ]); + verifyNoMoreInteractions(effect); + }); +} + +class MockEffect extends Mock { + VoidCallback? call(); +} + +class MockWidgetBuild extends Mock { + void call(); +} diff --git a/packages/flutter_hooks/test/use_expansible_controller_test.dart b/packages/flutter_hooks/test/use_expansible_controller_test.dart new file mode 100644 index 00000000..76d2207d --- /dev/null +++ b/packages/flutter_hooks/test/use_expansible_controller_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useExpansibleController(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + " │ useExpansibleController: Instance of 'ExpansibleController'\n" + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useExpansibleController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late ExpansibleController controller; + final controller2 = ExpansibleController(); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HookBuilder(builder: (context) { + controller = useExpansibleController(); + return Column( + children: [ + ExpansionTile( + controller: controller, + title: const Text('Expansion Tile'), + ), + ExpansionTile( + controller: controller2, + title: const Text('Expansion Tile 2'), + ), + ], + ); + }), + ), + )); + expect(controller, isA()); + expect(controller.isExpanded, controller2.isExpanded); + }); + + testWidgets('check expansion/collapse of tile', (tester) async { + late ExpansibleController controller; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HookBuilder(builder: (context) { + controller = useExpansibleController(); + return ExpansionTile( + controller: controller, + title: const Text('Expansion Tile'), + ); + }), + ), + )); + + expect(controller.isExpanded, false); + controller.expand(); + expect(controller.isExpanded, true); + controller.collapse(); + expect(controller.isExpanded, false); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_fixed_extent_scroll_controller_test.dart b/packages/flutter_hooks/test/use_fixed_extent_scroll_controller_test.dart new file mode 100644 index 00000000..00f9f6c8 --- /dev/null +++ b/packages/flutter_hooks/test/use_fixed_extent_scroll_controller_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useFixedExtentScrollController(); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useFixedExtentScrollController:\n' + ' │ FixedExtentScrollController#00000(no clients)\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useFixedExtentScrollController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late FixedExtentScrollController controller; + late FixedExtentScrollController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = FixedExtentScrollController(); + controller = useFixedExtentScrollController(); + return Container(); + }), + ); + + expect(controller.debugLabel, controller2.debugLabel); + expect(controller.initialItem, controller2.initialItem); + expect(controller.onAttach, controller2.onAttach); + expect(controller.onDetach, controller2.onDetach); + }); + testWidgets("returns a FixedExtentScrollController that doesn't change", + (tester) async { + late FixedExtentScrollController controller; + late FixedExtentScrollController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = FixedExtentScrollController(); + controller = useFixedExtentScrollController(); + return Container(); + }), + ); + expect(controller, isA()); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = useFixedExtentScrollController(); + return Container(); + }), + ); + + expect(identical(controller, controller2), isTrue); + }); + + testWidgets('passes hook parameters to the FixedExtentScrollController', + (tester) async { + late FixedExtentScrollController controller; + + void onAttach(ScrollPosition position) {} + void onDetach(ScrollPosition position) {} + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = useFixedExtentScrollController( + initialItem: 42, + onAttach: onAttach, + onDetach: onDetach, + ); + + return Container(); + }, + ), + ); + + expect(controller.initialItem, 42); + expect(controller.onAttach, onAttach); + expect(controller.onDetach, onDetach); + }); + }); +} + +class TickerProviderMock extends Mock implements TickerProvider {} diff --git a/packages/flutter_hooks/test/use_focus_node_test.dart b/packages/flutter_hooks/test/use_focus_node_test.dart new file mode 100644 index 00000000..0942c6b9 --- /dev/null +++ b/packages/flutter_hooks/test/use_focus_node_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('creates a focus node and disposes it', (tester) async { + late FocusNode focusNode; + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusNode = useFocusNode(); + return Container(); + }), + ); + + expect(focusNode, isA()); + // ignore: invalid_use_of_protected_member + expect(focusNode.hasListeners, isFalse); + + final previousValue = focusNode; + + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusNode = useFocusNode(); + return Container(); + }), + ); + + expect(previousValue, focusNode); + // check you can add listener (only possible if not disposed) + focusNode.addListener(() {}); + + await tester.pumpWidget(Container()); + + expect( + () => focusNode.addListener(() {}), + throwsAssertionError, + ); + }); + + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useFocusNode(); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useFocusNode: FocusNode#00000\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('default values matches with FocusNode', (tester) async { + final official = FocusNode(); + + late FocusNode focusNode; + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusNode = useFocusNode(); + return Container(); + }), + ); + + expect(focusNode.debugLabel, official.debugLabel); + expect(focusNode.skipTraversal, official.skipTraversal); + expect(focusNode.canRequestFocus, official.canRequestFocus); + expect(focusNode.descendantsAreFocusable, official.descendantsAreFocusable); + expect(focusNode.descendantsAreTraversable, + official.descendantsAreTraversable); + }); + + testWidgets('has all the FocusNode parameters', (tester) async { + KeyEventResult onKeyEvent(FocusNode node, KeyEvent event) => + KeyEventResult.ignored; + + late FocusNode focusNode; + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusNode = useFocusNode( + debugLabel: 'Foo', + onKeyEvent: onKeyEvent, + skipTraversal: true, + canRequestFocus: false, + descendantsAreFocusable: false, + descendantsAreTraversable: false, + ); + return Container(); + }), + ); + + expect(focusNode.debugLabel, 'Foo'); + expect(focusNode.onKeyEvent, onKeyEvent); + expect(focusNode.skipTraversal, true); + expect(focusNode.canRequestFocus, false); + expect(focusNode.descendantsAreFocusable, false); + expect(focusNode.descendantsAreTraversable, false); + }); + + testWidgets('handles parameter change', (tester) async { + KeyEventResult onKeyEvent(FocusNode node, KeyEvent event) => + KeyEventResult.ignored; + KeyEventResult onKeyEvent2(FocusNode node, KeyEvent event) => + KeyEventResult.ignored; + + late FocusNode focusNode; + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusNode = useFocusNode( + debugLabel: 'Foo', + onKeyEvent: onKeyEvent, + skipTraversal: true, + canRequestFocus: false, + descendantsAreFocusable: false, + descendantsAreTraversable: false, + ); + + return Container(); + }), + ); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusNode = useFocusNode( + debugLabel: 'Bar', + onKeyEvent: onKeyEvent2, + ); + + return Container(); + }), + ); + + expect(focusNode.onKeyEvent, onKeyEvent2); + expect(focusNode.debugLabel, 'Bar'); + expect(focusNode.skipTraversal, false); + expect(focusNode.canRequestFocus, true); + expect(focusNode.descendantsAreFocusable, true); + expect(focusNode.descendantsAreTraversable, true); + }); +} diff --git a/packages/flutter_hooks/test/use_focus_scope_node_test.dart b/packages/flutter_hooks/test/use_focus_scope_node_test.dart new file mode 100644 index 00000000..fcadb77a --- /dev/null +++ b/packages/flutter_hooks/test/use_focus_scope_node_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('creates a focus scope node and disposes it', (tester) async { + late FocusScopeNode focusScopeNode; + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusScopeNode = useFocusScopeNode(); + return Container(); + }), + ); + + expect(focusScopeNode, isA()); + // ignore: invalid_use_of_protected_member + expect(focusScopeNode.hasListeners, isFalse); + + final previousValue = focusScopeNode; + + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusScopeNode = useFocusScopeNode(); + return Container(); + }), + ); + + expect(previousValue, focusScopeNode); + // ignore: invalid_use_of_protected_member + expect(focusScopeNode.hasListeners, isFalse); + + await tester.pumpWidget(Container()); + + expect( + () => focusScopeNode.dispose(), + throwsAssertionError, + ); + }); + + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useFocusScopeNode(); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useFocusScopeNode: FocusScopeNode#00000\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('default values matches with FocusScopeNode', (tester) async { + final official = FocusScopeNode(); + + late FocusScopeNode focusScopeNode; + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusScopeNode = useFocusScopeNode(); + return Container(); + }), + ); + + expect(focusScopeNode.debugLabel, official.debugLabel); + expect(focusScopeNode.skipTraversal, official.skipTraversal); + expect(focusScopeNode.canRequestFocus, official.canRequestFocus); + }); + + testWidgets('has all the FocusScopeNode parameters', (tester) async { + KeyEventResult onKeyEvent(FocusNode node, KeyEvent event) => + KeyEventResult.ignored; + + late FocusScopeNode focusScopeNode; + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusScopeNode = useFocusScopeNode( + debugLabel: 'Foo', + onKeyEvent: onKeyEvent, + skipTraversal: true, + canRequestFocus: false, + ); + return Container(); + }), + ); + + expect(focusScopeNode.debugLabel, 'Foo'); + expect(focusScopeNode.onKeyEvent, onKeyEvent); + expect(focusScopeNode.skipTraversal, true); + expect(focusScopeNode.canRequestFocus, false); + }); + + testWidgets('handles parameter change', (tester) async { + KeyEventResult onKeyEvent(FocusNode node, KeyEvent event) => + KeyEventResult.ignored; + KeyEventResult onKeyEvent2(FocusNode node, KeyEvent event) => + KeyEventResult.ignored; + + late FocusScopeNode focusScopeNode; + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusScopeNode = useFocusScopeNode( + debugLabel: 'Foo', + onKeyEvent: onKeyEvent, + skipTraversal: true, + canRequestFocus: false, + ); + + return Container(); + }), + ); + + await tester.pumpWidget( + HookBuilder(builder: (_) { + focusScopeNode = useFocusScopeNode( + debugLabel: 'Bar', + onKeyEvent: onKeyEvent2, + ); + + return Container(); + }), + ); + + expect(focusScopeNode.onKeyEvent, onKeyEvent2); + expect(focusScopeNode.debugLabel, 'Bar'); + expect(focusScopeNode.skipTraversal, false); + expect(focusScopeNode.canRequestFocus, true); + }); +} diff --git a/packages/flutter_hooks/test/use_future_test.dart b/packages/flutter_hooks/test/use_future_test.dart new file mode 100644 index 00000000..497f98de --- /dev/null +++ b/packages/flutter_hooks/test/use_future_test.dart @@ -0,0 +1,231 @@ +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('default preserve state, changing future keeps previous value', + (tester) async { + late AsyncSnapshot value; + Widget Function(BuildContext) builder(Future stream) { + return (context) { + value = useFuture(stream); + return Container(); + }; + } + + var future = Future.value(0); + await tester.pumpWidget(HookBuilder(builder: builder(future))); + expect(value.data, null); + await tester.pumpWidget(HookBuilder(builder: builder(future))); + expect(value.data, 0); + + future = Future.value(42); + await tester.pumpWidget(HookBuilder(builder: builder(future))); + expect(value.data, 0); + await tester.pumpWidget(HookBuilder(builder: builder(future))); + expect(value.data, 42); + }); + + testWidgets('debugFillProperties', (tester) async { + final future = Future.value(42); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + useFuture(future, initialData: 42); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useFuture: AsyncSnapshot(ConnectionState.done, 42, null,\n' + ' │ null)\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('If preserveState == false, changing future resets value', + (tester) async { + late AsyncSnapshot value; + Widget Function(BuildContext) builder(Future stream) { + return (context) { + value = useFuture(stream, preserveState: false); + return Container(); + }; + } + + var future = Future.value(0); + await tester.pumpWidget(HookBuilder(builder: builder(future))); + expect(value.data, null); + await tester.pumpWidget(HookBuilder(builder: builder(future))); + expect(value.data, 0); + + future = Future.value(42); + await tester.pumpWidget(HookBuilder(builder: builder(future))); + expect(value.data, null); + await tester.pumpWidget(HookBuilder(builder: builder(future))); + expect(value.data, 42); + }); + + Widget Function(BuildContext) snapshotText( + Future stream, { + String? initialData, + }) { + return (context) { + final snapshot = useFuture(stream, initialData: initialData); + return Text(snapshot.toString(), textDirection: TextDirection.ltr); + }; + } + + testWidgets('gracefully handles transition to other future', (tester) async { + final completerA = Completer(); + final completerB = Completer(); + + await tester.pumpWidget( + HookBuilder(builder: snapshotText(completerA.future)), + ); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, null, null, null)', + ), + findsOneWidget, + ); + + await tester.pumpWidget( + HookBuilder(builder: snapshotText(completerB.future)), + ); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, null, null, null)', + ), + findsOneWidget, + ); + + completerB.complete('B'); + completerA.complete('A'); + await eventFiring(tester); + + expect( + find.text('AsyncSnapshot(ConnectionState.done, B, null, null)'), + findsOneWidget, + ); + }); + + testWidgets('tracks life-cycle of Future to success', (tester) async { + final completer = Completer(); + await tester.pumpWidget( + HookBuilder(builder: snapshotText(completer.future)), + ); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, null, null, null)', + ), + findsOneWidget, + ); + + completer.complete('hello'); + await eventFiring(tester); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.done, hello, null, null)', + ), + findsOneWidget, + ); + }); + + testWidgets('tracks life-cycle of Future to error', (tester) async { + final completer = Completer(); + await tester.pumpWidget( + HookBuilder(builder: snapshotText(completer.future)), + ); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, null, null, null)', + ), + findsOneWidget, + ); + + completer.completeError('bad', StackTrace.fromString('stackTrace')); + await eventFiring(tester); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.done, null, bad, stackTrace)', + ), + findsOneWidget, + ); + }); + testWidgets('runs the builder using given initial data', (tester) async { + await tester.pumpWidget( + HookBuilder( + builder: snapshotText( + Future.value(), + initialData: 'I', + ), + ), + ); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, I, null, null)', + ), + findsOneWidget, + ); + }); + testWidgets('ignores initialData when reconfiguring', (tester) async { + await tester.pumpWidget( + HookBuilder( + builder: snapshotText( + Future.value(), + initialData: 'I', + ), + ), + ); + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, I, null, null)', + ), + findsOneWidget, + ); + + final completer = Completer(); + + await tester.pumpWidget( + HookBuilder( + builder: snapshotText( + completer.future, + initialData: 'Ignored', + ), + ), + ); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, null, null, null)', + ), + findsOneWidget, + ); + }); +} + +Future eventFiring(WidgetTester tester) async { + await tester.pump(Duration.zero); +} diff --git a/packages/flutter_hooks/test/use_listenable_selector_test.dart b/packages/flutter_hooks/test/use_listenable_selector_test.dart new file mode 100644 index 00000000..99b14991 --- /dev/null +++ b/packages/flutter_hooks/test/use_listenable_selector_test.dart @@ -0,0 +1,205 @@ +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) { + final listenable = ValueNotifier(42); + useListenableSelector(listenable, () => listenable.value.isOdd); + return const SizedBox(); + }, + ), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useListenableSelector\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n' + '', + ), + ); + }); + + testWidgets('basic', (tester) async { + final listenable = ValueNotifier(0); + // ignore: prefer_function_declarations_over_variables + final isOddSelector = () => listenable.value.isOdd; + var isOdd = listenable.value.isOdd; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + isOdd = useListenableSelector(listenable, isOddSelector); + return Container(); + }, + ), + ); + + final element = tester.firstElement(find.byType(HookBuilder)); + + // ignore: invalid_use_of_protected_member + expect(listenable.hasListeners, true); + expect(listenable.value, 0); + expect(isOdd, false); + expect(element.dirty, false); + + listenable.value++; + + expect(element.dirty, true); + expect(listenable.value, 1); + + await tester.pump(); + + expect(isOdd, true); + expect(element.dirty, false); + + listenable.value++; + + expect(element.dirty, true); + + await tester.pump(); + + expect(listenable.value, 2); + expect(isOdd, false); + + listenable.value = listenable.value + 2; + + expect(element.dirty, false); + + await tester.pump(); + + expect(listenable.value, 4); + expect(isOdd, false); + + listenable.dispose(); + }); + + testWidgets('null as Listener', (tester) async { + const notFoundValue = -1; + final testListener = ValueNotifier(false); + final listenable = ValueNotifier(777); + var result = 0; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + final shouldUseListener = useListenableSelector(testListener, () { + return testListener.value; + }); + + final actualListener = shouldUseListener ? listenable : null; + + result = useListenableSelector(actualListener, () { + return actualListener?.value ?? notFoundValue; + }); + + return Container(); + }, + ), + ); + + expect(result, notFoundValue); + testListener.value = true; + + await tester.pump(); + + expect(result, listenable.value); + }); + + testWidgets('update selector', (tester) async { + final listenable = ValueNotifier(0); + var isOdd = false; + // ignore: prefer_function_declarations_over_variables + bool isOddSelector() => listenable.value.isOdd; + var isEven = false; + bool isEvenSelector() => listenable.value.isEven; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + isOdd = useListenableSelector(listenable, isOddSelector); + return Container(); + }, + ), + ); + + final element = tester.firstElement(find.byType(HookBuilder)); + + // ignore: invalid_use_of_protected_member + expect(listenable.hasListeners, true); + expect(listenable.value, 0); + expect(isOdd, false); + expect(element.dirty, false); + + listenable.value++; + + expect(element.dirty, true); + expect(listenable.value, 1); + + await tester.pump(); + + expect(isOdd, true); + expect(element.dirty, false); + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + isEven = useListenableSelector(listenable, isEvenSelector); + return Container(); + }, + ), + ); + + expect(listenable.value, 1); + expect(isEven, false); + + listenable.dispose(); + }); + + testWidgets('update listenable', (tester) async { + var listenable = ValueNotifier(0); + bool isOddSelector() => listenable.value.isOdd; + var isOdd = false; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + isOdd = useListenableSelector(listenable, isOddSelector); + return Container(); + }, + ), + ); + + expect(isOdd, false); + + final previousListenable = listenable; + listenable = ValueNotifier(1); + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + isOdd = useListenableSelector(listenable, isOddSelector); + return Container(); + }, + ), + ); + + // ignore: invalid_use_of_protected_member + expect(previousListenable.hasListeners, false); + expect(isOdd, true); + + listenable.dispose(); + previousListenable.dispose(); + }); +} diff --git a/packages/flutter_hooks/test/use_listenable_test.dart b/packages/flutter_hooks/test/use_listenable_test.dart new file mode 100644 index 00000000..a5b77c3a --- /dev/null +++ b/packages/flutter_hooks/test/use_listenable_test.dart @@ -0,0 +1,112 @@ +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) { + useListenable(const AlwaysStoppedAnimation(42)); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useListenable: AlwaysStoppedAnimation#00000(▶ 42; paused)\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('useListenable', (tester) async { + var listenable = ValueNotifier(0); + + Future pump() { + return tester.pumpWidget(HookBuilder( + builder: (context) { + useListenable(listenable); + return Container(); + }, + )); + } + + await pump(); + + final element = tester.firstElement(find.byType(HookBuilder)); + + // ignore: invalid_use_of_protected_member + expect(listenable.hasListeners, true); + expect(element.dirty, false); + listenable.value++; + expect(element.dirty, true); + await tester.pump(); + expect(element.dirty, false); + + final previousListenable = listenable; + listenable = ValueNotifier(0); + + await pump(); + + // ignore: invalid_use_of_protected_member + expect(previousListenable.hasListeners, false); + // ignore: invalid_use_of_protected_member + expect(listenable.hasListeners, true); + expect(element.dirty, false); + listenable.value++; + expect(element.dirty, true); + await tester.pump(); + expect(element.dirty, false); + + await tester.pumpWidget(const SizedBox()); + + // ignore: invalid_use_of_protected_member + expect(listenable.hasListeners, false); + + listenable.dispose(); + previousListenable.dispose(); + }); + + testWidgets('useListenable should handle null', (tester) async { + ValueNotifier? listenable; + + Future pump() { + return tester.pumpWidget(HookBuilder( + builder: (context) { + useListenable(listenable); + return Container(); + }, + )); + } + + await pump(); + + final element = tester.firstElement(find.byType(HookBuilder)); + expect(element.dirty, false); + + final notifier = ValueNotifier(0); + listenable = notifier; + await pump(); + + // ignore: invalid_use_of_protected_member + expect(listenable.hasListeners, true); + + listenable = null; + await pump(); + + // ignore: invalid_use_of_protected_member + expect(notifier.hasListeners, false); + + await tester.pumpWidget(const SizedBox()); + + notifier.dispose(); + }); +} diff --git a/packages/flutter_hooks/test/use_material_states_controller_test.dart b/packages/flutter_hooks/test/use_material_states_controller_test.dart new file mode 100644 index 00000000..8592ef90 --- /dev/null +++ b/packages/flutter_hooks/test/use_material_states_controller_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useWidgetStatesController(); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useWidgetStatesController: WidgetStatesController#00000({})\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useWidgetStatesController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late WidgetStatesController controller; + late WidgetStatesController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = WidgetStatesController(); + controller = useWidgetStatesController(); + return Container(); + }), + ); + + expect(controller.value, controller2.value); + }); + testWidgets("returns a WidgetStatesController that doesn't change", + (tester) async { + late WidgetStatesController controller; + late WidgetStatesController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useWidgetStatesController(); + return Container(); + }), + ); + + expect(controller, isA()); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = useWidgetStatesController(); + return Container(); + }), + ); + + expect(identical(controller, controller2), isTrue); + }); + + testWidgets('passes hook parameters to the WidgetStatesController', + (tester) async { + late WidgetStatesController controller; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = useWidgetStatesController( + values: {WidgetState.selected}, + ); + + return Container(); + }, + ), + ); + + expect(controller.value, {WidgetState.selected}); + }); + + testWidgets('disposes the WidgetStatesController on unmount', + (tester) async { + late WidgetStatesController controller; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = useWidgetStatesController(); + return Container(); + }, + ), + ); + + // pump another widget so that the old one gets disposed + await tester.pumpWidget(Container()); + + expect( + () => controller.addListener(() {}), + throwsA(isFlutterError.having( + (e) => e.message, 'message', contains('disposed'))), + ); + }); + }); +} 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..88bef9ef --- /dev/null +++ b/packages/flutter_hooks/test/use_multi_ticker_provider_test.dart @@ -0,0 +1,125 @@ +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), + ); + final animationControllerB = AnimationController( + vsync: provider, + duration: const Duration(seconds: 1), + ); + + unawaited(animationControllerA.forward()); + unawaited(animationControllerB.forward()); + + // With a multi provider, creating additional AnimationControllers is allowed. + expect( + () => AnimationController(vsync: provider, duration: const Duration(seconds: 1)), + returnsNormally, + ); + + 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('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)); + }); +} diff --git a/packages/flutter_hooks/test/use_on_listenable_change_test.dart b/packages/flutter_hooks/test/use_on_listenable_change_test.dart new file mode 100644 index 00000000..35cc10e5 --- /dev/null +++ b/packages/flutter_hooks/test/use_on_listenable_change_test.dart @@ -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 { + 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#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); + }); +} diff --git a/packages/flutter_hooks/test/use_on_stream_change_test.dart b/packages/flutter_hooks/test/use_on_stream_change_test.dart new file mode 100644 index 00000000..2a063ad9 --- /dev/null +++ b/packages/flutter_hooks/test/use_on_stream_change_test.dart @@ -0,0 +1,293 @@ +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 { + final stream = Stream.value(42); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + useOnStreamChange(stream); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + " │ useOnStreamChange: Instance of '_ControllerSubscription'\n" + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('calls onData when data arrives', (tester) async { + const data = 42; + final stream = Stream.value(data); + + late int value; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + useOnStreamChange( + stream, + onData: (data) { + value = data; + }, + ); + return const SizedBox(); + }), + ); + + expect(value, data); + }); + + testWidgets('calls onError when error occurs', (tester) async { + final error = Exception(); + final stream = Stream.error(error); + + late Object receivedError; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + useOnStreamChange( + stream, + onError: (error, stackTrace) { + receivedError = error; + }, + ); + return const SizedBox(); + }), + ); + + expect(receivedError, same(error)); + }); + + testWidgets('calls onDone when stream is closed', (tester) async { + final streamController = StreamController.broadcast(); + + var onDoneCalled = false; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + useOnStreamChange( + streamController.stream, + onDone: () { + onDoneCalled = true; + }, + ); + return const SizedBox(); + }), + ); + + await streamController.close(); + + expect(onDoneCalled, isTrue); + }); + + testWidgets( + 'cancels subscription when cancelOnError is true and error occurrs', + (tester) => tester.runAsync(() async { + final streamController = StreamController(); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + useOnStreamChange( + streamController.stream, + // onError needs to be set to prevent unhandled errors from propagating. + onError: (error, stackTrace) {}, + cancelOnError: true, + ); + return const SizedBox(); + }), + ); + + expect(streamController.hasListener, isTrue); + + streamController.addError(Exception()); + + await tester.pump(); + + expect(streamController.hasListener, isFalse); + + await streamController.close(); + }), + ); + + testWidgets( + 'listens new stream when stream is changed', + (tester) => tester.runAsync(() async { + final streamController1 = StreamController(); + final streamController2 = StreamController(); + + late StreamSubscription subscription1; + late StreamSubscription subscription2; + + await tester.pumpWidget( + HookBuilder( + key: const Key('hook_builder'), + builder: (context) { + subscription1 = useOnStreamChange(streamController1.stream)!; + return const SizedBox(); + }, + ), + ); + + expect(streamController1.hasListener, isTrue); + expect(streamController2.hasListener, isFalse); + + await tester.pumpWidget( + HookBuilder( + key: const Key('hook_builder'), + builder: (context) { + subscription2 = useOnStreamChange(streamController2.stream)!; + return const SizedBox(); + }, + ), + ); + + expect(streamController1.hasListener, isFalse); + expect(streamController2.hasListener, isTrue); + + expect(subscription1, isNot(same(subscription2))); + + await streamController1.close(); + await streamController2.close(); + }), + ); + + testWidgets( + 'resubscribes stream when cancelOnError changed', + (tester) => tester.runAsync(() async { + var listenCount = 0; + var cancelCount = 0; + + final streamController = StreamController.broadcast( + onListen: () => listenCount++, + onCancel: () => cancelCount++, + ); + final stream = streamController.stream; + + await tester.pumpWidget( + HookBuilder( + key: const Key('hook'), + builder: (context) { + useOnStreamChange( + stream, + cancelOnError: false, + ); + return const SizedBox(); + }, + ), + ); + + expect(listenCount, 1); + expect(cancelCount, isZero); + + await tester.pumpWidget( + HookBuilder( + key: const Key('hook'), + builder: (context) { + useOnStreamChange( + stream, + cancelOnError: true, + ); + return const SizedBox(); + }, + ), + ); + + expect(listenCount, 2); + expect(cancelCount, 1); + + await streamController.close(); + }), + ); + + testWidgets( + 'stop listening when cancel is called on StreamSubscription', + (tester) => tester.runAsync(() async { + final streamController = StreamController(); + + late StreamSubscription subscription; + + await tester.pumpWidget( + HookBuilder( + key: const Key('hook_builder'), + builder: (context) { + subscription = useOnStreamChange(streamController.stream)!; + return const SizedBox(); + }, + ), + ); + + await subscription.cancel(); + + expect(streamController.hasListener, isFalse); + + await streamController.close(); + }), + ); + + testWidgets('returns null when stream is null', (tester) async { + StreamSubscription? subscription; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + subscription = useOnStreamChange(null); + return const SizedBox(); + }), + ); + + expect(subscription, isNull); + }); + + testWidgets( + 'unsubscribes when stream changed to null', + (tester) => tester.runAsync(() async { + final streamController = StreamController(); + + StreamSubscription? subscription; + + await tester.pumpWidget( + HookBuilder( + key: const Key('hook_builder'), + builder: (context) { + subscription = useOnStreamChange(streamController.stream); + return const SizedBox(); + }, + ), + ); + + expect(streamController.hasListener, isTrue); + expect(subscription, isNotNull); + + await tester.pumpWidget( + HookBuilder( + key: const Key('hook_builder'), + builder: (context) { + subscription = useOnStreamChange(null); + return const SizedBox(); + }, + ), + ); + + expect(streamController.hasListener, isFalse); + expect(subscription, isNotNull); + + await streamController.close(); + }), + ); +} diff --git a/packages/flutter_hooks/test/use_overlay_portal_controller_test.dart b/packages/flutter_hooks/test/use_overlay_portal_controller_test.dart new file mode 100644 index 00000000..c922645b --- /dev/null +++ b/packages/flutter_hooks/test/use_overlay_portal_controller_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useOverlayPortalController(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useOverlayPortalController: OverlayPortalController DETACHED\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useOverlayPortalController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late OverlayPortalController controller; + final controller2 = OverlayPortalController(); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HookBuilder(builder: (context) { + controller = useOverlayPortalController(); + return Column( + children: [ + OverlayPortal( + controller: controller, + overlayChildBuilder: (context) => + const Text('Overlay Portal'), + ), + OverlayPortal( + controller: controller2, + overlayChildBuilder: (context) => + const Text('Overlay Portal 2'), + ), + ], + ); + }), + ), + )); + expect(controller, isA()); + expect(controller.isShowing, controller2.isShowing); + }); + + testWidgets('check show/hide of overlay portal', (tester) async { + late OverlayPortalController controller; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HookBuilder(builder: (context) { + controller = useOverlayPortalController(); + return OverlayPortal( + controller: controller, + overlayChildBuilder: (context) => const Text('Overlay Content'), + ); + }), + ), + )); + + expect(controller.isShowing, false); + expect(find.text('Overlay Content'), findsNothing); + + controller.show(); + await tester.pump(); + expect(controller.isShowing, true); + expect(find.text('Overlay Content'), findsOneWidget); + + controller.hide(); + await tester.pump(); + expect(controller.isShowing, false); + expect(find.text('Overlay Content'), findsNothing); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_page_controller_test.dart b/packages/flutter_hooks/test/use_page_controller_test.dart new file mode 100644 index 00000000..7015090e --- /dev/null +++ b/packages/flutter_hooks/test/use_page_controller_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + usePageController(); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ usePageController: PageController#00000(no clients)\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('usePageController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late PageController controller; + late PageController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = PageController(); + controller = usePageController(); + return Container(); + }), + ); + + expect(controller.initialPage, controller2.initialPage); + expect(controller.keepPage, controller2.keepPage); + expect(controller.viewportFraction, controller2.viewportFraction); + expect(controller.onAttach, controller2.onAttach); + expect(controller.onDetach, controller2.onDetach); + }); + testWidgets("returns a PageController that doesn't change", (tester) async { + late PageController controller; + late PageController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = usePageController(); + return Container(); + }), + ); + + expect(controller, isA()); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = usePageController(); + return Container(); + }), + ); + + expect(identical(controller, controller2), isTrue); + }); + + testWidgets('passes hook parameters to the PageController', (tester) async { + late PageController controller; + + void onAttach(ScrollPosition position) {} + void onDetach(ScrollPosition position) {} + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = usePageController( + initialPage: 42, + keepPage: false, + viewportFraction: 3.4, + onAttach: onAttach, + onDetach: onDetach, + ); + + return Container(); + }, + ), + ); + + expect(controller.initialPage, 42); + expect(controller.keepPage, false); + expect(controller.viewportFraction, 3.4); + expect(controller.onAttach, onAttach); + expect(controller.onDetach, onDetach); + }); + + testWidgets('disposes the PageController on unmount', (tester) async { + late PageController controller; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = usePageController(); + return Container(); + }, + ), + ); + + // pump another widget so that the old one gets disposed + await tester.pumpWidget(Container()); + + expect( + () => controller.addListener(() {}), + throwsA(isFlutterError.having( + (e) => e.message, 'message', contains('disposed'))), + ); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_platform_brightness_test.dart b/packages/flutter_hooks/test/use_platform_brightness_test.dart new file mode 100644 index 00000000..b2ff32e1 --- /dev/null +++ b/packages/flutter_hooks/test/use_platform_brightness_test.dart @@ -0,0 +1,74 @@ +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + group('usePlatformBrightness', () { + testWidgets('returns initial value and rebuild widgets on change', + (tester) async { + final binding = tester.binding; + binding.platformDispatcher.platformBrightnessTestValue = Brightness.light; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + final brightness = usePlatformBrightness(); + return Text('$brightness', textDirection: TextDirection.ltr); + }, + ), + ); + + expect(find.text('Brightness.light'), findsOneWidget); + + binding.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + await tester.pump(); + + expect(find.text('Brightness.dark'), findsOneWidget); + }); + }); + + group('useOnPlatformBrightnessChange', () { + testWidgets( + 'sends previous and new value on change, without rebuilding widgets', + (tester) async { + final binding = tester.binding; + binding.platformDispatcher.platformBrightnessTestValue = Brightness.light; + var buildCount = 0; + final listener = PlatformBrightnessListener(); + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + buildCount++; + useOnPlatformBrightnessChange(listener); + return Container(); + }, + ), + ); + + expect(buildCount, 1); + verifyZeroInteractions(listener); + + binding.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + await tester.pump(); + + expect(buildCount, 1); + verify(listener(Brightness.light, Brightness.dark)); + verifyNoMoreInteractions(listener); + + binding.platformDispatcher.platformBrightnessTestValue = Brightness.light; + await tester.pump(); + + expect(buildCount, 1); + verify(listener(Brightness.dark, Brightness.light)); + verifyNoMoreInteractions(listener); + }); + }); +} + +class PlatformBrightnessListener extends Mock { + void call(Brightness previous, Brightness current); +} diff --git a/packages/flutter_hooks/test/use_previous_test.dart b/packages/flutter_hooks/test/use_previous_test.dart new file mode 100644 index 00000000..de653de1 --- /dev/null +++ b/packages/flutter_hooks/test/use_previous_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget build(int value) => HookBuilder( + builder: (context) => + Text(usePrevious(value).toString(), textDirection: TextDirection.ltr), + ); +void main() { + group('usePrevious', () { + testWidgets('default value is null', (tester) async { + await tester.pumpWidget(build(0)); + + expect(find.text('null'), findsOneWidget); + }); + testWidgets('subsequent build returns previous value', (tester) async { + await tester.pumpWidget(build(0)); + await tester.pumpWidget(build(1)); + + expect(find.text('0'), findsOneWidget); + + await tester.pumpWidget(build(1)); + + expect(find.text('1'), findsOneWidget); + + await tester.pumpWidget(build(2)); + expect(find.text('1'), findsOneWidget); + + await tester.pumpWidget(build(3)); + expect(find.text('2'), findsOneWidget); + }); + }); + + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + usePrevious(42); + return const SizedBox(); + }), + ); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + usePrevious(21); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ usePrevious: 42\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); +} diff --git a/packages/flutter_hooks/test/use_reassemble_test.dart b/packages/flutter_hooks/test/use_reassemble_test.dart new file mode 100644 index 00000000..3cc7adce --- /dev/null +++ b/packages/flutter_hooks/test/use_reassemble_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets("hot-reload calls useReassemble's callback", (tester) async { + final reassemble = MockReassemble(); + + await tester.pumpWidget(HookBuilder(builder: (context) { + useReassemble(reassemble); + return Container(); + })); + + verifyNoMoreInteractions(reassemble); + + hotReload(tester); + await tester.pump(); + + verify(reassemble()).called(1); + verifyNoMoreInteractions(reassemble); + }); + + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useReassemble(() {}); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useReassemble\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); +} diff --git a/packages/flutter_hooks/test/use_reducer_test.dart b/packages/flutter_hooks/test/use_reducer_test.dart new file mode 100644 index 00000000..b620e4b8 --- /dev/null +++ b/packages/flutter_hooks/test/use_reducer_test.dart @@ -0,0 +1,203 @@ +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) { + useReducer( + (state, action) => 42, + initialAction: null, + initialState: null, + ); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useReducer: 42\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useReducer', () { + testWidgets('supports null initial state', (tester) async { + Store? store; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + store = useReducer( + (state, action) => state, + initialAction: null, + initialState: null, + ); + + return Container(); + }, + ), + ); + + expect(store!.state, isNull); + }); + + testWidgets('supports null state after dispatch', (tester) async { + Store? store; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + store = useReducer( + (state, action) => action, + initialAction: 0, + initialState: null, + ); + + return Container(); + }, + ), + ); + + expect(store?.state, 0); + + store!.dispatch(null); + + expect(store!.state, null); + }); + + testWidgets('initialize the state even "state" is never read', + (tester) async { + final reducer = MockReducer(); + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + useReducer( + reducer, + initialAction: '', + initialState: 0, + ); + return Container(); + }, + ), + ); + + verify(reducer(null, null)).called(1); + verifyNoMoreInteractions(reducer); + }); + + testWidgets('basic', (tester) async { + final reducer = MockReducer(); + + Store? store; + + Future pump() { + return tester.pumpWidget( + HookBuilder( + builder: (context) { + store = useReducer( + reducer, + initialAction: null, + initialState: null, + ); + return Container(); + }, + ), + ); + } + + when(reducer(null, null)).thenReturn(0); + + await pump(); + final element = tester.firstElement(find.byType(HookBuilder)); + + verify(reducer(null, null)).called(1); + verifyNoMoreInteractions(reducer); + + expect(store!.state, 0); + + await pump(); + + verifyNoMoreInteractions(reducer); + expect(store!.state, 0); + + when(reducer(0, 'foo')).thenReturn(1); + + store!.dispatch('foo'); + + verify(reducer(0, 'foo')).called(1); + verifyNoMoreInteractions(reducer); + expect(element.dirty, true); + + await pump(); + + when(reducer(1, 'bar')).thenReturn(1); + + store!.dispatch('bar'); + + verify(reducer(1, 'bar')).called(1); + verifyNoMoreInteractions(reducer); + expect(element.dirty, false); + }); + + testWidgets('dispatch during build works', (tester) async { + Store? store; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + store = useReducer( + (state, action) => action, + initialAction: 0, + initialState: null, + )..dispatch(42); + return Container(); + }, + ), + ); + + expect(store!.state, 42); + }); + + testWidgets('first reducer call receive initialAction and initialState', + (tester) async { + final reducer = MockReducer(); + when(reducer(0, 'Foo')).thenReturn(42); + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + final result = useReducer( + reducer, + initialAction: 'Foo', + initialState: 0, + ).state; + return Text('$result', textDirection: TextDirection.ltr); + }, + ), + ); + + expect(find.text('42'), findsOneWidget); + }); + }); +} + +class MockReducer extends Mock { + int? call(int? state, String? action) { + return super.noSuchMethod( + Invocation.getter(#call), + returnValue: 0, + ) as int?; + } +} diff --git a/packages/flutter_hooks/test/use_scroll_controller_test.dart b/packages/flutter_hooks/test/use_scroll_controller_test.dart new file mode 100644 index 00000000..fee5fd52 --- /dev/null +++ b/packages/flutter_hooks/test/use_scroll_controller_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useScrollController(); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useScrollController: ScrollController#00000(no clients)\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useScrollController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late ScrollController controller; + late ScrollController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = ScrollController(); + controller = useScrollController(); + return Container(); + }), + ); + + expect(controller.debugLabel, controller2.debugLabel); + expect(controller.initialScrollOffset, controller2.initialScrollOffset); + expect(controller.keepScrollOffset, controller2.keepScrollOffset); + expect(controller.onAttach, controller2.onAttach); + expect(controller.onDetach, controller2.onDetach); + }); + testWidgets("returns a ScrollController that doesn't change", + (tester) async { + late ScrollController controller; + late ScrollController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useScrollController(); + return Container(); + }), + ); + + expect(controller, isA()); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = useScrollController(); + return Container(); + }), + ); + + expect(identical(controller, controller2), isTrue); + }); + + testWidgets('passes hook parameters to the ScrollController', + (tester) async { + late ScrollController controller; + + void onAttach(ScrollPosition position) {} + void onDetach(ScrollPosition position) {} + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = useScrollController( + initialScrollOffset: 42, + debugLabel: 'Hello', + keepScrollOffset: false, + onAttach: onAttach, + onDetach: onDetach, + ); + + return Container(); + }, + ), + ); + + expect(controller.initialScrollOffset, 42); + expect(controller.debugLabel, 'Hello'); + expect(controller.keepScrollOffset, false); + expect(controller.onAttach, onAttach); + expect(controller.onDetach, onDetach); + }); + }); +} + +class TickerProviderMock extends Mock implements TickerProvider {} diff --git a/packages/flutter_hooks/test/use_search_controller_test.dart b/packages/flutter_hooks/test/use_search_controller_test.dart new file mode 100644 index 00000000..10061ae1 --- /dev/null +++ b/packages/flutter_hooks/test/use_search_controller_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useSearchController(); + + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useSearchController:\n' + ' │ SearchController#00000(TextEditingValue(text: ┤├, selection:\n' + ' │ TextSelection.invalid, composing: TextRange(start: -1, end:\n' + ' │ -1)))\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useSearchController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late SearchController controller; + final controller2 = SearchController(); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useSearchController(); + + return Container(); + }), + ); + + expect(controller, isA()); + + expect(controller.selection, controller2.selection); + expect(controller.text, controller2.text); + expect(controller.value, controller2.value); + }); + + testWidgets('check opening/closing view', (tester) async { + late SearchController controller; + + await tester.pumpWidget(MaterialApp( + home: HookBuilder(builder: (context) { + controller = useSearchController(); + + return SearchAnchor.bar( + searchController: controller, + suggestionsBuilder: (context, controller) => [], + ); + }), + )); + + controller.openView(); + + expect(controller.isOpen, true); + + await tester.pumpWidget(MaterialApp( + home: HookBuilder(builder: (context) { + controller = useSearchController(); + + return SearchAnchor.bar( + searchController: controller, + suggestionsBuilder: (context, controller) => [], + ); + }), + )); + + controller.closeView('selected'); + + expect(controller.isOpen, false); + expect(controller.text, 'selected'); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_snapshot_controller_test.dart b/packages/flutter_hooks/test/use_snapshot_controller_test.dart new file mode 100644 index 00000000..842691d4 --- /dev/null +++ b/packages/flutter_hooks/test/use_snapshot_controller_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useSnapshotController(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + " │ useSnapshotController: Instance of 'SnapshotController'\n" + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useSnapshotController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late SnapshotController controller; + late SnapshotController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = SnapshotController(); + controller = useSnapshotController(); + return Container(); + }), + ); + + expect(controller.allowSnapshotting, controller2.allowSnapshotting); + }); + + testWidgets('passes hook parameters to the SnapshotController', + (tester) async { + late SnapshotController controller; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useSnapshotController(allowSnapshotting: true); + return const SizedBox(); + }), + ); + + expect(controller.allowSnapshotting, true); + + late SnapshotController retrievedController; + await tester.pumpWidget( + HookBuilder(builder: (context) { + // ignore: avoid_redundant_argument_values + retrievedController = useSnapshotController(allowSnapshotting: false); + return const SizedBox(); + }), + ); + + expect(retrievedController, same(controller)); + expect(retrievedController.allowSnapshotting, false); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_state_test.dart b/packages/flutter_hooks/test/use_state_test.dart new file mode 100644 index 00000000..7817ec93 --- /dev/null +++ b/packages/flutter_hooks/test/use_state_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('useState basic', (tester) async { + late ValueNotifier state; + late HookElement element; + + await tester.pumpWidget(HookBuilder( + builder: (context) { + element = context as HookElement; + state = useState(42); + return Container(); + }, + )); + + expect(state.value, 42); + expect(element.dirty, false); + + await tester.pump(); + + expect(state.value, 42); + expect(element.dirty, false); + + state.value++; + expect(element.dirty, true); + await tester.pump(); + + expect(state.value, 43); + expect(element.dirty, false); + + // dispose + await tester.pumpWidget(const SizedBox()); + + expect(() => state.addListener(() {}), throwsFlutterError); + }); + + testWidgets('no initial data', (tester) async { + late ValueNotifier state; + late HookElement element; + + await tester.pumpWidget(HookBuilder( + builder: (context) { + element = context as HookElement; + state = useState(null); + return Container(); + }, + )); + + expect(state.value, null); + expect(element.dirty, false); + + await tester.pump(); + + expect(state.value, null); + expect(element.dirty, false); + + state.value = 43; + expect(element.dirty, true); + await tester.pump(); + + expect(state.value, 43); + expect(element.dirty, false); + + // dispose + await tester.pumpWidget(const SizedBox()); + + expect(() => state.addListener(() {}), throwsFlutterError); + }); + + testWidgets('debugFillProperties should print state hook ', (tester) async { + late ValueNotifier state; + late HookElement element; + final hookWidget = HookBuilder( + builder: (context) { + element = context as HookElement; + state = useState(0); + return const SizedBox(); + }, + ); + await tester.pumpWidget(hookWidget); + + expect( + element.toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder(useState: 0)\n' + '└SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + + state.value++; + + await tester.pump(); + + expect( + element.toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder(useState: 1)\n' + '└SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); +} diff --git a/packages/flutter_hooks/test/use_stream_controller_test.dart b/packages/flutter_hooks/test/use_stream_controller_test.dart new file mode 100644 index 00000000..ede731eb --- /dev/null +++ b/packages/flutter_hooks/test/use_stream_controller_test.dart @@ -0,0 +1,133 @@ +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) { + useStreamController(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useStreamController: Instance of\n' + // ignore: avoid_escaping_inner_quotes + ' │ \'_AsyncBroadcastStreamController\'\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useStreamController', () { + testWidgets('keys', (tester) async { + late StreamController controller; + + await tester.pumpWidget(HookBuilder(builder: (context) { + controller = useStreamController(); + return Container(); + })); + + final previous = controller; + await tester.pumpWidget(HookBuilder(builder: (context) { + controller = useStreamController(keys: []); + return Container(); + })); + + expect(previous, isNot(controller)); + }); + testWidgets('basics', (tester) async { + late StreamController controller; + + await tester.pumpWidget(HookBuilder(builder: (context) { + controller = useStreamController(); + return Container(); + })); + + expect( + controller, + isNot(isInstanceOf>()), + ); + expect(controller.onListen, isNull); + expect(controller.onCancel, isNull); + expect(() => controller.onPause, throwsUnsupportedError); + expect(() => controller.onResume, throwsUnsupportedError); + + final previousController = controller; + void onListen() {} + void onCancel() {} + await tester.pumpWidget(HookBuilder(builder: (context) { + controller = useStreamController( + sync: true, + onCancel: onCancel, + onListen: onListen, + ); + return Container(); + })); + + expect(controller, previousController); + expect( + controller, + isNot(isInstanceOf>()), + ); + expect(controller.onListen, onListen); + expect(controller.onCancel, onCancel); + expect(() => controller.onPause, throwsUnsupportedError); + expect(() => controller.onResume, throwsUnsupportedError); + + await tester.pumpWidget(Container()); + + expect(controller.isClosed, true); + }); + testWidgets('sync', (tester) async { + late StreamController controller; + + await tester.pumpWidget(HookBuilder(builder: (context) { + controller = useStreamController(sync: true); + return Container(); + })); + + expect(controller, isInstanceOf>()); + expect(controller.onListen, isNull); + expect(controller.onCancel, isNull); + expect(() => controller.onPause, throwsUnsupportedError); + expect(() => controller.onResume, throwsUnsupportedError); + + final previousController = controller; + void onListen() {} + void onCancel() {} + await tester.pumpWidget(HookBuilder(builder: (context) { + controller = useStreamController( + onCancel: onCancel, + onListen: onListen, + ); + return Container(); + })); + + expect(controller, previousController); + expect(controller, isInstanceOf>()); + expect(controller.onListen, onListen); + expect(controller.onCancel, onCancel); + expect(() => controller.onPause, throwsUnsupportedError); + expect(() => controller.onResume, throwsUnsupportedError); + + await tester.pumpWidget(Container()); + + expect(controller.isClosed, true); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_stream_test.dart b/packages/flutter_hooks/test/use_stream_test.dart new file mode 100644 index 00000000..497ae8c0 --- /dev/null +++ b/packages/flutter_hooks/test/use_stream_test.dart @@ -0,0 +1,199 @@ +// ignore_for_file: close_sinks + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +/// port of [StreamBuilder] +/// +void main() { + testWidgets('debugFillProperties', (tester) async { + final stream = Stream.value(42); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + useStream(stream, initialData: 42); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useStream: AsyncSnapshot(ConnectionState.done, 42, null,\n' + ' │ null)\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('default preserve state, changing stream keeps previous value', + (tester) async { + late AsyncSnapshot? value; + Widget Function(BuildContext) builder(Stream stream) { + return (context) { + value = useStream(stream); + return Container(); + }; + } + + var stream = Stream.fromFuture(Future.value(0)); + await tester.pumpWidget(HookBuilder(builder: builder(stream))); + expect(value!.data, null); + await tester.pumpWidget(HookBuilder(builder: builder(stream))); + expect(value!.data, 0); + + stream = Stream.fromFuture(Future.value(42)); + await tester.pumpWidget(HookBuilder(builder: builder(stream))); + expect(value!.data, 0); + await tester.pumpWidget(HookBuilder(builder: builder(stream))); + expect(value!.data, 42); + }); + testWidgets('If preserveState == false, changing stream resets value', + (tester) async { + late AsyncSnapshot? value; + Widget Function(BuildContext) builder(Stream stream) { + return (context) { + value = useStream(stream, preserveState: false); + return Container(); + }; + } + + var stream = Stream.fromFuture(Future.value(0)); + await tester.pumpWidget(HookBuilder(builder: builder(stream))); + expect(value!.data, null); + await tester.pumpWidget(HookBuilder(builder: builder(stream))); + expect(value!.data, 0); + + stream = Stream.fromFuture(Future.value(42)); + await tester.pumpWidget(HookBuilder(builder: builder(stream))); + expect(value!.data, null); + await tester.pumpWidget(HookBuilder(builder: builder(stream))); + expect(value!.data, 42); + }); + + Widget Function(BuildContext) snapshotText(Stream stream, + {String? initialData}) { + return (context) { + final snapshot = useStream(stream, initialData: initialData ?? ''); + return Text(snapshot.toString(), textDirection: TextDirection.ltr); + }; + } + + testWidgets('gracefully handles transition to other stream', (tester) async { + final controllerA = StreamController(); + final controllerB = StreamController(); + await tester + .pumpWidget(HookBuilder(builder: snapshotText(controllerA.stream))); + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, , null, null)', + ), + findsOneWidget); + await tester + .pumpWidget(HookBuilder(builder: snapshotText(controllerB.stream))); + controllerB.add('B'); + controllerA.add('A'); + await eventFiring(tester); + expect( + find.text( + 'AsyncSnapshot(ConnectionState.active, B, null, null)'), + findsOneWidget); + }); + testWidgets('tracks events and errors of stream until completion', + (tester) async { + final controller = StreamController(); + await tester + .pumpWidget(HookBuilder(builder: snapshotText(controller.stream))); + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, , null, null)', + ), + findsOneWidget); + controller + ..add('1') + ..add('2'); + await eventFiring(tester); + expect( + find.text( + 'AsyncSnapshot(ConnectionState.active, 2, null, null)', + ), + findsOneWidget); + controller + ..add('3') + ..addError('bad', StackTrace.fromString('stackTrace')); + + await eventFiring(tester); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.active, null, bad, stackTrace)', + ), + findsOneWidget, + ); + + controller.add('4'); + await controller.close(); + await eventFiring(tester); + + expect( + find.text('AsyncSnapshot(ConnectionState.done, 4, null, null)'), + findsOneWidget, + ); + }); + testWidgets('runs the builder using given initial data', (tester) async { + final controller = StreamController(); + await tester.pumpWidget( + HookBuilder( + builder: snapshotText(controller.stream, initialData: 'I'), + ), + ); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, I, null, null)'), + findsOneWidget, + ); + }); + testWidgets('ignores initialData when reconfiguring', (tester) async { + await tester.pumpWidget( + HookBuilder( + builder: snapshotText(const Stream.empty(), initialData: 'I'), + ), + ); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, I, null, null)'), + findsOneWidget, + ); + + final controller = StreamController(); + + await tester.pumpWidget(HookBuilder( + builder: snapshotText(controller.stream, initialData: 'Ignored'), + )); + + expect( + find.text( + 'AsyncSnapshot(ConnectionState.waiting, I, null, null)', + ), + findsOneWidget, + ); + }); +} + +Future eventFiring(WidgetTester tester) async { + await tester.pump(Duration.zero); +} diff --git a/packages/flutter_hooks/test/use_tab_controller_test.dart b/packages/flutter_hooks/test/use_tab_controller_test.dart new file mode 100644 index 00000000..b4dffce7 --- /dev/null +++ b/packages/flutter_hooks/test/use_tab_controller_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useTabController(initialLength: 4); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useSingleTickerProvider\n' + " │ useTabController: Instance of 'TabController'\n" + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useTabController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late TabController controller; + late TabController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + final vsync = useSingleTickerProvider(); + controller2 = TabController(length: 4, vsync: vsync); + controller = useTabController(initialLength: 4); + return Container(); + }), + ); + + expect(controller.index, controller2.index); + }); + testWidgets("returns a TabController that doesn't change", (tester) async { + late TabController controller; + late TabController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useTabController(initialLength: 1); + return Container(); + }), + ); + + expect(controller, isA()); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = useTabController(initialLength: 1); + return Container(); + }), + ); + + expect(identical(controller, controller2), isTrue); + }); + testWidgets('changing length is no-op', (tester) async { + late TabController controller; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useTabController(initialLength: 1); + return Container(); + }), + ); + + expect(controller.length, 1); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useTabController(initialLength: 2); + return Container(); + }), + ); + + expect(controller.length, 1); + }); + + testWidgets('passes hook parameters to the TabController', (tester) async { + late TabController controller; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = useTabController(initialIndex: 2, initialLength: 4); + + return Container(); + }, + ), + ); + + expect(controller.index, 2); + expect(controller.length, 4); + }); + testWidgets('allows passing custom vsync', (tester) async { + final vsync = TickerProviderMock(); + final ticker = Ticker((_) {}); + when(vsync.createTicker((_) {})).thenReturn(ticker); + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + useTabController(initialLength: 1, vsync: vsync); + + return Container(); + }, + ), + ); + + verify(vsync.createTicker((_) {})).called(1); + verifyNoMoreInteractions(vsync); + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + useTabController(initialLength: 1, vsync: vsync); + return Container(); + }, + ), + ); + + verifyNoMoreInteractions(vsync); + ticker.dispose(); + }); + + testWidgets('initial animationDuration matches with real constructor', + (tester) async { + late TabController controller; + late TabController controller2; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + final vsync = useSingleTickerProvider(); + controller = useTabController(initialLength: 4); + controller2 = TabController(length: 4, vsync: vsync); + return Container(); + }, + ), + ); + + expect(controller.animationDuration, controller2.animationDuration); + }); + }); +} + +class TickerProviderMock extends Mock implements TickerProvider { + @override + Ticker createTicker(TickerCallback onTick) => super.noSuchMethod( + Invocation.getter(#createTicker), + returnValue: Ticker(onTick), + ) as Ticker; +} diff --git a/packages/flutter_hooks/test/use_text_editing_controller_test.dart b/packages/flutter_hooks/test/use_text_editing_controller_test.dart new file mode 100644 index 00000000..acce0f5e --- /dev/null +++ b/packages/flutter_hooks/test/use_text_editing_controller_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useTextEditingController(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useTextEditingController:\n' + ' │ TextEditingController#00000(TextEditingValue(text: ┤├,\n' + ' │ selection: TextSelection.invalid, composing: TextRange(start:\n' + ' │ -1, end: -1)))\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('useTextEditingController returns a controller', (tester) async { + final rebuilder = ValueNotifier(0); + late TextEditingController controller; + + await tester.pumpWidget(HookBuilder( + builder: (context) { + controller = useTextEditingController(); + useValueListenable(rebuilder); + return Container(); + }, + )); + + expect(controller, isNotNull); + controller.addListener(() {}); + + // rebuild hook + final firstController = controller; + rebuilder.notifyListeners(); + await tester.pumpAndSettle(); + expect(identical(controller, firstController), isTrue, + reason: 'Controllers should be identical after rebuilds'); + + // pump another widget so that the old one gets disposed + await tester.pumpWidget(Container()); + + expect( + () => controller.addListener(() {}), + throwsA(isFlutterError.having( + (e) => e.message, 'message', contains('disposed'))), + ); + }); + + testWidgets('respects initial text property', (tester) async { + final rebuilder = ValueNotifier(0); + late TextEditingController controller; + const initialText = 'hello hooks'; + var targetText = initialText; + + await tester.pumpWidget(HookBuilder( + builder: (context) { + controller = useTextEditingController(text: targetText); + useValueListenable(rebuilder); + return Container(); + }, + )); + + expect(controller.text, targetText); + + // change text and rebuild - the value of the controller shouldn't change + targetText = "can't see me!"; + rebuilder.notifyListeners(); + await tester.pumpAndSettle(); + expect(controller.text, initialText); + }); + + testWidgets('respects initial value property', (tester) async { + final rebuilder = ValueNotifier(0); + const initialValue = TextEditingValue( + text: 'foo', + selection: TextSelection.collapsed(offset: 2), + ); + var targetValue = initialValue; + late TextEditingController controller; + + await tester.pumpWidget(HookBuilder( + builder: (context) { + controller = useTextEditingController.fromValue(targetValue); + useValueListenable(rebuilder); + return Container(); + }, + )); + + expect(controller.value, targetValue); + + // similar to above - the value should not change after a rebuild + targetValue = const TextEditingValue(text: 'another'); + rebuilder.notifyListeners(); + await tester.pumpAndSettle(); + expect(controller.value, initialValue); + }); +} diff --git a/packages/flutter_hooks/test/use_ticker_provider_test.dart b/packages/flutter_hooks/test/use_ticker_provider_test.dart new file mode 100644 index 00000000..801a8b7b --- /dev/null +++ b/packages/flutter_hooks/test/use_ticker_provider_test.dart @@ -0,0 +1,114 @@ +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) { + useSingleTickerProvider(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useSingleTickerProvider\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('useSingleTickerProvider basic', (tester) async { + late TickerProvider provider; + + await tester.pumpWidget(TickerMode( + enabled: true, + child: HookBuilder(builder: (context) { + provider = useSingleTickerProvider(); + return Container(); + }), + )); + + final animationController = AnimationController( + vsync: provider, + duration: const Duration(seconds: 1), + ); + unawaited(animationController.forward()); + + expect(() => AnimationController(vsync: provider), throwsFlutterError); + + animationController.dispose(); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('useSingleTickerProvider unused', (tester) async { + await tester.pumpWidget(HookBuilder(builder: (context) { + useSingleTickerProvider(); + return Container(); + })); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('useSingleTickerProvider still active', (tester) async { + late TickerProvider provider; + + await tester.pumpWidget(TickerMode( + enabled: true, + child: HookBuilder(builder: (context) { + provider = useSingleTickerProvider(); + 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('useSingleTickerProvider pass down keys', (tester) async { + late TickerProvider provider; + List? keys; + + await tester.pumpWidget(HookBuilder(builder: (context) { + provider = useSingleTickerProvider(keys: keys); + return Container(); + })); + + final previousProvider = provider; + keys = []; + + await tester.pumpWidget(HookBuilder(builder: (context) { + provider = useSingleTickerProvider(keys: keys); + return Container(); + })); + + expect(previousProvider, isNot(provider)); + }); +} diff --git a/packages/flutter_hooks/test/use_transformation_controller_test.dart b/packages/flutter_hooks/test/use_transformation_controller_test.dart new file mode 100644 index 00000000..c67a0368 --- /dev/null +++ b/packages/flutter_hooks/test/use_transformation_controller_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useTransformationController(); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + anyOf( + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useTransformationController:\n' + ' │ TransformationController#00000([0] 1.0,0.0,0.0,0.0\n' + ' │ [1] 0.0,1.0,0.0,0.0\n' + ' │ [2] 0.0,0.0,1.0,0.0\n' + ' │ [3] 0.0,0.0,0.0,1.0\n' + ' │ )\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useTransformationController:\n' + ' │ TransformationController#00000([0] [1.0,0.0,0.0,0.0]\n' + ' │ [1] [0.0,1.0,0.0,0.0]\n' + ' │ [2] [0.0,0.0,1.0,0.0]\n' + ' │ [3] [0.0,0.0,0.0,1.0]\n' + ' │ )\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ), + ); + }); + + group('useTransformationController', () { + testWidgets('initial values matches with real constructor', (tester) async { + late TransformationController controller; + late TransformationController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = TransformationController(); + controller = useTransformationController(); + return Container(); + }), + ); + + expect(controller.value, controller2.value); + }); + testWidgets("returns a TransformationController that doesn't change", + (tester) async { + late TransformationController controller; + late TransformationController controller2; + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller = useTransformationController(); + return Container(); + }), + ); + + expect(controller, isA()); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + controller2 = useTransformationController(); + return Container(); + }), + ); + + expect(identical(controller, controller2), isTrue); + }); + + testWidgets('passes hook parameters to the TransformationController', + (tester) async { + late TransformationController controller; + + await tester.pumpWidget( + HookBuilder( + builder: (context) { + controller = useTransformationController( + initialValue: Matrix4.translationValues(1, 2, 3), + ); + + return Container(); + }, + ), + ); + + expect(controller.value, Matrix4.translationValues(1, 2, 3)); + }); + }); +} + +class TickerProviderMock extends Mock implements TickerProvider {} diff --git a/packages/flutter_hooks/test/use_tree_sliver_controller_test.dart b/packages/flutter_hooks/test/use_tree_sliver_controller_test.dart new file mode 100644 index 00000000..48646b46 --- /dev/null +++ b/packages/flutter_hooks/test/use_tree_sliver_controller_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/src/framework.dart'; +import 'package:flutter_hooks/src/hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('debugFillProperties', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useTreeSliverController(); + return const SizedBox(); + }), + ); + + await tester.pump(); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + " │ useTreeSliverController: Instance of 'TreeSliverController'\n" + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useTreeSliverController', () { + testWidgets('check expansion/collapse of node', (tester) async { + late TreeSliverController controller; + final tree = >[ + TreeSliverNode(0, children: [TreeSliverNode(1), TreeSliverNode(2)]), + TreeSliverNode( + expanded: true, 3, children: [TreeSliverNode(4), TreeSliverNode(5)]) + ]; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HookBuilder(builder: (context) { + controller = useTreeSliverController(); + return CustomScrollView(slivers: [ + TreeSliver( + controller: controller, + tree: tree, + ), + ]); + }), + ), + )); + + expect(controller.isExpanded(tree[0]), false); + controller.expandNode(tree[0]); + expect(controller.isExpanded(tree[0]), true); + controller.collapseNode(tree[0]); + expect(controller.isExpanded(tree[0]), false); + + expect(controller.isExpanded(tree[1]), true); + controller.collapseNode(tree[1]); + expect(controller.isExpanded(tree[1]), false); + controller.expandNode(tree[1]); + expect(controller.isExpanded(tree[1]), true); + }); + }); +} diff --git a/packages/flutter_hooks/test/use_value_changed_test.dart b/packages/flutter_hooks/test/use_value_changed_test.dart new file mode 100644 index 00000000..8ffca397 --- /dev/null +++ b/packages/flutter_hooks/test/use_value_changed_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('diagnostics', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useValueChanged(0, (_, __) => 21); + return const SizedBox(); + }), + ); + + await tester.pumpWidget( + HookBuilder(builder: (context) { + useValueChanged(42, (_, __) => 21); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useValueChanged: _ValueChangedHookState#00000(21,\n' + ' │ value: 42, result: 21)\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + testWidgets('useValueChanged basic', (tester) async { + var value = 42; + final _useValueChanged = MockValueChanged(); + late String? result; + + Future pump() { + return tester.pumpWidget( + HookBuilder(builder: (context) { + result = useValueChanged(value, _useValueChanged); + return Container(); + }), + ); + } + + await pump(); + + final context = find.byType(HookBuilder).evaluate().first; + + expect(result, null); + verifyNoMoreInteractions(_useValueChanged); + expect(context.dirty, false); + + await pump(); + + expect(result, null); + verifyNoMoreInteractions(_useValueChanged); + expect(context.dirty, false); + + value++; + when(_useValueChanged(any, any)).thenReturn('Hello'); + await pump(); + + verify(_useValueChanged(42, null)); + expect(result, 'Hello'); + verifyNoMoreInteractions(_useValueChanged); + expect(context.dirty, false); + + await pump(); + + expect(result, 'Hello'); + verifyNoMoreInteractions(_useValueChanged); + expect(context.dirty, false); + + value++; + when(_useValueChanged(any, any)).thenReturn('Foo'); + await pump(); + + expect(result, 'Foo'); + verify(_useValueChanged(43, 'Hello')); + verifyNoMoreInteractions(_useValueChanged); + expect(context.dirty, false); + + await pump(); + + expect(result, 'Foo'); + verifyNoMoreInteractions(_useValueChanged); + expect(context.dirty, false); + + // dispose + await tester.pumpWidget(const SizedBox()); + }); +} + +class MockValueChanged extends Mock { + String? call(int? value, String? previous); +} diff --git a/packages/flutter_hooks/test/use_value_listenable_test.dart b/packages/flutter_hooks/test/use_value_listenable_test.dart new file mode 100644 index 00000000..b388f901 --- /dev/null +++ b/packages/flutter_hooks/test/use_value_listenable_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('diagnostics', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useValueListenable(ValueNotifier(0)); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useValueListenable: 0\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + testWidgets('useValueListenable', (tester) async { + var listenable = ValueNotifier(0); + late int result; + + Future pump() { + return tester.pumpWidget(HookBuilder( + builder: (context) { + result = useValueListenable(listenable); + return Container(); + }, + )); + } + + await pump(); + + final element = tester.firstElement(find.byType(HookBuilder)); + + expect(result, 0); + // ignore: invalid_use_of_protected_member + expect(listenable.hasListeners, true); + expect(element.dirty, false); + listenable.value++; + expect(element.dirty, true); + await tester.pump(); + expect(result, 1); + expect(element.dirty, false); + + final previousListenable = listenable; + listenable = ValueNotifier(0); + + await pump(); + + expect(result, 0); + // ignore: invalid_use_of_protected_member + expect(previousListenable.hasListeners, false); + // ignore: invalid_use_of_protected_member + expect(listenable.hasListeners, true); + expect(element.dirty, false); + listenable.value++; + expect(element.dirty, true); + await tester.pump(); + expect(result, 1); + expect(element.dirty, false); + + await tester.pumpWidget(const SizedBox()); + + // ignore: invalid_use_of_protected_member + expect(listenable.hasListeners, false); + + listenable.dispose(); + previousListenable.dispose(); + }); +} diff --git a/packages/flutter_hooks/test/use_value_notifier_test.dart b/packages/flutter_hooks/test/use_value_notifier_test.dart new file mode 100644 index 00000000..5e8ba7d9 --- /dev/null +++ b/packages/flutter_hooks/test/use_value_notifier_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'mock.dart'; + +void main() { + testWidgets('diagnostics', (tester) async { + await tester.pumpWidget( + HookBuilder(builder: (context) { + useValueNotifier(0); + return const SizedBox(); + }), + ); + + final element = tester.element(find.byType(HookBuilder)); + + expect( + element + .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage) + .toStringDeep(), + equalsIgnoringHashCodes( + 'HookBuilder\n' + ' │ useValueNotifier: ValueNotifier#00000(0)\n' + ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n', + ), + ); + }); + + group('useValueNotifier', () { + testWidgets('useValueNotifier basic', (tester) async { + late ValueNotifier state; + late HookElement element; + final listener = MockListener(); + + await tester.pumpWidget(HookBuilder( + builder: (context) { + element = context as HookElement; + state = useValueNotifier(42); + return Container(); + }, + )); + + state.addListener(listener); + + expect(state.value, 42); + expect(element.dirty, false); + verifyNoMoreInteractions(listener); + + await tester.pump(); + + verifyNoMoreInteractions(listener); + expect(state.value, 42); + expect(element.dirty, false); + + state.value++; + verify(listener()).called(1); + verifyNoMoreInteractions(listener); + expect(element.dirty, false); + await tester.pump(); + + expect(state.value, 43); + expect(element.dirty, false); + verifyNoMoreInteractions(listener); + + // dispose + await tester.pumpWidget(const SizedBox()); + + expect(() => state.addListener(() {}), throwsFlutterError); + }); + + testWidgets('no initial data', (tester) async { + late ValueNotifier state; + late HookElement element; + final listener = MockListener(); + + await tester.pumpWidget(HookBuilder( + builder: (context) { + element = context as HookElement; + state = useValueNotifier(null); + return Container(); + }, + )); + + state.addListener(listener); + + expect(state.value, null); + expect(element.dirty, false); + verifyNoMoreInteractions(listener); + + await tester.pump(); + + expect(state.value, null); + expect(element.dirty, false); + verifyNoMoreInteractions(listener); + + state.value = 43; + expect(element.dirty, false); + verify(listener()).called(1); + verifyNoMoreInteractions(listener); + await tester.pump(); + + expect(state.value, 43); + expect(element.dirty, false); + verifyNoMoreInteractions(listener); + + // dispose + await tester.pumpWidget(const SizedBox()); + + expect(() => state.addListener(() {}), throwsFlutterError); + }); + + testWidgets('creates new valuenotifier when key change', (tester) async { + late ValueNotifier state; + late ValueNotifier previous; + + await tester.pumpWidget(HookBuilder( + builder: (context) { + state = useValueNotifier(42); + return Container(); + }, + )); + + await tester.pumpWidget(HookBuilder( + builder: (context) { + previous = state; + state = useValueNotifier(42, [42]); + return Container(); + }, + )); + + expect(state, isNot(previous)); + }); + testWidgets("instance stays the same when keys don't change", + (tester) async { + late ValueNotifier state; + late ValueNotifier previous; + + await tester.pumpWidget(HookBuilder( + builder: (context) { + state = useValueNotifier(0, [42]); + return Container(); + }, + )); + + await tester.pumpWidget(HookBuilder( + builder: (context) { + previous = state; + state = useValueNotifier(42, [42]); + return Container(); + }, + )); + + expect(state, previous); + }); + }); +} + +class MockListener extends Mock { + void call(); +} diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index 224c7bb4..00000000 --- a/pubspec.lock +++ /dev/null @@ -1,132 +0,0 @@ -# Generated by pub -# See https://www.dartlang.org/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.8" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.4" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.2" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.14.11" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.3+1" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.6" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.2" - quiver: - dependency: transitive - description: - name: quiver - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.4.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.9.3" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.8" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.4" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.1" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.6" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.8" -sdks: - dart: ">=2.0.0 <3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml deleted file mode 100644 index eb223bf1..00000000 --- a/pubspec.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: flutter_hooks -description: A new Flutter project. - -version: 0.0.0+1 - -environment: - sdk: ">=2.0.0-dev.68.0 <3.0.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter diff --git a/test/hook_test.dart b/test/hook_test.dart deleted file mode 100644 index 7793154c..00000000 --- a/test/hook_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('test', () { - // noop - }); -}