【Flutter】flutter_hooks のメソッドをまとめる②
初めに
今回は、前回に引き続き、 flutter_hooks に用意されているメソッドをいくつか抽出して、その用途をまとめて見たいと思います。
前回の記事
記事の対象者
- Flutter 学習者
- flutter_hooks について知りたい方
9. useFocusNode, useFocusScopeNode
概要
Creates an automatically disposed FocusNode.
(日本語訳)
自動的に破棄される FocusNode を作成
使用用途
- フォーム入力のフォーカス管理
複数のテキストフィールド間でのフォーカスの移動が実装可能(例:次のフィールドに自動移動) - フォーカスの状態に応じたUIの変更
フォーカスが当たったときにボーダーの色を変更するなど、UIの視覚的フィードバックを提供 - フォーカスのプログラム的制御
特定の条件下でウィジェットにフォーカスを当てる実装が可能(例:エラーメッセージ表示時に該当フィールドにフォーカス)
使用方法
以下では、テキストフィールドを入力して Enter を押すと次のフィールドにフォーカスが移るようになっています。また、ボタンで外部からフォーカスを操作できるようになっています。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class UseFocusNodeSample extends HookWidget {
const UseFocusNodeSample({super.key});
Widget build(BuildContext context) {
// 各テキストフィールド用の FocusNode を作成
final focusNode1 = useFocusNode();
final focusNode2 = useFocusNode();
final focusNode3 = useFocusNode();
// テキストフィールドの入力内容を管理する状態
final text1 = useState<String>('');
final text2 = useState<String>('');
final text3 = useState<String>('');
useEffect(() {
// クリーンアップ
return () {
focusNode1.dispose();
focusNode2.dispose();
focusNode3.dispose();
};
}, [
focusNode1,
focusNode2,
focusNode3,
]);
return Scaffold(
appBar: AppBar(
title: const Text('useFocusNode サンプル'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
focusNode: focusNode1,
decoration: const InputDecoration(
labelText: '名前',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
onChanged: (value) {
text1.value = value;
},
onSubmitted: (_) {
// Field2 にフォーカスを移動
FocusScope.of(context).requestFocus(focusNode2);
},
),
const SizedBox(height: 20),
TextField(
focusNode: focusNode2,
decoration: const InputDecoration(
labelText: 'メールアドレス',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
onChanged: (value) {
text2.value = value;
},
onSubmitted: (_) {
// Field3 にフォーカスを移動
FocusScope.of(context).requestFocus(focusNode3);
},
),
const SizedBox(height: 20),
TextField(
focusNode: focusNode3,
decoration: const InputDecoration(
labelText: 'パスワード',
border: OutlineInputBorder(),
),
obscureText: true,
textInputAction: TextInputAction.done,
onChanged: (value) {
text3.value = value;
},
onSubmitted: (_) {
// キーボードを閉じる
focusNode3.unfocus();
debugPrint('名前: ${text1.value}');
debugPrint('メールアドレス: ${text2.value}');
debugPrint('パスワード: ${text3.value}');
},
),
const SizedBox(height: 30),
Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
ElevatedButton(
onPressed: () {
FocusScope.of(context).requestFocus(focusNode1);
},
child: const Text('名前にフォーカス'),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () {
FocusScope.of(context).requestFocus(focusNode2);
},
child: const Text('メールアドレスにフォーカス'),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () {
FocusScope.of(context).requestFocus(focusNode3);
},
child: const Text('パスワードにフォーカス'),
),
],
),
],
),
),
);
}
}
実行結果
少しコードを見ていきます。
以下では useFocusNode
で FocusNode
を作成しています。
final focusNode1 = useFocusNode();
final focusNode2 = useFocusNode();
final focusNode3 = useFocusNode();
以下では useState
でテキストの内容を保持しています。
final text1 = useState<String>('');
final text2 = useState<String>('');
final text3 = useState<String>('');
以下では、テキストフィールドの focusNode
に useFocusNode
で生成した FocusNode
を指定しています。また、 onSubmitted
に requestFocus
を指定することでテキストフィールドで Enter が押された際に次のフィールドにフォーカスが当たるようにしています。
TextField(
focusNode: focusNode1,
decoration: const InputDecoration(
labelText: '名前',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
onChanged: (value) {
text1.value = value;
},
onSubmitted: (_) {
// Field2 にフォーカスを移動
FocusScope.of(context).requestFocus(focusNode2);
},
),
10. useFuture
概要
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.(日本語訳)
Futureに購読し、その現在の状態をAsyncSnapshotとして返します。
preserveStateは、Futureインスタンスを変更した際に現在の値を保持するかどうかを決定します。
FutureはuseFutureの外側で作成する必要があります。FutureがuseFutureの内側で作成されると、ビルドメソッドが呼び出されるたびにFutureが再度呼び出されてしまいます。FutureをuseFutureの外側で作成する一つの方法として、useMemoizedを使用することが挙げられます。
使用用途
- データのフェッチ
APIからデータを取得し、取得完了後にUIを更新 - 非同期計算
時間のかかる計算処理をバックグラウンドで実行し、結果を表示できる - データベース操作
ローカルデータベースからデータを読み込む、またはデータを保存する際に使用 - ファイル操作
ファイルの読み書きなどのI/O操作を非同期で実行可能
使用方法
以下のコードでは JSON Placeholder API でデータを取得して表示する際に useFuture
を用いて実装しています。
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:http/http.dart' as http;
class UseFutureSample extends HookWidget {
const UseFutureSample({super.key});
/// データをフェッチする非同期関数
Future<List<dynamic>> fetchData() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final response = await http.get(url);
if (response.statusCode == 200) {
return json.decode(response.body);
} else {
throw Exception('データの取得に失敗しました');
}
}
Widget build(BuildContext context) {
// useFuture を使用して非同期データを取得
final future = useMemoized(() => fetchData());
final snapshot = useFuture(future, initialData: null);
return Scaffold(
appBar: AppBar(
title: const Text('useFuture サンプル'),
),
body: Center(
child: () {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('エラー: ${snapshot.error}');
} else if (snapshot.hasData) {
final posts = snapshot.data!;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
title: Text(post['title']),
subtitle: Text(post['body']),
);
},
);
} else {
return const Text('データがありません');
}
}(),
),
);
}
}
実行結果
コードを見ていきます。
以下では fetchData
メソッドで JSON Placeholder API のデータを非同期で取得しています。
Future<List<dynamic>> fetchData() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final response = await http.get(url);
if (response.statusCode == 200) {
return json.decode(response.body);
} else {
throw Exception('データの取得に失敗しました');
}
}
以下では future
として、 useMemoized
を用いて fetchData
をメモ化しています。
そして、 useFuture
に future
を渡しています。初期のデータは必要ないため initialData
は null にしています。
final future = useMemoized(() => fetchData());
final snapshot = useFuture(future, initialData: null);
以下では useFuture
で定義した snapshot
の状態をもとに表示するビューを切り替えています。
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('エラー: ${snapshot.error}');
} else if (snapshot.hasData) {
final posts = snapshot.data!;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
title: Text(post['title']),
subtitle: Text(post['body']),
);
},
);
} else {
return const Text('データがありません');
}
11. useListenable, useListenableSelector
概要
Subscribes to a Listenable and marks the widget as needing build whenever the listener is called.
(日本語訳)
Listenableに購読し、リスナーが呼び出されるたびにウィジェットを再ビルドが必要であるとマークします。
使用用途
- テキスト入力のリアルタイム監視
TextEditingController
を使用してテキストフィールドの変更を監視し、リアルタイムで反映 - アニメーションの制御
AnimationController
を利用してアニメーションの進行状態を監視し、アニメーションに応じてUIを更新 - スクロール位置の追跡
ScrollController
を使用してスクロール位置の変化を監視し、スクロールに応じたUI変更
使用方法
以下では useListenableSelector
で TextEditingController
を監視して、入力されている内容に応じてエラーを表示させています。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class UseListenableSelectorSample extends HookWidget {
const UseListenableSelectorSample({super.key});
/// メールアドレスのバリデーション関数
bool isValidEmail(String email) {
final emailRegex = RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@"
r"[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$");
return emailRegex.hasMatch(email);
}
Widget build(BuildContext context) {
final textController = useTextEditingController();
final isValid = useListenableSelector(
textController,
() => isValidEmail(textController.text),
);
return Scaffold(
appBar: AppBar(
title: const Text('useListenableSelector サンプル'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: textController,
decoration: InputDecoration(
labelText: 'メールアドレス',
labelStyle: TextStyle(
color: isValid
? Theme.of(context).colorScheme.primary
: Colors.red,
),
border: const OutlineInputBorder(),
errorText: isValid ? null : '有効なメールアドレスを入力してください',
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: isValid
? Theme.of(context).colorScheme.primary
: Colors.red,
width: 2.0,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: isValid ? Colors.grey : Colors.red,
width: 1.0,
),
),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
textController.clear();
},
tooltip: '入力をクリア',
child: const Icon(Icons.clear),
),
);
}
}
コードを見ていきます。
以下では、テキストの内容を保持する textController
を定義して、それを useListenableSelector
に渡しています。これで textController
の動きを監視することができるようになります。
final textController = useTextEditingController();
final isValid = useListenableSelector(
textController,
() => isValidEmail(textController.text),
);
12. useMemoized
概要
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.(日本語訳)
複雑なオブジェクトのインスタンスをキャッシュします。
useMemoized は初回の呼び出し時に即座に valueBuilder を実行し、その結果を保存します。その後、 HookWidget が再ビルドされた場合でも、 useMemoized の呼び出しは以前に作成されたインスタンスを返し、 valueBuilder は再び呼び出されません。
ただし、異なるキーを使用して useMemoized を再び呼び出すと、新しいインスタンスを作成するために関数が再実行されます。
使用用途
- 重い計算の結果のメモ化
ウィジェットの再ビルド時に重い計算処理を繰り返さないため、一度計算した結果をメモ化して再利用 - 非同期処理の一度だけの実行
APIコールやデータベースからのデータ取得などの非同期処理を、一度だけ実行し、その結果を再利用 - オブジェクトの一貫性の保持
再ビルド時に新しいインスタンスが作成されないように、重要なオブジェクト(例えば、ScrollController や AnimationController)をメモ化
使用方法
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class UseMemoizedSample extends HookWidget {
const UseMemoizedSample({super.key});
int fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
Widget build(BuildContext context) {
// フィボナッチ数の計算をメモ化
final fibResult = useMemoized(() => fibonacci(40), []);
final result = useState<int>(fibResult);
return Scaffold(
appBar: AppBar(
title: const Text('useMemoized サンプル'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('フィボナッチ(40) の結果: ${result.value}'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
result.value = fibResult;
},
child: const Text('結果を更新'),
),
],
),
),
);
}
}
useMemoized
を使用することで、フィボナッチ計算は一度だけ実行され、その結果がメモ化されます。Widget が再ビルドされても、計算は再実行されず、メモ化された値が利用されるため、パフォーマンスが向上します。
今回はフィボナッチ計算にしていますが、他にもデータの取得処理など複数応用できるかと思います。
13. useOnAppLifecycleStateChange
概要
Listens to the AppLifecycleState.
(日本語訳)
AppLifecycleState をリッスンして監視
使用用途
- データの自動保存
アプリがバックグラウンドに移行する際に、ユーザーのデータやセッション情報を自動的に保存 - リソースの解放と再確保
アプリが非アクティブになる際に、不要なリソース(例えば、メモリやバッテリー消費の高いタスク)を解放し、再アクティブ化時に再確保 - プッシュ通知の管理
アプリがアクティブかどうかに応じて、プッシュ通知の受信や表示方法を調整
使用方法
以下のように useOnAppLifecycleStateChange
でアプリのライフサイクルを監視して、状態が変化した時にその状態を出力するようにしています。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class UseOnAppLifecycleStateChangeSample extends HookWidget {
const UseOnAppLifecycleStateChangeSample({Key? key}) : super(key: key);
Widget build(BuildContext context) {
final statusText = useState('不明な状態');
useOnAppLifecycleStateChange((beforeState, currState) {
switch (currState) {
case AppLifecycleState.resumed:
statusText.value = 'アプリはフォアグラウンドにあります';
debugPrint('アプリはフォアグラウンドにあります');
break;
case AppLifecycleState.inactive:
statusText.value = 'アプリは非アクティブ状態です';
debugPrint('アプリは非アクティブ状態です');
break;
case AppLifecycleState.paused:
statusText.value = 'アプリは一時停止しました';
debugPrint('アプリは一時停止しました');
break;
case AppLifecycleState.detached:
statusText.value = 'アプリは終了しました';
debugPrint('アプリは終了しました');
break;
case AppLifecycleState.hidden:
statusText.value = 'アプリは非表示になりました';
debugPrint('アプリは非表示になりました');
break;
default:
statusText.value = '不明な状態';
debugPrint('不明な状態');
}
});
return Scaffold(
appBar: AppBar(
title: const Text('useOnAppLifecycleStateChange サンプル'),
),
body: Center(
child: Text(statusText.value),
),
);
}
}
14. usePageController
概要
Creates a PageController that will be disposed automatically.
(日本語訳)
自動的に破棄される PageController を作成します
使用用途
- ページ間のプログラム的な移動
ボタンや他のUI要素から特定のページに移動する際に使用(初めのページに戻るなど) - ページインジケーターとの同期
ページインジケーターと PageView を連動させて現在のページの位置を表示
使用方法
以下のコードのように usePageController
で PageController
を作成し、それに対してリスナーを追加することで PageView
のページ番号を管理することができます。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class UsePageControllerSample extends HookWidget {
const UsePageControllerSample({super.key});
Widget build(BuildContext context) {
// usePageController を使用して PageController を作成
final pageController = usePageController(initialPage: 0);
final currentPage = useState<int>(0);
useEffect(() {
void listener() {
currentPage.value = pageController.page?.round() ?? 0;
}
pageController.addListener(listener);
return () => pageController.removeListener(listener);
}, [pageController]);
return Scaffold(
appBar: AppBar(
title: const Text('usePageController サンプル'),
),
body: Column(
children: [
Expanded(
child: PageView(
controller: pageController,
children: const [
Center(
child: Text(
'ページ 1',
style: TextStyle(fontSize: 24),
),
),
Center(
child: Text(
'ページ 2',
style: TextStyle(fontSize: 24),
),
),
Center(
child: Text(
'ページ 3',
style: TextStyle(fontSize: 24),
),
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (index) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 16),
width: currentPage.value == index ? 12 : 8,
height: currentPage.value == index ? 12 : 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
currentPage.value == index ? Colors.black : Colors.grey,
),
);
}),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton(
onPressed: () {
if (currentPage.value > 0) {
pageController.animateToPage(
currentPage.value - 1,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
child: const Text('前へ'),
),
TextButton(
onPressed: () {
if (currentPage.value < 2) {
pageController.animateToPage(
currentPage.value + 1,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
child: const Text('次へ'),
),
],
),
),
const SizedBox(height: 20),
],
),
);
}
}
15. useReducer
概要
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.(日本語訳)
より複雑な状態を扱うための useState の代替手段です。
useReducerは、 Reducer によって解釈されるアクションをディスパッチすることで更新可能な、読み取り専用の状態インスタンスを管理します。
初回のビルド時に、Reducer は initialAction と initialState をパラメーターとして即座に呼び出されます。
新しいリデューサーを使用して useReducer を呼び出すことで、リデューサーを変更することが可能です。
使用用途
- 複数の関連する状態を管理
useReducer
を使用して一元管理することで、状態の一貫性を保つ - 複雑な状態遷移ロジック
状態更新が複数のステップや条件に依存する場合、Reducer 関数内でロジックを整理することで、コードの可読性と保守性が向上する - フォーム管理
複数の入力フィールドやバリデーションロジックを持つフォームの状態管理
使用方法
以下のようなコードで、Flutter において Redux アーキテクチャを採用する場合に使用されることが多いかと思います。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class CounterState {
const CounterState({this.count = 0});
final int count;
CounterState copyWith({int? count}) {
return CounterState(count: count ?? this.count);
}
}
sealed class CounterAction {}
class CounterIncrementAction implements CounterAction {
const CounterIncrementAction();
}
class CounterDecrementAction implements CounterAction {
const CounterDecrementAction();
}
class CounterResetAction implements CounterAction {
const CounterResetAction();
}
typedef CounterStore = Store<CounterState, CounterAction>;
typedef CounterReducer = Reducer<CounterState, CounterAction>;
CounterReducer get reducer => (state, action) {
return switch (action) {
CounterIncrementAction() => state.copyWith(count: state.count + 1),
CounterDecrementAction() => state.copyWith(count: state.count - 1),
CounterResetAction() => state.copyWith(count: 0),
};
};
class UseReducerSample extends HookWidget {
const UseReducerSample({super.key});
Widget build(BuildContext context) {
final CounterStore store = useReducer(
reducer,
initialState: const CounterState(),
initialAction: const CounterResetAction(),
);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${store.state.count}',
style: Theme.of(context).textTheme.headlineMedium,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
color: Colors.red,
onPressed: () =>
store.dispatch(const CounterIncrementAction()),
icon: const Icon(Icons.add),
),
IconButton(
color: Colors.blue,
onPressed: () =>
store.dispatch(const CounterDecrementAction()),
icon: const Icon(Icons.remove),
),
],
),
],
),
),
);
}
}
実行結果
まとめ
最後まで読んでいただいてありがとうございました。
今回も flutter_hooks に用意されているメソッドについてみてきました。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。
参考
Discussion