🪝

【Flutter】flutter_hooks のメソッドをまとめる①

2024/12/10に公開

初めに

今回は flutter_hooks に用意されているメソッドをいくつか抽出して、その用途をまとめてみたいと思います。普段使用しないメソッドがかなりあるので、それも含めてみていければと思います。

記事の対象者

  • Flutter 学習者
  • flutter_hooks について知りたい方

目的

今回の目的は先述の通り、flutter_hooks に用意されているメソッドのうち、いくつかの用途、使い方をまとめることです。
基本的には一つずつ使用例を提示していく形で、内部の実装や複雑な実装については扱わないので、ご了承ください。

なお、今回実装したコードは以下のリポジトリにあるので、適宜参考にしていただければと思います。

https://github.com/Koichi5/sample-flutter/tree/feature/hooks

1. useTextEditingController

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useTextEditingController-constant.html

Creates a TextEditingController, either via an initial text or an initial TextEditingValue.

(日本語訳)
初期テキストまたは初期TextEditingValueを使用して、TextEditingControllerを作成する

使用用途

  1. TextField や TextFormField の入力管理
  2. テキストの初期値設定
  3. リアルタイム入力の監視
  4. 入力内容のバリデーション
  5. フォームのリセット

使用方法

以下のようなコードを実行すると、useTextEditingController でテキストフィールドの値の保持ができていることがわかります。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

class UseTextEditingControllerSample extends HookWidget {
  const UseTextEditingControllerSample({super.key});

  
  Widget build(BuildContext context) {
    final textController = useTextEditingController();
    final displayedText = useState<String>('');

    void onDisplayPressed() {
      displayedText.value = textController.text;
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('テキスト表示サンプル'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: textController,
              decoration: const InputDecoration(
                labelText: 'テキストを入力',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: onDisplayPressed,
              child: const Text('表示'),
            ),
            const SizedBox(height: 20),
            Text(
              displayedText.value.isEmpty
                  ? 'ここに入力内容が表示されます'
                  : displayedText.value,
              style: const TextStyle(fontSize: 18),
            ),
          ],
        ),
      ),
    );
  }
}

実行結果

https://dartpad.dev/?null_safety=true&id=44f715e44f78b24f49c32773e10668ad

2. useAnimation, useAnimationController

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAnimation.html

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAnimationController.html

https://api.flutter.dev/flutter/animation/AnimationController-class.html

useAnimation ではアニメーションを監視して、その値を返します。
useAnimationController と合わせて使用されることが多いかと思います。
useAnimationController では AnimationController が生成され、必要に応じて破棄されるようになっています。

AnimationController に関しては公式ドキュメントに以下のような記載があります。

A controller for an animation.
This class lets you perform tasks such as:

  • Play an animation forward or in reverse, or stop an animation.
  • Set the animation to a specific value.
  • Define the upperBound and lowerBound values of an animation.
  • Create a fling animation effect using a physics simulation.

By default, an AnimationController linearly produces values that range from 0.0 to 1.0, during a given duration. The animation controller generates a new value whenever the device running your app is ready to display a new frame (typically, this rate is around 60 values per second).

(日本語訳)
アニメーション用のコントローラー。
このクラスを使用すると、次のようなタスクを実行できます:

  • アニメーションを前方向または逆方向に再生したり、停止したりする。
  • アニメーションを特定の値に設定する。
  • アニメーションの上限値(upperBound)と下限値(lowerBound)を定義する。
  • 物理シミュレーションを使用してフリングアニメーション効果を作成する。

デフォルトでは、AnimationControllerは指定された期間中に0.0から1.0までの値を線形に生成します。アプリを実行しているデバイスが新しいフレームを表示する準備ができるたびに、アニメーションコントローラーは新しい値を生成します(通常、このレートは1秒あたり約60回です)。

使用用途

  1. ウィジェットのプロパティのアニメーション
    Container のサイズや色、Opacity の変更など、ウィジェットのプロパティを滑らかにアニメーションさせる際に使用する
  2. ページ遷移や画面切り替えのアニメーション
    新しい画面への遷移時などにアニメーションを追加できる
  3. ボタンの押下エフェクト
    ボタンを押した際の波紋エフェクトや、押下時のサイズ変更などの視覚的なフィードバックを追加できる
  4. その他カスタムアニメーションの作成
    上記以外にも独自のアニメーションを追加できる

