🪝

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

2024/12/12に公開

初めに

今回は、前回に引き続き、 flutter_hooks に用意されているメソッドをいくつか抽出して、その用途をまとめて見たいと思います。

前回の記事

https://zenn.dev/koichi_51/articles/203551d34d0648

記事の対象者

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

16. useRef

概要

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

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.

(日本語訳)
単一の変更可能なプロパティを持つオブジェクトを作成します。
オブジェクトのプロパティを変更しても再ビルドには影響を与えません。これは、ビルド呼び出し間で状態を共有しつつ、不必要な再ビルドを引き起こさない場合に便利です。

使用用途

  1. ウィジェットのライフサイクルに依存しない値の保持
    再レンダー間で持続させたいカウンターやタイマーなどのミュータブルな値を保持
  2. フォーカスノードの保持
    テキストフィールドなどのフォーカス状態を管理する FocusNode を保持
  3. スクロールコントローラーの管理
    ScrollController を保持し、スクロール位置の管理やプログラム的なスクロール操作が可能

使用方法

以下のコードではタイマーの実装を行なっています。
useRef は再レンダー間で同じインスタンスを保持し続けるため、タイマーの動作状態を維持できます。この例ではわかりやすいように useState でカウントを表示していますが、カウンターを内部的に保持して表示する必要がない場合は useRef のみの実装で値を保持することができます。
内部的に値を変化させつつUIは変更する必要がない場合に使用できます。

import 'dart:async';

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

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

  
  Widget build(BuildContext context) {
    final counter = useState<int>(0);
    final isRunning = useState<bool>(false);

    // タイマーを保持するための Ref
    final timerRef = useRef<Timer?>(null);

    void startTimer() {
      if (!isRunning.value) {
        timerRef.value = Timer.periodic(const Duration(seconds: 1), (timer) {
          counter.value += 1;
        });
        isRunning.value = true;
      }
    }

    void stopTimer() {
      if (isRunning.value) {
        timerRef.value?.cancel();
        timerRef.value = null;
        isRunning.value = false;
      }
    }

    useEffect(() {
      return () {
        timerRef.value?.cancel();
      };
    }, []);
    return Scaffold(
      appBar: AppBar(
        title: const Text('useRef サンプル'),
      ),
      body: Center(
        child: Text(
          'カウンター: ${counter.value}',
          style: const TextStyle(fontSize: 32),
        ),
      ),
      floatingActionButton: !isRunning.value
          ? FloatingActionButton(
              onPressed: startTimer,
              tooltip: '開始',
              child: const Icon(Icons.play_arrow),
            )
          : FloatingActionButton(
              onPressed: stopTimer,
              tooltip: '停止',
              child: const Icon(Icons.stop),
            ),
    );
  }
}

実行結果

https://dartpad.dev/?null_safety=true&id=9e108cba9c5156a0b4bf6f2b568bbfdc

17. useScrollController

概要

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

Creates ScrollController that will be disposed automatically.

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

ScrollController
https://api.flutter.dev/flutter/widgets/ScrollController-class.html

Controls a scrollable widget.
Scroll controllers are typically stored as member variables in State objects and are reused in each State.build. A single scroll controller can be used to control multiple scrollable widgets, but some operations, such as reading the scroll offset, require the controller to be used with a single scrollable widget.
A scroll controller creates a ScrollPosition to manage the state specific to an individual Scrollable widget. To use a custom ScrollPosition, subclass ScrollController and override createScrollPosition.

(日本語訳)
スクロール可能なウィジェットを制御します。
スクロールコントローラーは通常、Stateオブジェクト内のメンバー変数として保存され、各State.buildで再利用されます。単一のスクロールコントローラーで複数のスクロール可能なウィジェットを制御することができますが、スクロールオフセットを読み取るような操作では、コントローラーを1つのスクロール可能なウィジェットで使用する必要があります。
スクロールコントローラーは、個々のスクロール可能なウィジェットに特有の状態を管理するためにScrollPositionを作成します。カスタムのScrollPositionを使用するには、ScrollControllerをサブクラス化し、createScrollPositionをオーバーライドしてください。

使用用途

  1. プログラムによるスクロール操作
    ボタンや他のUI要素から特定の位置へスクロールする際に使用可能(例えば、「トップへ戻る」ボタンを実装する場合)
  2. スクロール位置の監視
    現在のスクロール位置をリアルタイムで取得し、スクロールに応じたUIの変更が可能(例えば、AppBarの透明度を変更)
  3. 複数のスクロールビューの同期
    複数のスクロールビューのスクロール位置を同期させ、一方のスクロールに他方も連動してスクロールさせることが可能

使用方法

