はじめに
Flutter アプリケーションで Google Books API を利用して、書籍検索機能の実装についてまとめます。
前回は Google Books API で書籍データを取得して、取得結果を表示させました。
現状は「ノラネコぐんだん」というキーワードで固定して表示していますが、今回はユーザーが検索ページの検索フィールドにキーワードを入力したら、そのキーワードを基に書籍を検索し、データ取得し検索結果ページが開くようにします。
前提
この記事では、hukusuke1007 / flutter_app_template をベースにして開発しています。
Flutter + Firebase アプリのスターターキットになっており、サンプル機能がとても充実しており、今回の Google Books API を利用した書籍検索機能もテンプレートをベースにしています。
-
hukusuke1007 / flutter_app_template の導入(
git clone
からflutter run
まで)が完了していること
目次
1. ゴールの確認 - ページ遷移
2. アプリでの実装
2-1. コントローラの編集
2-2. ページ下部のタブウィジェットの編集
2-3. 検索ページウィジェットの作成
2-4. 検索結果ページウィジェットの作成
3. 動作確認
1. ゴールの確認
実装する前に、ゴールの確認をします。
今回は、以下のような動作を実現します。
- 検索タブをタップすると、検索ページが開く
- 検索ページで検索フィールドにキーワードを入力し、エンターキーを押すとデータが取得され、検索結果ページが開く
- 検索結果ページで、スクロールすることで +20 件ずつ検索結果が追加表示される
ページ遷移
ページ下部のタブ(常時表示)
-
検索(みつける)タブ
- 検索タブをタップすると、検索ページへ遷移
- 検索結果ページが開いている場合は、検索ページに戻る
↓
検索ページ
- 検索フィールド
- 検索テキスト削除ボタン
- 検索ボタン
- 画面には表示させない(キーボードの決定ボタン=検索ボタン)
検索結果ページ
- 戻るボタン
- 検索フィールド
- 検索テキスト削除ボタン
- 検索ボタン
- 画面には表示させない(キーボードの決定ボタン=検索ボタン)
- 検索結果を表示
- Google Books API でデータ取得
- スクロールで+20件ずつ追加表示
今後の予定としては、検索結果のリストに「+本棚登録」ボタンを追加し、書籍データを登録する機能を実装することを考えています。
2. アプリでの実装
実際のコードを編集して機能を追加する方法を説明します。
2-1. コントローラの編集
まずは、検索を実行するためのコントローラを編集します。ユーザーがキーワードを入力した際にデータを動的に取得するようにします。
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../entities/google_books.dart';
import '../repositories/google_books_api_repository.dart';
part 'search_books_controller.g.dart';
/// Google Booksの検索結果を管理するためのコントローラークラス。
///
/// riverpodの状態管理を用いて、Google Books APIから書籍情報を取得し、
/// ページング処理を含む検索結果の管理を行う。
@riverpod
class SearchBooksController extends _$SearchBooksController {
/// 1ページあたりに取得する書籍の件数。
static int get _pageCount => 20;
/// 最後に取得した書籍リストの開始インデックスを保持。
int _lastStartIndex = 0;
/// コントローラーのインスタンス生成時に呼び出されるメソッド。
///
/// 初期検索結果を取得し、リストの長さに応じて次の取得インデックスを設定する。
///
/// [Book]書籍情報のリストを含むFutureを返す。
@override
- Future<List<Book>> build() async {
+ Future<List<Book>> build(String query) async {
final length = state.value?.length ?? 0;
final data = await ref.watch(googleBooksApiRepositoryProvider).getBooks(
- query: 'ノラネコぐんだん',
+ query: query,
startIndex: 0,
maxResults: length > _pageCount ? length : _pageCount,
);
if (data.isNotEmpty) {
_lastStartIndex = data.length;
} else {
_lastStartIndex = 0;
}
return data;
}
/// リストの最下部に到達した際に追加の書籍データを取得する。
///
/// ページングを考慮して、次の書籍データを取得し、現在の状態に追加する。
- Future<void> onFetchMore() async {
+ Future<void> onFetchMore(String query) async {
final result = await AsyncValue.guard(() async {
final data = await ref.read(googleBooksApiRepositoryProvider).getBooks(
- query: 'ノラネコぐんだん',
+ query: query,
startIndex: _lastStartIndex,
maxResults: _pageCount,
);
if (data.isNotEmpty) {
_lastStartIndex += data.length;
}
final previousState = await future;
return [...previousState, ...data];
});
state = result;
}
}
2-2. ページ下部のタブ(メインページ)ウィジェットの編集
次に、メインページでのタブ設定を編集します。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:page_transition/page_transition.dart';
import '../../../core/utils/tab_tap_operation_provider.dart';
import '../../github_users/pages/github_users_page.dart';
import '../../home/pages/home_page.dart';
import '../../memo/pages/memo_page.dart';
import '../../search/pages/start_search_page.dart';
import '../../setting/pages/setting_page.dart';
import 'widgets/tab_navigator.dart';
/// メインページウィジェット。
///
/// タブナビゲーションを提供し、アプリケーションの主要な画面にアクセスさせる。
class MainPage extends HookConsumerWidget {
const MainPage({super.key});
/// ページ名を返す。
static String get pageName => 'main';
/// ページのパスを返す。
static String get pagePath => '/$pageName';
/// go_routerを使用してこのページに遷移するためのメソッド。
///
/// 引数[context]は、ページ遷移の際に使用するBuildContext。
static void go(BuildContext context) {
context.go(pagePath);
}
/// 従来のページ遷移メソッド。
///
/// 引数[context]は、ページ遷移の際に使用するBuildContext。
static Future<void> showNav1(BuildContext context) =>
Navigator.of(context, rootNavigator: true).pushReplacement<void, void>(
PageTransition(
type: PageTransitionType.fade,
child: const MainPage(),
duration: const Duration(milliseconds: 500),
settings: RouteSettings(name: pageName),
),
);
@override
Widget build(BuildContext context, WidgetRef ref) {
// 各タブで表示するウィジェットとそのNavigatorの状態を保持するリスト
final widgetsState =
useState<List<(GlobalKey<NavigatorState>, String, Widget)>>(
[
(
GlobalKey<NavigatorState>(),
HomePage.pageName,
const HomePage(),
),
(
GlobalKey<NavigatorState>(),
GithubUsersPage.pageName,
const GithubUsersPage()
),
(
GlobalKey<NavigatorState>(),
StartSearchPage.pageName,
const StartSearchPage()
),
(
GlobalKey<NavigatorState>(),
MemoPage.pageName,
const MemoPage(),
),
(
GlobalKey<NavigatorState>(),
SettingPage.pageName,
const SettingPage(),
),
],
);
final widgets = widgetsState.value;
// 選択されたタブのインデックスを保持する状態
final selectedTabIndexState = useState(0); // 初期タブは"ホーム"タブ
final selectedTabIndex = selectedTabIndexState.value;
return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
if (didPop) {
return;
}
final keyTab = widgetsState.value[selectedTabIndex].$1;
if (keyTab.currentState != null && keyTab.currentState!.canPop()) {
await keyTab.currentState!.maybePop();
}
},
child: Scaffold(
resizeToAvoidBottomInset: false,
body: IndexedStack(
index: selectedTabIndex,
children: List.generate(
widgets.length,
(index) => TabNavigator(
navigatorKey: widgets[index].$1,
page: widgets[index].$3,
),
),
),
bottomNavigationBar: NavigationBar(
onDestinationSelected: (int index) {
// 同じタブが選択された場合の処理
if (index == selectedTabIndex) {
final pageName = widgets[index].$2;
if (index == 2) {
// "みつける"タブが再度タップされた場合
final navigator = widgets[index].$1.currentState!;
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
}
} else {
ref
.read(tabTapOperationProviders(pageName))
.call(TabTapOperationType.duplication);
}
}
// タブのインデックスを更新
selectedTabIndexState.value = index;
},
selectedIndex: selectedTabIndex,
destinations: const <Widget>[
NavigationDestination(
selectedIcon: Icon(Icons.home),
icon: Icon(Icons.home_outlined),
label: 'ホーム',
),
NavigationDestination(
selectedIcon: Icon(Icons.people),
icon: Icon(Icons.people_outlined),
label: 'タブ2',
),
NavigationDestination(
selectedIcon: Icon(Icons.search),
icon: Icon(Icons.search_outlined),
label: 'みつける',
),
NavigationDestination(
selectedIcon: Icon(Icons.edit),
icon: Icon(Icons.edit_outlined),
label: 'タブ3',
),
NavigationDestination(
selectedIcon: Icon(Icons.settings),
icon: Icon(Icons.settings_outlined),
label: '設定',
),
],
),
),
);
}
}
コードの補足
GlobalKey<NavigatorState>
の設定
(
GlobalKey<NavigatorState>(),
StartSearchPage.pageName,
const StartSearchPage()
),
- 各タブのナビゲーション状態を保持するために、
GlobalKey
を使用しています。これにより、ユーザーがタブを切り替えても各ページの状態が保持されます。
if (index == 2)
のロジック
if (index == 2) {
// "みつける"タブが再度タップされた場合
final navigator = widgets[index].$1.currentState!;
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
}
}
- 「みつける」タブが再度タップされた場合、現在のナビゲーションスタックのルートまで戻るように設定しています。これにより、ユーザーが「みつける」タブを再度タップすると、検索ページの最初の状態に戻ります。
NavigationDestination
の設定
NavigationDestination(
selectedIcon: Icon(Icons.search),
icon: Icon(Icons.search_outlined),
label: 'みつける',
),
- 各タブのアイコンとラベルを設定します。「みつける」タブが選択されると、検索ページが表示されます。
2-3. 検索ページウィジェットの作成
検索ページの実装では、検索フィールドを用意し、ユーザーが入力したキーワードを元に検索を行い、結果を表示するためのページへ遷移させます。
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'searched_books_page.dart';
/// 検索開始ページウィジェット。
///
/// ユーザーが入力した検索キーワードをもとに、
/// 検索結果を表示する[SearchedBooksPage]へ遷移する機能を提供する。
class StartSearchPage extends HookConsumerWidget {
/// コンストラクタ。キーをオプションで受け取る。
const StartSearchPage({super.key});
/// ページの名前を示す定数。
static String get pageName => 'start_search';
@override
Widget build(BuildContext context, WidgetRef ref) {
// 検索クエリ入力のためのテキストコントローラを初期化
final searchController = TextEditingController();
// 検索を実行し、結果を表示するページへ遷移
void searchBooks() {
if (searchController.text.isNotEmpty) {
Navigator.of(context).push(
MaterialPageRoute<SearchedBooksPage>(
builder: (context) =>
SearchedBooksPage(query: searchController.text),
),
);
}
}
return Scaffold(
appBar: AppBar(
title: const Text('みつける'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: searchController, // 検索用のテキストコントローラ
decoration: InputDecoration(
labelText: '作品・著者・キーワードでみつける', // テキストフィールドのラベル
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Colors.grey,
shape: BoxShape.circle,
),
child: const Icon(Icons.clear, size: 12, color: Colors.white),
),
onPressed: searchController.clear, // クリアボタンの動作
),
),
onSubmitted: (_) => searchBooks(), // キーワードが入力されたときの動作
),
],
),
),
);
}
}
コードの補足
searchBooks
メソッド
// 検索を実行し、結果を表示するページへ遷移
void searchBooks() {
if (searchController.text.isNotEmpty) {
Navigator.of(context).push(
MaterialPageRoute<SearchedBooksPage>(
builder: (context) =>
SearchedBooksPage(query: searchController.text),
),
);
}
}
- ユーザーが検索キーワードを入力し、検索ボタンをタップした際に、
SearchedBooksPage
へ遷移するためのメソッドです。キーワードが空でない場合のみ遷移を実行します。
2-4. 検索結果ページウィジェットの作成
検索結果ページでは、Google Books API を用いて取得した書籍情報を表示し、スクロール操作で追加の結果を読み込むことができます。
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../core/custom_hooks/use_refresh_controller.dart';
import '../use_cases/search_books_controller.dart';
import 'widgets/error_message.dart';
/// 検索結果ページウィジェット。
///
/// 指定された検索クエリに基づいてGoogle Books APIから書籍の検索結果を取得し、その結果をリスト形式で表示する。
/// プルダウンでリフレッシュし、スクロール時に追加の結果を読み込む。
class SearchedBooksPage extends HookConsumerWidget {
/// 検索クエリを受け取るコンストラクタ。
const SearchedBooksPage({super.key, required this.query});
/// ユーザーが入力した検索クエリ。
final String query;
@override
Widget build(BuildContext context, WidgetRef ref) {
// 検索クエリに基づいて検索を行うためのテキストコントローラを初期化
final searchController = TextEditingController(text: query);
// 指定されたクエリに基づいて書籍検索結果を監視
final searchBooks = ref.watch(searchBooksControllerProvider(query));
// スクロール操作を監視するためのスクロールコントローラを初期化
final scrollController = useScrollController();
// リフレッシュ操作を制御するためのリフレッシュコントローラを初期化
final refreshController = useRefreshController();
// 検索操作。ユーザーが検索キーワードを入力し、エンターキーを押した際に新たな検索結果ページを表示
void onSearch() {
if (searchController.text.isNotEmpty) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) =>
SearchedBooksPage(query: searchController.text),
),
);
}
}
return Scaffold(
appBar: AppBar(
// 検索フィールド
title: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: '作品・著者・キーワードでみつける',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.grey),
),
filled: true,
fillColor: Colors.white,
prefixIcon: const Icon(Icons.search, color: Colors.grey),
contentPadding:
const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
suffixIcon: IconButton(
icon: Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Colors.grey,
shape: BoxShape.circle,
),
child: const Icon(Icons.clear, size: 12, color: Colors.white),
),
onPressed: searchController.clear,
),
),
onSubmitted: (_) => onSearch(),
),
),
body: searchBooks.when(
// データ取得成功時の表示
data: (items) {
return SmartRefresher(
controller: refreshController,
enablePullUp: true,
// リフレッシュ動作
onRefresh: () async {
ref.invalidate(searchBooksControllerProvider(query));
refreshController.refreshCompleted();
},
// 追加読み込み動作
onLoading: () async {
await ref
.read(searchBooksControllerProvider(query).notifier)
.onFetchMore(query);
refreshController.loadComplete();
},
// 検索結果のリスト表示
child: ListView.separated(
controller: scrollController,
itemBuilder: (BuildContext context, int index) {
final data = items[index];
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
height: 150,
child: Row(
children: [
// サムネイル画像表示
Padding(
padding: const EdgeInsets.only(left: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.grey[200],
),
child: data.volumeInfo.imageLinks?.smallThumbnail !=
null
? Image.network(
data.volumeInfo.imageLinks!.smallThumbnail!,
fit: BoxFit.cover,
)
: const Icon(
Icons.book,
size: 120,
color: Colors.grey,
),
),
),
),
const SizedBox(width: 16),
// 書籍タイトルと著者表示
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
data.volumeInfo.title,
style: Theme.of(context).textTheme.titleSmall,
),
Text(
data.volumeInfo.authors.join(', '),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
// Google Booksリンクボタン
IconButton(
icon: const Icon(Icons.open_in_new),
color: Colors.grey,
onPressed: () {
final infoUrl =
'https://books.google.com/books?id=${data.id}';
launchUrl(Uri.parse(infoUrl));
},
),
],
),
);
},
separatorBuilder: (BuildContext context, int index) =>
const Divider(),
itemCount: items.length,
),
);
},
// エラー時の表示
error: (e, _) => ErrorMessage(
message: e.toString(),
onTapRetry: () async {
ref.invalidate(searchBooksControllerProvider(query));
},
),
// ローディング中の表示
loading: () => const Center(
child: CupertinoActivityIndicator(),
),
),
);
}
}
コードの補足
searchBooks.when
メソッド
- 取得したデータの状態(成功、エラー、ローディング)に応じて UI を切り替えています。
onRefresh
と onLoading
メソッド
- プルダウンでリフレッシュし、追加読み込みのためのメソッドを提供しています。これにより、ユーザーがリストの最下部にスクロールした際に追加データを読み込むことができます。
3. 動作確認
プロジェクトをビルドし、実際に検索ページの検索フィールドで入力して、その入力内容をもとに Google Books API からデータを取得して表示できるか確認します。
おわりに
この記事では、Flutter アプリケーションで Google Books API を利用した書籍検索機能の実装方法についてまとめました。
以降は、検索結果には「+本棚登録」ボタンを追加し、ユーザーが書籍データを登録できる機能を実装する予定です。
ありがとうございました。