使用方法

以下のコードを実行すると、ボタンのタップに応じて表示されているボックスの大きさがアニメーション付きで変化することがわかります。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class UseAnimationSample extends HookWidget {
  const UseAnimationSample({super.key});

  
  Widget build(BuildContext context) {
    final controller = useAnimationController(
      duration: const Duration(milliseconds: 500),
    );

    final animation = useAnimation(
      Tween<double>(begin: 100.0, end: 200.0).animate(controller),
    );

    final isExpanded = useState<bool>(false);

    // ボタンが押されたときの処理
    void toggleBoxSize() {
      if (isExpanded.value) {
        controller.reverse();
      } else {
        controller.forward();
      }
      isExpanded.value = !isExpanded.value;
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('サイズアニメーションサンプル'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            // アニメーションするボックス
            Container(
              width: animation,
              height: animation,
              color: Colors.blue,
            ),
            const SizedBox(height: 40),
            // サイズを切り替えるボタン
            ElevatedButton(
              onPressed: toggleBoxSize,
              child: Text(isExpanded.value ? '縮小' : '拡大'),
            ),
          ],
        ),
      ),
    );
  }
}

実行結果

https://dartpad.dev/?null_safety=true&id=369461fdaa688b762152604dea028606

コードを少し見てみます。

以下では useAnimationController メソッドで AnimationController を作成しています。 今回はアニメーションにかける時間である duration のみを設定していますが、他にもアニメーションの初期状態やアニメーションの詳細な挙動などを設定できます。

final controller = useAnimationController(
  duration: const Duration(milliseconds: 500),
);

以下では useAnimation を用いてアニメーションの監視を行なっています。
Tween ではアニメーションが始まる前の値(begin)と終わった後の値(end)を決めることができます。
animate メソッドに先ほど作成した AnimationController を渡してアニメーションに関する設定を反映させています。

final animation = useAnimation(
  Tween<double>(begin: 100.0, end: 200.0).animate(controller),
);

以下ではボタンが押された際の挙動を実装しています。
アニメーションは forward, reverse で実行することができます。
先ほど定義した animationTweenbegin は 100、 end は 200 だったので、 forward では 100 → 200、 reverse では 200 → 100 というふうにアニメーションが起こります。

void toggleBoxSize() {
  if (isExpanded.value) {
    controller.reverse();
  } else {
    controller.forward();
  }
  isExpanded.value = !isExpanded.value;
}

以下では作成した animationContainer の大きさに反映させています。
これで初期状態の Container の大きさは 100 × 100 であり、アニメーションの終了後には 200 × 200 になります。

// アニメーションするボックス
Container(
  width: animation,
  height: animation,
  color: Colors.blue,
),

3. useAppLifecycleState

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAppLifecycleState.html

Returns the current AppLifecycleState value and rebuilds the widget when it changes.

(日本語訳)
現在のAppLifecycleStateの値を返し、その値が変化した際にウィジェットを再構築します。

使用用途

  1. データの同期
    アプリがフォアグラウンドに戻った際に、最新のデータをサーバーから取得する
  2. 通知の管理
    アプリがフォアグラウンド・バックグラウンドに移行する際に、通知の設定や表示方法を変更する
  3. アプリの状態保存
    アプリがバックグラウンドに移行する際に、現在の状態を保存し、フォアグラウンドに戻った際に復元する
  4. 音声やビデオの制御
    アプリがバックグラウンドに移行した際に音声やビデオの再生を一時停止し、フォアグラウンドに戻った際に再生を再開する

使用方法

以下のようなコードで実行すると、アプリのライフサイクルによって値が変化していることがわかります。なお、DartPad では確認しにくいため、Simulator や実機で試してもらえると良いかと思います。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