以下のようにスクロールの位置を監視し、その位置によってボタンを出すかどうかを変化させることができます。また、 animateTo メソッドで特定の場所まで戻ることもできます。

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

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

  
  Widget build(BuildContext context) {
    // useScrollController を使用して ScrollController を作成
    final scrollController = useScrollController();

    // ボタンの表示・非表示を管理する状態
    final showButton = useState<bool>(false);

    // スクロール位置の変化を監視するリスナーを設定
    useEffect(() {
      void listener() {
        if (scrollController.offset > 300 && !showButton.value) {
          showButton.value = true;
        } else if (scrollController.offset <= 300 && showButton.value) {
          showButton.value = false;
        }
      }

      scrollController.addListener(listener);

      return () => scrollController.removeListener(listener);
    }, [scrollController, showButton.value]);

    void scrollToTop() {
      scrollController.animateTo(
        0,
        duration: const Duration(milliseconds: 500),
        curve: Curves.easeInOut,
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('useScrollController サンプル'),
      ),
      body: Stack(
        children: [
          ListView.builder(
            controller: scrollController,
            itemCount: 100,
            itemBuilder: (context, index) {
              return ListTile(
                leading: CircleAvatar(child: Text('$index')),
                title: Text('アイテム $index'),
              );
            },
          ),
          // 「トップへ戻る」ボタンを右下に表示
          Positioned(
            bottom: 20,
            right: 20,
            child: AnimatedOpacity(
              opacity: showButton.value ? 1.0 : 0.0,
              duration: const Duration(milliseconds: 300),
              child: FloatingActionButton(
                onPressed: scrollToTop,
                tooltip: 'トップへ戻る',
                child: const Icon(Icons.arrow_upward),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

実行結果

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

18. useSearchController

概要

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

Creates a SearchController that will be disposed automatically.

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

SearchController
https://api.flutter.dev/flutter/material/SearchController-class.html

A controller to manage a search view created by SearchAnchor.
A SearchController is used to control a menu after it has been created, with methods such as openView and closeView. It can also control the text in the input field.

(日本語訳)
SearchAnchorによって作成された検索ビューを管理するためのコントローラーです。
SearchControllerは、メニューが作成された後にそれを制御するために使用されます。例えば、openViewやcloseViewといったメソッドを利用できます。また、入力フィールド内のテキストを制御することもできます。

使用用途

  1. 検索バーの入力管理
    ユーザーが検索バーに入力したテキストを効率的に管理し、リアルタイムで検索クエリを取得
  2. リアルタイム検索結果のフィルタリング
    ユーザーの入力に基づいてリストやデータをリアルタイムでフィルタリングし、動的に表示
  3. 検索履歴の管理
    ユーザーの過去の検索クエリを保持し、表示・再利用
  4. デバウンス機能の実装
    ユーザーが入力を完了するまで待機し、不要な再検索を回避するためにデバウンスを適用
  5. 検索クエリのバリデーション
    入力された検索クエリのバリデーションを行い、適切な形式や内容であることを確認
  6. カスタム検索ロジックの統合
    特定のビジネスロジックやフィルタリング条件を組み込んだカスタム検索機能を実装

使用方法

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

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

  
  Widget build(BuildContext context) {
    // `useSearchController` を使用して TextEditingController を作成
    final searchController = useSearchController();
    final filteredList = useState<List<String>>([]);

    // 全リストのデータ
    final dataList = [
      'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape',
      'Honeydew', 'Indian Fig', 'Jackfruit', 'Kiwi', 'Lemon', 'Mango',
      'Nectarine', 'Orange', 'Papaya', 'Quince', 'Raspberry', 'Strawberry', 'Tomato',
      'Ugli Fruit', 'Vanilla', 'Watermelon', 'Xigua', 'Yellow Passion Fruit', 'Zucchini',
    ];

    useEffect(() {
      filteredList.value = dataList;
      return null;
    }, []);

    // 検索入力の変更を監視してリストをフィルタリング
    useEffect(() {
      void listener() {
        final query = searchController.text.toLowerCase();
        if (query.isEmpty) {
          filteredList.value = dataList;
        } else {
          filteredList.value = dataList
              .where((item) => item.toLowerCase().contains(query))
              .toList();
        }
      }

      searchController.addListener(listener);

      return () => searchController.removeListener(listener);
    }, [searchController]);

    return Scaffold(
      appBar: AppBar(
        title: const Text('リアルタイム検索'),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: TextField(
              controller: searchController,
              decoration: const InputDecoration(
                labelText: '検索',
                prefixIcon: Icon(Icons.search),
                border: OutlineInputBorder(),
              ),
            ),
          ),
          Expanded(
            child: filteredList.value.isNotEmpty
                ? ListView.builder(
                    itemCount: filteredList.value.length,
                    itemBuilder: (context, index) {
                      return ListTile(
                        title: Text(filteredList.value[index]),
                      );
                    },
                  )
                : const Center(
                    child: Text(
                      '一致する項目がありません。',
                      style: TextStyle(fontSize: 18),
                    ),
                  ),
          ),
        ],
      ),
    );
  }
}

実行結果

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

19. useState

概要

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

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

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

20. useStream, useStreamController

概要

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

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.

(日本語訳)
Streamに購読し、その現在の状態をAsyncSnapshotとして返します。
preserveStateは、Streamインスタンスを変更した際に現在の値を保持するかどうかを決定します。
preserveStateがtrue(デフォルト)の場合、ストリームを切り替える際の更新による遅延が軽減されますが、複数のストリームやネストされたストリームを使用する場合、一貫性のない状態が発生する可能性があります。

使用用途

  1. リアルタイムデータの表示
    Firestore などからのリアルタイムデータを表示する際に使用
  2. 非同期データの取得と表示
    APIからのデータ取得や長時間かかる計算処理の結果を非同期に取得し、UIに反映
  3. ユーザーインタラクションの監視
    ユーザーのジェスチャーや操作イベントをストリームとして監視し、反応

使用方法

以下のように Stream を返す関数を useMemoized でラップし、それを useStream で囲むことで Stream を扱うことができるようになります。

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

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

  
  Widget build(BuildContext context) {
    // ストリームの作成: 1秒ごとにカウントを増加させる
    Stream<int> streamCounter() async* {
      int i = 0;
      while (true) {
        yield i++;
        await Future.delayed(const Duration(seconds: 1));
      }
    }

    final countStream = useMemoized(streamCounter);
    final count = useStream(countStream).data ?? 0;

    return Scaffold(
      appBar: AppBar(
        title: const Text('useStream サンプル'),
      ),
      body: Center(
        child: Text(
          'カウント: $count',
          style: const TextStyle(fontSize: 40),
        ),
      ),
    );
  }
}

実行結果

https://dartpad.dev/?null_safety=true&id=3606c23f21d2b384254bb3483b12d61a

21. useTabController

概要

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

Creates a TabController that will be disposed automatically.

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

TabController

https://api.flutter.dev/flutter/material/TabController-class.html

Coordinates tab selection between a TabBar and a TabBarView.
The index property is the index of the selected tab and the animation represents the current scroll positions of the tab bar and the tab bar view. The selected tab's index can be changed with animateTo.

(日本語訳)
TabBarとTabBarView間でタブの選択を調整します。
indexプロパティは選択されたタブのインデックスを示し、animationはタブバーとタブビューバーの現在のスクロール位置を表します。選択されたタブのインデックスはanimateToを使用して変更することができます。

使用用途

  1. タブナビゲーションの実装
    複数のタブを持つ画面でのナビゲーションを管理
  2. タブの選択状態の管理
    現在選択されているタブのインデックスを管理し、プログラムからタブを切り替えることが可能
  3. アニメーション付きのタブ切り替え
    タブ間の切り替えにアニメーションを付けて、ユーザー体験を向上

使用方法

以下のように useTabControllerTabController を作成し、 TabBarTabBarView に渡すことでタブの挙動を操作することができるようになります。

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

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

  
  Widget build(BuildContext context) {
    // useTabController を使用して TabController を作成
    final tabController = useTabController(initialLength: 3);

    return Scaffold(
      appBar: AppBar(
        title: const Text('useTabController サンプル'),
        bottom: TabBar(
          controller: tabController,
          tabs: const [
            Tab(icon: Icon(Icons.home), text: 'ホーム'),
            Tab(icon: Icon(Icons.search), text: '検索'),
            Tab(icon: Icon(Icons.person), text: 'プロフィール'),
          ],
        ),
      ),
      body: TabBarView(
        controller: tabController,
        children: const [
          Center(child: Text('ホームタブのコンテンツ')),
          Center(child: Text('検索タブのコンテンツ')),
          Center(child: Text('プロフィールタブのコンテンツ')),
        ],
      ),
    );
  }
}

実行結果

https://dartpad.dev/?null_safety=true&id=9f77bc0faf88ee358b4225c3c807c8ad

22. useTransformationController

概要

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

Creates and disposes a TransformationController.

(日本語訳)
TransformationController を作成して破棄する

TransformationController

https://api.flutter.dev/flutter/widgets/TransformationController-class.html

A thin wrapper on ValueNotifier whose value is a Matrix4 representing a transformation.
The value defaults to the identity matrix, which corresponds to no transformation.

(日本語訳)
変換を表す Matrix4 を値として持つ ValueNotifier のラッパーです。
値はデフォルトで単位行列(変換がない状態)になります。

使用用途

  1. インタラクティブな拡大縮小ビューの管理
    画像や地図など、ユーザーがピンチ操作で拡大縮小や移動を行うインタラクティブなビューで使用
  2. 変換の同期管理
    複数のウィジェット間で変換状態を同期させる際に使用

使用方法

以下のように useTransformationControllerTransformationController を作成し、 InteractiveViewer に渡すことでビューを操作することができるようになります。

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

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

  
  Widget build(BuildContext context) {
    // useTransformationController を使用して TransformationController を作成
    final transformationController = useTransformationController();
    final scale = useState(1.0);
    return Scaffold(
      appBar: AppBar(
        title: const Text('useTransformationController サンプル'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () {
              // リセットボタンを押すと、変換をリセット
              transformationController.value = Matrix4.identity();
            },
            tooltip: 'リセット',
          ),
        ],
      ),
      body: Center(
        child: InteractiveViewer(
          transformationController: transformationController,
          constrained: true,
          panEnabled: true,
          scaleEnabled: true,
          boundaryMargin: const EdgeInsets.all(16),
          minScale: 0.5,
          maxScale: 3.0,
          onInteractionUpdate: (details) {
            scale.value = transformationController.value.getMaxScaleOnAxis();
          },
          child: SizedBox(
            height: 300,
            child: Image.network(
              'https://flutter.github.io/assets-for-api-docs/assets/widgets/owl.jpg',
            ),
          ),
        ),
      ),
    );
  }
}

