5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

タイトルなし.gif

はじめに

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

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

image.png

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

前提

この記事では、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 件ずつ検索結果が追加表示される

ページ遷移

ページ下部のタブ(常時表示)

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

image.png

検索ページ

  • 検索フィールド
    • 検索テキスト削除ボタン
  • 検索ボタン
    • 画面には表示させない(キーボードの決定ボタン=検索ボタン)

image.png image.png

検索結果ページ

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

image.png image.png

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

2. アプリでの実装

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

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

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

編集: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. ページ下部のタブ(メインページ)ウィジェットの編集

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

編集: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> の設定

        (
          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. 検索ページウィジェットの作成

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

作成: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 メソッド

    // 検索を実行し、結果を表示するページへ遷移
    void searchBooks() {
      if (searchController.text.isNotEmpty) {
        Navigator.of(context).push(
          MaterialPageRoute<SearchedBooksPage>(
            builder: (context) =>
                SearchedBooksPage(query: searchController.text),
          ),
        );
      }
    }
  • ユーザーが検索キーワードを入力し、検索ボタンをタップした際に、SearchedBooksPage へ遷移するためのメソッドです。キーワードが空でない場合のみ遷移を実行します。

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

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

編集: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 を切り替えています。

onRefreshonLoading メソッド

  • プルダウンでリフレッシュし、追加読み込みのためのメソッドを提供しています。これにより、ユーザーがリストの最下部にスクロールした際に追加データを読み込むことができます。

3. 動作確認

プロジェクトをビルドし、実際に検索ページの検索フィールドで入力して、その入力内容をもとに Google Books API からデータを取得して表示できるか確認します。

タイトルなし.gif

おわりに

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

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

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

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?