class UseAppLifecycleStateSample extends HookWidget {
  const UseAppLifecycleStateSample({super.key});

  
  Widget build(BuildContext context) {
    // アプリのライフサイクル状態を取得
    final lifecycleState = useAppLifecycleState();

    // 現在の状態を表示するための文字列
    String statusText;

    switch (lifecycleState) {
      case AppLifecycleState.resumed:
        statusText = 'アプリはフォアグラウンドにあります';
        break;
      case AppLifecycleState.inactive:
        statusText = 'アプリは非アクティブ状態です';
        break;
      case AppLifecycleState.paused:
        statusText = 'アプリはバックグラウンドに移行しました';
        break;
      case AppLifecycleState.detached:
        statusText = 'アプリはデタッチ状態です';
        break;
      default:
        statusText = '不明な状態';
    }

    // ライフサイクル状態が変化したときに debugPrint を呼び出す
    useEffect(() {
      debugPrint('現在のライフサイクル状態: $lifecycleState');
      return null;
    }, [lifecycleState]);

    return Scaffold(
      appBar: AppBar(
        title: const Text('ライフサイクル監視サンプル'),
      ),
      body: Center(
        child: Text(
          statusText,
          style: const TextStyle(fontSize: 20),
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}

実行結果

https://dartpad.dev/?null_safety=true&id=00e3a5622d64a646e2cf7f2ac2c3986c

コードを少し見てみます。

以下で、現在のライフサイクルの取得ができます。
useAppLifecycleState の返り値は AppLifecycleState? であり、それぞれの状態に応じた処理の記述が可能です。

final lifecycleState = useAppLifecycleState();

以下では useEffect で現在のライフサイクルの状態を表示しています。
また、第二引数に lifecycleState を含めることで、ライフサイクルが変更された際に debugPrint で出力するようにしています。
このページ内でのみ音声や動画を流したい場合はライフサイクルがフォアグラウンドになった場合のみ再生し、それ以外の状態になった場合は停止するといった実装で実現が可能です。

useEffect(() {
  debugPrint('現在のライフサイクル状態: $lifecycleState');
  return null;
}, [lifecycleState]);

4. useAutomaticKeepAlive

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useAutomaticKeepAlive.html

Mark a widget using this hook as needing to stay alive even when it's in a lazy list that would otherwise remove it.

(日本語訳)
この hook を使用する Widget が、通常であれば削除される遅延リスト内にあっても、生存状態を維持する必要があることをマークします。

使用用途

  1. タブビューでの状態保持
    タブ間を切り替えても各タブのウィジェットの状態を保持できる
  2. ページビューでの状態保持
    ページ間をスワイプしても各ページのウィジェットの状態を保持できる
  3. リストビューのアイテム状態保持
    スクロールバック時にもリストアイテムの状態やスクロール位置を保持できる
  4. フォーム入力の状態保持
    異なる画面間を移動してもフォームの入力内容を保持できる

使用方法

以下のコードを実行すると、タブを切り替えてもそれぞれのタブのカウントが破棄されず、保持されることがわかります。 useAutomaticKeepAlive() の部分を除いてビルドすると、タブを切り替えるたびにカウントがリセットされていることがわかります。
画面遷移などで別のページに移っても元の状態を保持したい場合に使用できます。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

class UseAutomaticKeepAliveSample extends StatelessWidget {
  const UseAutomaticKeepAliveSample({super.key});

  
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('タブ状態保持サンプル'),
          bottom: const TabBar(
            tabs: [
              Tab(text: 'タブ 1'),
              Tab(text: 'タブ 2'),
              Tab(text: 'タブ 3'),
            ],
          ),
        ),
        body: const TabBarView(
          children: [
            _CounterTab(tabNumber: 1),
            _CounterTab(tabNumber: 2),
            _CounterTab(tabNumber: 3),
          ],
        ),
      ),
    );
  }
}

class _CounterTab extends HookWidget {
  const _CounterTab({required this.tabNumber});

  final int tabNumber;

  
  Widget build(BuildContext context) {
    // 状態を保持するためのカウンター
    final counter = useState<int>(0);

    // ウィジェットの状態を保持するために useAutomaticKeepAlive を使用
    useAutomaticKeepAlive();

    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('タブ $tabNumber のカウンター:', style: const TextStyle(fontSize: 20)),
          const SizedBox(height: 10),
          Text(
            '${counter.value}',
            style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
          ),
          const SizedBox(height: 20),
          ElevatedButton(
            onPressed: () {
              counter.value += 1;
            },
            child: const Text('カウントアップ'),
          ),
        ],
      ),
    );
  }
}

実行結果
https://dartpad.dev/?null_safety=true&id=71479fb4d33bc734ad92def93fe6a104

5. useCallback

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useCallback.html

Cache a function across rebuilds based on a list of keys.
This is syntax sugar for useMemoized,

