FlutterでのGoogle Books APIを利用した書籍検索機能の実装 2/2

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527226/01bf9d29-1a8d-eb51-1178-5298d9a6ef20.gif)

# はじめに

Flutter アプリケーションで Google Books API を利用して、書籍検索機能の実装についてまとめます。

前回は Google Books API で書籍データを取得して、取得結果を表示させました。

https://qiita.com/ryamate/items/ae86e312ad0e14bb0e7f

<img width="200" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527226/790c3f1d-5436-6098-90dd-9796cb6b58b3.png">

現状は「ノラネコぐんだん」というキーワードで固定して表示していますが、今回はユーザーが検索ページの検索フィールドにキーワードを入力したら、そのキーワードを基に書籍を検索し、データ取得し検索結果ページが開くようにします。

## 前提

この記事では、**hukusuke1007 / [flutter_app_template](https://github.com/hukusuke1007/flutter_app_template)** をベースにして開発しています。

https://github.com/hukusuke1007/flutter_app_template

Flutter + Firebase アプリのスターターキットになっており、サンプル機能がとても充実しており、今回の Google Books API を利用した書籍検索機能もテンプレートをベースにしています。

- **hukusuke1007 / [flutter_app_template](https://github.com/hukusuke1007/flutter_app_template)** の導入（`git clone` から `flutter run` まで）が完了していること

https://qiita.com/ryamate/items/f50aa769dbc34a39fa64

## 目次

[1. ゴールの確認 - ページ遷移](#1-ゴールの確認)
[2. アプリでの実装](#2-アプリでの実装)
　[2-1. コントローラの編集](#2-1-コントローラの編集)
　[2-2. ページ下部のタブウィジェットの編集](#2-2-ページ下部のタブメインページウィジェットの編集)
　[2-3. 検索ページウィジェットの作成](#2-3-検索ページウィジェットの作成)
　[2-4. 検索結果ページウィジェットの作成](#2-4-検索結果ページウィジェットの作成)
[3. 動作確認](#3-動作確認)

# 1. ゴールの確認

実装する前に、ゴールの確認をします。

今回は、以下のような動作を実現します。

- 検索タブをタップすると、検索ページが開く
- 検索ページで検索フィールドにキーワードを入力し、エンターキーを押すとデータが取得され、検索結果ページが開く
- 検索結果ページで、スクロールすることで +20 件ずつ検索結果が追加表示される

## ページ遷移

### ページ下部のタブ（常時表示）

- **検索（みつける）タブ**
    - 検索タブをタップすると、検索ページへ遷移
    - 検索結果ページが開いている場合は、検索ページに戻る

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527226/f28dcf8b-933a-dde0-4037-bbe276ccedfb.png)

↓

### **検索ページ**

- 検索フィールド
    - 検索テキスト削除ボタン
- 検索ボタン
    - 画面には表示させない（キーボードの決定ボタン＝検索ボタン）

<img width="180" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527226/d278dbb0-19a9-a035-6131-8c3a3fdcd889.png">　<img width="180" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527226/5e07970e-00f4-04ea-f7d5-1c114ddbdc18.png">
↓

### **検索結果ページ**

- 戻るボタン
- 検索フィールド
    - 検索テキスト削除ボタン
- 検索ボタン
    - 画面には表示させない（キーボードの決定ボタン＝検索ボタン）
- 検索結果を表示
    - Google Books API でデータ取得
    - スクロールで+20件ずつ追加表示

<img width="180" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527226/959c51c5-4397-ac0d-9d75-a4376fa06e49.png">　<img width="180" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527226/682f70eb-4fec-4ea9-dc98-82a287b502a7.png">

今後の予定としては、検索結果のリストに「＋本棚登録」ボタンを追加し、書籍データを登録する機能を実装することを考えています。

# 2. アプリでの実装

実際のコードを編集して機能を追加する方法を説明します。

## 2-1. コントローラの編集

まずは、検索を実行するためのコントローラを編集します。ユーザーがキーワードを入力した際にデータを動的に取得するようにします。

```diff_dart:編集：lib/features/search/use_cases/search_books_controller.dart
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. ページ下部のタブ（メインページ）ウィジェットの編集

次に、メインページでのタブ設定を編集します。

```dart:編集：lib/features/app_wrapper/pages/main_page.dart
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>` の設定**

```dart
        (
          GlobalKey<NavigatorState>(),
          StartSearchPage.pageName,
          const StartSearchPage()
        ),
```

- 各タブのナビゲーション状態を保持するために、`GlobalKey`を使用しています。これにより、ユーザーがタブを切り替えても各ページの状態が保持されます。

**`if (index == 2)` のロジック**

```dart
              if (index == 2) {
                // "みつける"タブが再度タップされた場合
                final navigator = widgets[index].$1.currentState!;
                if (navigator.canPop()) {
                  navigator.popUntil((route) => route.isFirst);
                }
              }
```

- 「みつける」タブが再度タップされた場合、現在のナビゲーションスタックのルートまで戻るように設定しています。これにより、ユーザーが「みつける」タブを再度タップすると、検索ページの最初の状態に戻ります。

**`NavigationDestination` の設定**

```dart
            NavigationDestination(
              selectedIcon: Icon(Icons.search),
              icon: Icon(Icons.search_outlined),
              label: 'みつける',
            ),
```

- 各タブのアイコンとラベルを設定します。「みつける」タブが選択されると、検索ページが表示されます。

## 2-3. 検索ページウィジェットの作成

検索ページの実装では、検索フィールドを用意し、ユーザーが入力したキーワードを元に検索を行い、結果を表示するためのページへ遷移させます。

```dart:作成：lib/features/search/pages/start_search_page.dart
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` メソッド**

```dart
    // 検索を実行し、結果を表示するページへ遷移
    void searchBooks() {
      if (searchController.text.isNotEmpty) {
        Navigator.of(context).push(
          MaterialPageRoute<SearchedBooksPage>(
            builder: (context) =>
                SearchedBooksPage(query: searchController.text),
          ),
        );
      }
    }
```

- ユーザーが検索キーワードを入力し、検索ボタンをタップした際に、`SearchedBooksPage` へ遷移するためのメソッドです。キーワードが空でない場合のみ遷移を実行します。

## 2-4. 検索結果ページウィジェットの作成

検索結果ページでは、Google Books API を用いて取得した書籍情報を表示し、スクロール操作で追加の結果を読み込むことができます。

```dart:編集：lib/features/search/pages/searched_books_page.dart
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 からデータを取得して表示できるか確認します。

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527226/01bf9d29-1a8d-eb51-1178-5298d9a6ef20.gif)

# おわりに

この記事では、Flutter アプリケーションで Google Books API を利用した書籍検索機能の実装方法についてまとめました。

以降は、検索結果には「＋本棚登録」ボタンを追加し、ユーザーが書籍データを登録できる機能を実装する予定です。

ありがとうございました。