実行結果

https://dartpad.dev/?null_safety=true&id=4de8f66173a903c6eda8671442cf97d9

23. useValueChanged

概要

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

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.

(日本語訳)
値を監視し、値が変更されるたびにコールバックをトリガーします。
useValueChangedはvalueChangeコールバックを受け取り、値が変更されるたびにそれを呼び出します。ただし、初回のuseValueChangedの呼び出し時にはvalueChangeは呼び出されません。
useValueChangedは補間にも使用できます。useValueChangedが異なる値で呼び出されるたびに、valueChangeが呼び出されます。useValueChangedによって返される値は、valueChangeによって最後に返された値、またはnullです。

使用用途

  1. 状態変化の監視と処理
    特定の状態変数が変更されたときに、ログを記録したり、他の変数を更新したりする処理を実行
  2. 入力値のバリデーション
    ユーザーの入力が変更された際に、その入力値のバリデーション
  3. アニメーションのトリガー
    値の変化に応じてアニメーションを開始 / 停止
  4. APIコールのトリガー
    特定の値が変更された際に、関連するAPIコールを実行

使用方法

以下のように useValueChanged を使えば第一引数の値の変化を監視して、変化があった場合には第二引数にある関数を実行することができるようになります。
この例では email の値を監視し、メールアドレスが適当かどうかを判定するようにしています。

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

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

  
  Widget build(BuildContext context) {
    final email = useState<String>('');
    final isValid = useState<bool>(false);

    // useValueChanged を使用して email.value の変化を監視
    useValueChanged<String, void>(
      email.value,
      (_, previousEmail) {
        final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
        isValid.value = emailRegex.hasMatch(email.value);
      },
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('useValueChanged サンプル'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              decoration: InputDecoration(
                labelText: 'メールアドレス',
                border: const OutlineInputBorder(),
                suffixIcon: Icon(
                  isValid.value ? Icons.check_circle : Icons.error,
                  color: isValid.value ? Colors.blue : Colors.red,
                ),
              ),
              onChanged: (value) {
                email.value = value;
              },
              keyboardType: TextInputType.emailAddress,
            ),
            const SizedBox(height: 20),
            Text(
              isValid.value ? '有効なメールアドレスです。' : '無効なメールアドレスです。',
              style: TextStyle(
                color: isValid.value ? Colors.blue : Colors.red,
                fontSize: 16,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

実行結果

https://dartpad.dev/?null_safety=true&id=4a02fd15bf594d0364796560a6b5dd71

まとめ

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

今回まで複数回に分けて flutter_hooks に用意されているメソッドについてみてきました。
普段の実装において、 flutter_hooks を使えばより簡単に記述できる部分が多くあると感じました。
もちろん、以下の点のような複数の点を考慮して使用すべきではありますが、便利なメソッドが多くあることがわかりました。

  • 各チームの方針
  • 理解する負荷が大きくないかどうか
  • ある程度の内部の実装

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

参考

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

Discussion