(日本語訳)
キーのリストに基づいて関数を再構築間でキャッシュします。
これは useMemoized の糖衣構文です。

ドキュメントによると、以下の useMemoizeduseCallback は同値であるとされています。
useMemoized

final cachedFunction = useMemoized(() => () {
  print('doSomething');
}, [key]);

useCallback

final cachedFunction = useCallback(() {
  print('doSomething');
}, [key]);

使用用途

  1. 子ウィジェットにコールバックを渡す
    親ウィジェットから子ウィジェットにコールバック関数を渡す際、関数が毎回再生成されるのを防ぎ、子ウィジェットの不要な再ビルドを避けることができる
  2. フォームの入力処理
    フォームフィールドの変更や送信時の処理を行う際に、同一のコールバック関数を維持できる

使用方法

以下のコードのように、親ウィジェットから子ウィジェットにコールバックで関数を渡す際に使用できます。カウンターのアプリで、カウントの増加 / 減少の関数をコールバックで渡しています。
大量のリスト項目に対して個別のコールバックを適用する際や負荷がかかる関数がある際などは使用した方が良いかと思います。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

class UseCallbackSample extends HookWidget {
  const UseCallbackSample({super.key});

  
  Widget build(BuildContext context) {
    // カウンターの状態を管理
    final counter = useState<int>(0);

    // useCallback を使用してコールバック関数をメモ化
    final incrementCounter = useCallback(() {
      counter.value += 1;
    }, [counter]);

    final decrementCounter = useCallback(() {
      counter.value -= 1;
    }, [counter]);

    return Scaffold(
      appBar: AppBar(
        title: const Text('useCallback サンプル'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'カウンターの値:',
              style: TextStyle(fontSize: 20),
            ),
            Text(
              '${counter.value}',
              style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 40),
            // 子ウィジェットにコールバックを渡す
            _ChildWidget(
              incrementCounter: incrementCounter,
              decrementCounter: decrementCounter,
            ),
          ],
        ),
      ),
    );
  }
}

class _ChildWidget extends HookWidget {
  const _ChildWidget({
    required this.incrementCounter,
    required this.decrementCounter,
  });
  final VoidCallback incrementCounter;
  final VoidCallback decrementCounter;

  
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        IconButton(
          onPressed: incrementCounter,
          icon: const Icon(Icons.add, color: Colors.red),
        ),
        const SizedBox(width: 16),
        IconButton(
          onPressed: decrementCounter,
          icon: const Icon(Icons.remove, color: Colors.blue),
        ),
      ],
    );
  }
}

実行結果
https://dartpad.dev/?null_safety=true&id=b10d0ea019dae7df4f4e4a10f856de20

6. useDebounced

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useDebounced.html

Returns a debounced version of the provided value toDebounce, triggering widget updates accordingly after a specified timeout duration.

(日本語訳)
指定された値「toDebounce」のデバウンス版を返し、指定されたタイムアウト時間後にそれに応じてウィジェットを更新します。

Debounce の本来の意味は「跳ね返りを抑えること」であり、特にプログラミングの文脈では、「特定のイベントが短期間に連続して発生した際に、タイマー等による入力遅延を利用して一度だけ処理が実行されるようにする手法」とされています。

使用用途

  1. 検索入力のハンドリング
    ユーザーが検索バーに入力する際に、「一定時間入力がない場合にのみ検索を実行する」といった処理の実装が可能
  2. ボタンの連打防止
    ボタンが短時間に複数回押された際に、最初の1回のみアクションを実行し、後続の押下を無視することが可能
  3. フォームのリアルタイムバリデーション
    ユーザーがフォームに入力するたびにバリデーションを行うのではなく、入力が一定時間途切れた後にバリデーションを実行することが可能

使用方法

以下のようにユーザーの入力に対してすぐに検索をかけることなく、一定時間入力がなければ検索する(以下の場合は検索は実装していません)といった挙動が実現できます。これで API を叩く回数が減るなどのメリットが享受できます。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

class UseDebouncedSample extends HookWidget {
  const UseDebouncedSample({super.key});

  
  Widget build(BuildContext context) {
    // 検索するまでの時間
    const searchDuration = Duration(milliseconds: 800);
    final searchQuery = useState<String>('');
    final searchResult = useState<String>('検索結果がここに表示されます');

    // デバウンスされた検索クエリ
    final debouncedSearchQuery = useDebounced(
      searchQuery.value,
      searchDuration,
    );

    useEffect(() {
      // デバウンスされたクエリに基づいて検索を実行
      if (debouncedSearchQuery != null && debouncedSearchQuery.isNotEmpty) {
        searchResult.value = '$debouncedSearchQuery の検索結果';
      } else {
        searchResult.value = '検索結果がここに表示されます';
      }
      return null;
    }, [debouncedSearchQuery]);

    return Scaffold(
      appBar: AppBar(
        title: const Text('useDebounced サンプル'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              decoration: const InputDecoration(
                labelText: '検索',
                border: OutlineInputBorder(),
              ),
              onChanged: (value) {
                searchQuery.value = value;
              },
            ),
            const SizedBox(height: 20),
            Text(
              searchResult.value,
              style: const TextStyle(fontSize: 18),
            ),
          ],
        ),
      ),
    );
  }
}

実行結果
https://dartpad.dev/?null_safety=true&id=9e8af46cd673cb416d9c5d3a01f2866c

少しコードを見ていきます。

以下では、それぞれの変数を定義しています。

  • searchDuration : 何秒間入力が無ければアクションを起こすか(コードでは800ms)
  • searchQuery : 検索キーワードの状態を保持
  • searchResult : 検索結果のダミーの結果を保持
// 検索するまでの時間
const searchDuration = Duration(milliseconds: 800);
final searchQuery = useState<String>('');
final searchResult = useState<String>('検索結果がここに表示されます');

以下では useDebounced の第一引数に searchQuery.value、第二引数に searchDuration を割り当てています。これで、 searchQuery.value の値に変化があり、そのあと searchDuration の値だけ間が空いて debouncedSearchQuery の値が更新されます。
今回のケースだと、ユーザーの検索キーワードに変化があった 800ms 後に debouncedSearchQuery が更新されるようになっています。

final debouncedSearchQuery = useDebounced(
  searchQuery.value,
  searchDuration,
);

以下では useEffectdebouncedSearchQuery の変化に応じて searchResult の内容を切り替えています。

useEffect(() {
  // デバウンスされたクエリに基づいて検索を実行
  if (debouncedSearchQuery != null && debouncedSearchQuery.isNotEmpty) {
    searchResult.value = '$debouncedSearchQuery の検索結果';
  } else {
    searchResult.value = '検索結果がここに表示されます';
  }
  return null;
}, [debouncedSearchQuery]);

7. useEffect

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useEffect.html

useEffect に関しては、概要、使用用途や使用方法は以下の記事で詳しく紹介しているので、そちらをよろしければご覧ください。

https://zenn.dev/koichi_51/articles/d0faf92bd17d26

8. useExpansionTileController

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useExpansionTileController.html

Creates a ExpansionTileController that will be disposed automatically.

(日本語訳)
自動的に破棄される ExpansionTileController を作成する

使用用途

  1. 複数の ExpansionTile の状態管理
    複数の ExpansionTile の開閉状態の一元管理が可能
  2. プログラムからの ExpansionTile の開閉制御
    特定の条件下で自動的に ExpansionTile を開閉することが可能

使用方法

以下のようなコードを実行すると、 useExpansionTileControllerExpansionTile の開閉状態を管理することができます。ただ、使用シーンは限定的かなと思います。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

class UseExpansionTileControllerSample extends HookWidget {
  const UseExpansionTileControllerSample({super.key});

  
  Widget build(BuildContext context) {
    final expansionController = useExpansionTileController();

    return Scaffold(
      appBar: AppBar(
        title: const Text('useExpansionTileController サンプル'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            ExpansionTile(
              title: const Text('詳細情報'),
              initiallyExpanded: false,
              onExpansionChanged: (expanded) {
                if (expanded) {
                  expansionController.expand();
                } else {
                  expansionController.collapse();
                }
              },
              children: const [
                ListTile(
                  title: Text('ここに詳細情報が表示されます。'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

実行結果
https://dartpad.dev/?null_safety=true&id=30fdb0d8e44abb0787bb92d6ff45d580

以上です。

まとめ

最後まで読んでいただいてありがとうございました。

今回は flutter_hooks に用意されているメソッドについてみてきました。
今まで扱うことのなかったメソッドについても触れることができました。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/flutter_hooks-library.html

Discussion