Flutterアプリケーション開発概論

【検索機能】TextFieldに入力した文字で作品をリアルタイム検索する

24NETFLIX風動画アプリを作りながらUI・検索・動画再生・共有機能を学ぶFlutter(iOS・Android)アプリ開発
FlutteriOSAndroidMacOSWindows基礎から学ぶ開発アプリ開発

この節で学ぶこと

前の節では、sharePositionOrigin を指定して、iOSの共有画面で起きやすい PlatformException を防ぐ方法を学びました。

今回の節では、Search 画面を作ります。

TextField に文字を入力すると、その文字に合う作品だけがリアルタイムで表示される検索機能です。

Search画面を開く
↓
TextFieldに文字を入力する
↓
入力された文字をstateで管理する
↓
作品リストを絞り込む
↓
検索結果だけを表示する

たとえば、検索欄に squid と入力すると、タイトルや説明文、カテゴリなどに squid が含まれる作品だけが表示されます。

検索機能が入ると、アプリが一気に「見るだけの画面」から「探せるアプリ」になります。


今回作るSearch画面の完成イメージ

今回のSearch画面は、次のような構成です。

┌────────────────────┐
│ Search             │
│ ┌────────────────┐ │
│ │ Search title... │ │
│ └────────────────┘ │
│                    │
│ 検索結果             │
│ ┌────────────────┐ │
│ │ 画像  作品名      │ │
│ │      説明文       │ │
│ └────────────────┘ │
│ ┌────────────────┐ │
│ │ 画像  作品名      │ │
│ │      説明文       │ │
│ └────────────────┘ │
└────────────────────┘

上部に検索入力欄を置き、その下に検索結果を表示します。

入力が空のときは、作品一覧を表示してもよいですし、「検索してください」という案内を表示してもよいです。

今回の教材では、入力が空のときは作品一覧を表示し、文字を入力したらリアルタイムで絞り込む形にします。


今回見るコード

Search画面は、SearchPage という StatefulWidget で作ります。

class SearchPage extends StatefulWidget {
  const SearchPage({super.key});

  @override
  State<SearchPage> createState() => _SearchPageState();
}

検索欄に入力した文字によって画面が変わるので、StatefulWidget を使います。

入力文字が変わる
↓
検索結果が変わる
↓
画面を更新する必要がある
↓
StatefulWidgetを使う

SearchPage全体のコード

まずは、Search画面全体のコードを見てみましょう。

class SearchPage extends StatefulWidget {
  const SearchPage({super.key});

  @override
  State<SearchPage> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {
  String query = '';

  List<MovieItem> get filteredMovies {
    final keyword = query.trim().toLowerCase();

    if (keyword.isEmpty) {
      return movies;
    }

    return movies.where((movie) {
      final title = movie.title.toLowerCase();
      final subtitle = movie.subtitle.toLowerCase();
      final description = movie.description.toLowerCase();
      final category = movie.category.toLowerCase();

      return title.contains(keyword) ||
          subtitle.contains(keyword) ||
          description.contains(keyword) ||
          category.contains(keyword);
    }).toList();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: NetflixColors.black,
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Text(
                'Search',
                style: TextStyle(
                  color: NetflixColors.white,
                  fontSize: 30,
                  fontWeight: FontWeight.w900,
                ),
              ),
              const SizedBox(height: 18),
              TextField(
                onChanged: (value) {
                  setState(() {
                    query = value;
                  });
                },
                style: const TextStyle(
                  color: NetflixColors.white,
                  fontSize: 16,
                  fontWeight: FontWeight.w600,
                ),
                cursorColor: NetflixColors.red,
                decoration: InputDecoration(
                  hintText: 'Search titles, genres, stories...',
                  hintStyle: TextStyle(
                    color: NetflixColors.white.withValues(alpha: 0.45),
                  ),
                  prefixIcon: const Icon(
                    Icons.search,
                    color: NetflixColors.white,
                  ),
                  filled: true,
                  fillColor: NetflixColors.darkGray,
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(8),
                    borderSide: BorderSide.none,
                  ),
                ),
              ),
              const SizedBox(height: 18),
              Expanded(
                child: filteredMovies.isEmpty
                    ? const EmptySearchResult()
                    : ListView.separated(
                        itemCount: filteredMovies.length,
                        separatorBuilder: (context, index) {
                          return const SizedBox(height: 12);
                        },
                        itemBuilder: (context, index) {
                          final movie = filteredMovies[index];

                          return SearchResultTile(movie: movie);
                        },
                      ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

少し長く見えますが、大きく分けると次の4つです。

部分役割
query入力された検索文字を保存する
filteredMovies検索文字に合う作品だけを返す
TextField検索文字を入力する
ListView.separated検索結果を一覧表示する

まずは、検索文字を管理するところから見ていきます。


queryで入力文字を管理する

検索欄に入力された文字は、query という変数で管理します。

String query = '';

最初は何も入力されていないので、空文字です。

query = ''

検索欄に squid と入力すると、query には次のような文字が入ります。

query = 'squid'

この query をもとに、作品リストを絞り込みます。


なぜStatefulWidgetが必要なのか

検索機能では、ユーザーが文字を入力するたびに画面が変わります。

s と入力
↓
s を含む作品を表示

sq と入力
↓
sq を含む作品を表示

squid と入力
↓
squid を含む作品を表示

このように、入力によって画面が変わるため、状態管理が必要です。

今回の状態は、検索文字である query です。

String query = '';

この query が変わるたびに、setState で画面を更新します。


TextFieldとは?

TextField は、ユーザーが文字を入力するためのWidgetです。

TextField(
  onChanged: (value) {
    setState(() {
      query = value;
    });
  },
)

アプリの検索欄、ログイン画面のメールアドレス入力、メモアプリの本文入力など、文字を入力する場面でよく使います。

今回のSearch画面では、検索キーワードを入力するために使っています。


onChangedとは?

TextField の中で大切なのが、onChanged です。

onChanged: (value) {
  setState(() {
    query = value;
  });
},

onChanged は、入力欄の文字が変わるたびに呼ばれる処理です。

たとえば、ユーザーが squid と入力する場合、だいたい次のように動きます。

s を入力
↓
onChangedが呼ばれる

q を入力
↓
onChangedが呼ばれる

u を入力
↓
onChangedが呼ばれる

つまり、リアルタイム検索を作るときにとても便利です。


valueには何が入るのか

onChanged(value) には、現在の入力欄の文字が入ります。

onChanged: (value) {
  ...
}

たとえば、検索欄に squid と入力されている場合、value は次のようになります。

value = 'squid'

この valuequery に入れます。

query = value;

これで、入力された文字を状態として保存できます。


setStateで検索結果を更新する

入力文字が変わったら、setState の中で query を更新します。

setState(() {
  query = value;
});

setState を使う理由は、画面を再描画するためです。

TextFieldに文字を入力する
↓
queryが変わる
↓
setStateで画面更新を知らせる
↓
filteredMoviesが再計算される
↓
検索結果が変わる

もし setState を使わずに query = value; とだけ書くと、内部の値は変わっても画面が更新されないことがあります。


TextFieldの文字色を設定する

検索欄に入力する文字の見た目は、style で設定しています。

style: const TextStyle(
  color: NetflixColors.white,
  fontSize: 16,
  fontWeight: FontWeight.w600,
),

黒背景のアプリなので、入力文字は白にしています。

color: NetflixColors.white,

文字サイズは 16、少し太めにしています。

fontSize: 16,
fontWeight: FontWeight.w600,

cursorColorでカーソルの色を変える

入力欄のカーソル色は、cursorColor で指定しています。

cursorColor: NetflixColors.red,

カーソルとは、入力中の位置を示す縦線です。

Search titles|

ここでは、アプリのアクセントカラーである赤にしています。

小さな部分ですが、こうした色を統一すると、アプリ全体の雰囲気が整います。


decorationで検索欄の見た目を作る

TextField の見た目は、decoration で作っています。

decoration: InputDecoration(
  hintText: 'Search titles, genres, stories...',
  hintStyle: TextStyle(
    color: NetflixColors.white.withValues(alpha: 0.45),
  ),
  prefixIcon: const Icon(
    Icons.search,
    color: NetflixColors.white,
  ),
  filled: true,
  fillColor: NetflixColors.darkGray,
  border: OutlineInputBorder(
    borderRadius: BorderRadius.circular(8),
    borderSide: BorderSide.none,
  ),
),

InputDecoration を使うと、ヒント文字、アイコン、背景色、枠線などをまとめて設定できます。


hintTextとは?

hintText は、入力前に薄く表示される案内文です。

hintText: 'Search titles, genres, stories...',

まだ何も入力していないときに、検索欄の中に表示されます。

Search titles, genres, stories...

ユーザーに「ここに何を入力すればいいか」を伝える役割があります。


hintStyleでヒント文字を薄くする

ヒント文字の見た目は、hintStyle で指定しています。

hintStyle: TextStyle(
  color: NetflixColors.white.withValues(alpha: 0.45),
),

白をそのまま使うと、入力済みの文字と同じ強さになってしまいます。

そこで、透明度を下げています。

withValues(alpha: 0.45)

これにより、ヒント文字が少し薄く表示されます。

入力文字:はっきり白
ヒント文字:薄い白

prefixIconで検索アイコンを表示する

検索欄の左側には、虫眼鏡アイコンを置いています。

prefixIcon: const Icon(
  Icons.search,
  color: NetflixColors.white,
),

prefixIcon は、入力欄の先頭にアイコンを表示するための指定です。

検索欄に虫眼鏡アイコンがあると、ユーザーはすぐに「ここは検索欄だ」と分かります。

🔍 Search titles, genres, stories...

filledとfillColorで背景色をつける

検索欄には、背景色をつけています。

filled: true,
fillColor: NetflixColors.darkGray,

filled: true にすると、入力欄の背景を塗れるようになります。

fillColor で、その色を指定します。

fillColor: NetflixColors.darkGray,

黒背景の中に、少し明るいグレーの検索欄を置くことで、入力欄が分かりやすくなります。


borderで角丸の検索欄にする

検索欄の形は、border で指定しています。

border: OutlineInputBorder(
  borderRadius: BorderRadius.circular(8),
  borderSide: BorderSide.none,
),

OutlineInputBorder は、入力欄の枠の形を作るためのものです。

今回は、角を少し丸めています。

borderRadius: BorderRadius.circular(8),

枠線は表示しないので、次のようにしています。

borderSide: BorderSide.none,

これにより、Netflix風のシンプルな検索バーになります。


filteredMoviesとは?

検索結果を作っているのが、filteredMovies です。

List<MovieItem> get filteredMovies {
  final keyword = query.trim().toLowerCase();

  if (keyword.isEmpty) {
    return movies;
  }

  return movies.where((movie) {
    final title = movie.title.toLowerCase();
    final subtitle = movie.subtitle.toLowerCase();
    final description = movie.description.toLowerCase();
    final category = movie.category.toLowerCase();

    return title.contains(keyword) ||
        subtitle.contains(keyword) ||
        description.contains(keyword) ||
        category.contains(keyword);
  }).toList();
}

これは、query に合う作品だけを返す処理です。

get を使っているので、filteredMovies と書くだけで、現在の検索文字に合わせた結果を取得できます。


getとは?

filteredMovies は、普通の変数ではなく、getterです。

List<MovieItem> get filteredMovies {
  ...
}

getterは、見た目は変数のように使えますが、中では処理を実行できます。

たとえば、次のように書けます。

filteredMovies.length

しかし実際には、query をもとに作品リストを絞り込む処理が動いています。

filteredMovies と書く
↓
中の処理が実行される
↓
検索結果のListが返る

trimで前後の空白を消す

検索キーワードは、最初に整えています。

final keyword = query.trim().toLowerCase();

まず、trim() で前後の空白を消しています。

query.trim()

たとえば、ユーザーが間違えて前後に空白を入れても、

'  squid  '

trim() を使うと、こうなります。

'squid'

これにより、余計な空白のせいで検索できない問題を防げます。


toLowerCaseで大文字小文字をそろえる

次に、toLowerCase() で小文字に変換しています。

query.trim().toLowerCase()

たとえば、ユーザーが SQUID と入力した場合でも、

'SQUID'

小文字に変換すると、

'squid'

になります。

作品タイトル側も小文字に変換して比較することで、大文字小文字を気にせず検索できます。


入力が空なら全作品を返す

検索文字が空の場合は、全作品を返しています。

if (keyword.isEmpty) {
  return movies;
}

つまり、Search画面を開いた直後は、すべての作品が表示されます。

検索欄が空
↓
全作品を表示

この方が、何も入力していない状態でも画面が寂しくなりません。

別の設計として、「検索してください」という案内だけを表示する方法もありますが、今回のアプリでは一覧表示にしています。


whereで条件に合う作品だけを取り出す

検索文字が空でない場合は、where を使って作品を絞り込みます。

return movies.where((movie) {
  ...
}).toList();

where は、条件に合うものだけを取り出すためのメソッドです。

moviesの中から
↓
条件に合う作品だけを残す
↓
Listにする

たとえば、squid というキーワードが入っている作品だけを残すイメージです。


検索対象を小文字にする

where の中では、作品側の情報も小文字にしています。

final title = movie.title.toLowerCase();
final subtitle = movie.subtitle.toLowerCase();
final description = movie.description.toLowerCase();
final category = movie.category.toLowerCase();

検索キーワードも小文字にしているので、作品側も小文字にして比較します。

検索キーワード:squid
作品タイトル:Squid Game → squid game
↓
大文字小文字を気にせず比較できる

これにより、SquidSQUIDsquid のどれで検索しても見つかります。


containsで文字が含まれるか調べる

実際に検索しているのは、contains の部分です。

return title.contains(keyword) ||
    subtitle.contains(keyword) ||
    description.contains(keyword) ||
    category.contains(keyword);

contains は、文字列の中に指定した文字が含まれているかを調べるメソッドです。

たとえば、次のようになります。

'squid game'.contains('squid') // true
'wednesday'.contains('squid')  // false

true なら検索結果に残ります。

false なら検索結果から外れます。


|| は「または」

検索条件の中には、|| が出てきます。

title.contains(keyword) ||
subtitle.contains(keyword) ||
description.contains(keyword) ||
category.contains(keyword)

|| は「または」という意味です。

つまり、次のどれか1つでも当てはまれば検索結果に表示します。

タイトルに含まれる
または
サブタイトルに含まれる
または
説明文に含まれる
または
カテゴリに含まれる

これにより、タイトルだけでなく、作品説明やカテゴリからも検索できるようになります。


ここまでのまとめ

ここまでで、Search画面の入力欄と検索ロジックを確認しました。

大切なポイントは次の通りです。

  • 検索画面は、入力によって表示が変わるので StatefulWidget にする。
  • 入力文字は String query = ''; で管理する。
  • TextField は、ユーザーが文字を入力するためのWidget。
  • onChanged は、入力文字が変わるたびに呼ばれる。
  • setState の中で query = value; と書くと、検索結果をリアルタイムで更新できる。
  • InputDecoration を使うと、検索欄のヒント文字、アイコン、背景色、枠線を設定できる。
  • filteredMovies は、現在の query に合う作品だけを返すgetter。
  • trim() で前後の空白を消せる。
  • toLowerCase() で大文字小文字をそろえられる。
  • where を使うと、条件に合う作品だけを取り出せる。
  • contains を使うと、文字列の中に検索文字が含まれているか調べられる。
  • || を使うと、タイトル・サブタイトル・説明文・カテゴリのどれかに一致した作品を表示できる。

検索結果を画面に表示する

ここからは、filteredMovies で絞り込んだ作品を、画面に表示する部分を見ていきます。

検索結果の表示には、ExpandedListView.separated を使っています。

Expanded(
  child: filteredMovies.isEmpty
      ? const EmptySearchResult()
      : ListView.separated(
          itemCount: filteredMovies.length,
          separatorBuilder: (context, index) {
            return const SizedBox(height: 12);
          },
          itemBuilder: (context, index) {
            final movie = filteredMovies[index];

            return SearchResultTile(movie: movie);
          },
        ),
),

この部分では、検索結果がある場合と、検索結果がない場合で表示を切り替えています。

検索結果がある
↓
ListViewで作品一覧を表示

検索結果がない
↓
EmptySearchResultを表示

Expandedで残りの高さを使う

検索結果エリアは、Expanded で包まれています。

Expanded(
  child: ...
),

Search画面は、上から順番に次のような構成になっています。

Searchタイトル
↓
検索欄
↓
検索結果一覧

タイトルと検索欄の高さは、ある程度決まっています。

その下の残りのスペースを、検索結果一覧に使いたいので、Expanded を使っています。

画面の残り高さ
↓
検索結果一覧に使う

Column の中で、下の一覧部分を広げたいときに Expanded はよく使います。


filteredMovies.isEmptyで結果があるか確認する

検索結果が空かどうかは、次のコードで確認しています。

filteredMovies.isEmpty

isEmpty は、リストが空かどうかを調べるプロパティです。

状態filteredMovies.isEmpty
検索結果が0件true
検索結果が1件以上false

この結果を使って、表示内容を切り替えています。

filteredMovies.isEmpty
    ? const EmptySearchResult()
    : ListView.separated(...)

これは三項演算子です。

意味は次の通りです。

検索結果が空なら EmptySearchResult を表示する
そうでなければ ListView.separated を表示する

EmptySearchResultとは?

EmptySearchResult は、検索結果が0件だったときに表示するWidgetです。

class EmptySearchResult extends StatelessWidget {
  const EmptySearchResult({super.key});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(
        'No titles found.',
        style: TextStyle(
          color: NetflixColors.white.withValues(alpha: 0.58),
          fontSize: 15,
          fontWeight: FontWeight.w700,
        ),
      ),
    );
  }
}

検索結果がないときに、何も表示されないとユーザーは不安になります。

検索結果がない
↓
画面が空白
↓
エラーなのか、0件なのか分かりにくい

そこで、No titles found. と表示しています。


Centerで中央に表示する

EmptySearchResult の中では、Center を使っています。

return Center(
  child: Text(
    'No titles found.',
    ...
  ),
);

Center は、子Widgetを中央に配置するWidgetです。

検索結果がないときのメッセージは、画面の中央に出した方が分かりやすくなります。

画面中央
↓
No titles found.

ListView.separatedで検索結果を並べる

検索結果がある場合は、ListView.separated を使います。

ListView.separated(
  itemCount: filteredMovies.length,
  separatorBuilder: (context, index) {
    return const SizedBox(height: 12);
  },
  itemBuilder: (context, index) {
    final movie = filteredMovies[index];

    return SearchResultTile(movie: movie);
  },
)

ListView.separated は、リストの項目と項目の間に区切りを入れやすいWidgetです。

今回の場合は、項目の間に SizedBox(height: 12) を入れています。

検索結果1
↓ 12px余白
検索結果2
↓ 12px余白
検索結果3

作品カード同士がくっつきすぎないようにしています。


itemCountで表示件数を決める

itemCount には、検索結果の件数を指定しています。

itemCount: filteredMovies.length,

filteredMovies.length は、検索結果の数です。

たとえば、検索結果が3件なら、itemCount は3になります。

filteredMovies.length = 3
↓
3件分のSearchResultTileを作る

この指定があることで、検索結果の件数分だけリスト項目を表示できます。


separatorBuilderで余白を作る

項目と項目の間は、separatorBuilder で作っています。

separatorBuilder: (context, index) {
  return const SizedBox(height: 12);
},

ここでは、縦方向に12の余白を入れています。

もし余白を広げたい場合は、次のように変更できます。

return const SizedBox(height: 18);

検索結果のカードをゆったり見せたい場合は、余白を少し広げると見やすくなります。


itemBuilderで検索結果を作る

実際に検索結果の1件分を作っているのが、itemBuilder です。

itemBuilder: (context, index) {
  final movie = filteredMovies[index];

  return SearchResultTile(movie: movie);
},

index は、何番目の検索結果を作るかを表します。

index = 0 → 1件目
index = 1 → 2件目
index = 2 → 3件目

その番号を使って、filteredMovies から作品を取り出します。

final movie = filteredMovies[index];

そして、その作品を SearchResultTile に渡します。

SearchResultTile(movie: movie)

SearchResultTileとは?

SearchResultTile は、検索結果1件分の見た目を作るWidgetです。

class SearchResultTile extends StatelessWidget {
  const SearchResultTile({
    super.key,
    required this.movie,
  });

  final MovieItem movie;

movie を受け取り、その作品の画像、タイトル、説明文、メタ情報を表示します。

検索結果の1行分を部品化しているイメージです。

SearchPage
↓
ListView.separated
↓
SearchResultTile
↓
作品1件分の表示

部品化しておくと、コードが読みやすくなります。


SearchResultTileのコード

SearchResultTile は、次のようなコードです。

class SearchResultTile extends StatelessWidget {
  const SearchResultTile({
    super.key,
    required this.movie,
  });

  final MovieItem movie;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.of(context).push(
          MaterialPageRoute<void>(
            builder: (context) => MovieDetailPage(movie: movie),
          ),
        );
      },
      child: Container(
        decoration: BoxDecoration(
          color: NetflixColors.darkGray,
          borderRadius: BorderRadius.circular(10),
        ),
        padding: const EdgeInsets.all(10),
        child: Row(
          children: [
            ClipRRect(
              borderRadius: BorderRadius.circular(6),
              child: Image.network(
                movie.posterUrl,
                width: 78,
                height: 112,
                fit: BoxFit.cover,
              ),
            ),
            const SizedBox(width: 13),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    movie.title,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      color: NetflixColors.white,
                      fontSize: 16,
                      fontWeight: FontWeight.w900,
                    ),
                  ),
                  const SizedBox(height: 5),
                  Text(
                    '${movie.matchRate}% Match • ${movie.year} • ${movie.rating}',
                    style: TextStyle(
                      color: NetflixColors.white.withValues(alpha: 0.72),
                      fontSize: 12,
                      fontWeight: FontWeight.w700,
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    movie.description,
                    maxLines: 3,
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(
                      color: NetflixColors.white.withValues(alpha: 0.62),
                      fontSize: 12.5,
                      height: 1.35,
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

このWidgetは、検索結果カードを押すと詳細画面に移動できるようになっています。


GestureDetectorでカード全体をタップできるようにする

SearchResultTile は、GestureDetector で包まれています。

return GestureDetector(
  onTap: () {
    Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (context) => MovieDetailPage(movie: movie),
      ),
    );
  },
  child: Container(
    ...
  ),
);

これにより、検索結果カード全体をタップできます。

検索結果カードをタップ
↓
MovieDetailPageへ移動

検索結果から作品詳細に移動できると、アプリとして自然な導線になります。


カードを押したときは、Navigator.of(context).push で詳細画面に移動します。

Navigator.of(context).push(
  MaterialPageRoute<void>(
    builder: (context) => MovieDetailPage(movie: movie),
  ),
);

ここでも、movie を渡しています。

MovieDetailPage(movie: movie)

これにより、検索結果で選んだ作品の詳細画面を表示できます。

SearchResultTileでSquid Gameをタップ
↓
MovieDetailPage(movie: Squid Game)
↓
Squid Gameの詳細画面を表示

このように、作品データを次の画面へ渡す流れは、これまでの教材と同じです。


Containerでカードの背景を作る

検索結果カードの背景は、Container で作っています。

Container(
  decoration: BoxDecoration(
    color: NetflixColors.darkGray,
    borderRadius: BorderRadius.circular(10),
  ),
  padding: const EdgeInsets.all(10),
  child: Row(
    ...
  ),
)

背景色は、少し明るい黒系の NetflixColors.darkGray です。

color: NetflixColors.darkGray,

角は少し丸めています。

borderRadius: BorderRadius.circular(10),

真っ黒の背景の上に、少し明るいカードを置くことで、検索結果が見やすくなります。


paddingで内側の余白を作る

カードの内側には、余白を入れています。

padding: const EdgeInsets.all(10),

この余白がないと、画像や文字がカードの端にくっついてしまいます。

余白なし
↓
窮屈に見える

余白あり
↓
読みやすいカードになる

カードUIでは、内側の余白がとても大切です。


Rowで画像とテキストを横に並べる

カードの中では、Row を使っています。

child: Row(
  children: [
    ClipRRect(
      ...
    ),
    const SizedBox(width: 13),
    Expanded(
      child: Column(
        ...
      ),
    ),
  ],
),

左にポスター画像、右に作品情報を並べています。

画像  タイトル
      メタ情報
      説明文

検索結果では、一覧性が大切です。

ポスター画像と文字情報を横に並べると、作品をすばやく確認できます。


ClipRRectで画像の角を丸める

ポスター画像は、ClipRRect で包んでいます。

ClipRRect(
  borderRadius: BorderRadius.circular(6),
  child: Image.network(
    movie.posterUrl,
    width: 78,
    height: 112,
    fit: BoxFit.cover,
  ),
),

ClipRRect は、子Widgetを角丸に切り抜くためのWidgetです。

ここでは、画像の角を少し丸めています。

borderRadius: BorderRadius.circular(6),

検索結果カード自体も角丸なので、画像も少し角丸にすると統一感が出ます。


Image.networkでポスター画像を表示する

ポスター画像は、Image.network で表示しています。

Image.network(
  movie.posterUrl,
  width: 78,
  height: 112,
  fit: BoxFit.cover,
)

movie.posterUrl には、作品ごとのポスター画像URLが入っています。

movie.posterUrl

幅は78、高さは112にしています。

width: 78,
height: 112,

検索結果では、画像が大きすぎるとリストが見にくくなるので、ほどよいサイズにしています。


Expandedで文字エリアを広げる

右側の文字部分は、Expanded で包まれています。

Expanded(
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      ...
    ],
  ),
),

Expanded を使うことで、画像の右側に残った横幅を文字エリアとして使えます。

左:画像の固定幅
右:残りの横幅を使う文字エリア

検索結果のタイトルや説明文は長くなることがあるため、横幅をできるだけ使えるようにしています。


タイトルを1行で省略する

作品タイトルは、次のように表示しています。

Text(
  movie.title,
  maxLines: 1,
  overflow: TextOverflow.ellipsis,
  style: const TextStyle(
    color: NetflixColors.white,
    fontSize: 16,
    fontWeight: FontWeight.w900,
  ),
),

maxLines: 1 は、1行だけ表示する指定です。

overflow: TextOverflow.ellipsis は、入りきらない文字を ... で省略する指定です。

長いタイトル
↓
1行に収まらない
↓
途中で...

検索結果では、カードの高さをそろえたいので、タイトルは1行にしています。


メタ情報を表示する

タイトルの下には、作品のメタ情報を表示しています。

Text(
  '${movie.matchRate}% Match • ${movie.year} • ${movie.rating}',
  style: TextStyle(
    color: NetflixColors.white.withValues(alpha: 0.72),
    fontSize: 12,
    fontWeight: FontWeight.w700,
  ),
),

ここでは、次の情報を表示しています。

マッチ率
公開年
年齢制限

たとえば、次のような表示になります。

98% Match • 2021 • 18+

少し薄い白にして、タイトルよりも控えめに表示しています。


説明文を3行まで表示する

説明文は、最大3行まで表示しています。

Text(
  movie.description,
  maxLines: 3,
  overflow: TextOverflow.ellipsis,
  style: TextStyle(
    color: NetflixColors.white.withValues(alpha: 0.62),
    fontSize: 12.5,
    height: 1.35,
  ),
),

検索結果のカード内では、説明文が長くなりすぎないようにします。

maxLines: 3,
overflow: TextOverflow.ellipsis,

これにより、読みやすさと一覧性のバランスを取っています。


検索画面の流れを整理する

ここまでの流れを整理すると、次のようになります。

TextFieldに文字を入力
↓
onChangedが呼ばれる
↓
setStateでqueryを更新
↓
filteredMoviesが再計算される
↓
検索結果が0件か確認
↓
0件ならEmptySearchResult
↓
1件以上ならListView.separated
↓
SearchResultTileで1件ずつ表示
↓
カードをタップするとMovieDetailPageへ移動

検索機能は、入力欄だけでは完成しません。

入力された文字を状態として持ち、その状態をもとにリストを作り直し、画面に表示することで完成します。


まずカスタマイズしてみよう

まずは、検索対象に seasonLabel も追加してみましょう。

現在の検索対象は、次の4つです。

final title = movie.title.toLowerCase();
final subtitle = movie.subtitle.toLowerCase();
final description = movie.description.toLowerCase();
final category = movie.category.toLowerCase();

ここに seasonLabel を追加します。

final seasonLabel = movie.seasonLabel.toLowerCase();

そして、検索条件にも追加します。

return title.contains(keyword) ||
    subtitle.contains(keyword) ||
    description.contains(keyword) ||
    category.contains(keyword) ||
    seasonLabel.contains(keyword);

これで、Season 1 のような情報からも検索できるようになります。


検索欄のヒント文字を変えてみよう

次に、検索欄のヒント文字を変えてみます。

現在は、次のようになっています。

hintText: 'Search titles, genres, stories...',

日本語にしたい場合は、次のようにできます。

hintText: '作品名・ジャンル・説明文で検索',

授業用のアプリでは、日本語の方が分かりやすい場合もあります。


検索結果がないときの文言を変えてみよう

EmptySearchResult の文言も変えられます。

現在は、次のようになっています。

'No titles found.'

日本語にするなら、次のようにできます。

'該当する作品が見つかりませんでした。'

ユーザーに伝わりやすい文言にすると、アプリの印象がよくなります。


よくあるつまずきポイント

Q. 入力しても検索結果が変わりません。

onChanged の中で、setState を使っているか確認してください。

onChanged: (value) {
  setState(() {
    query = value;
  });
},

query = value; だけだと、画面が更新されないことがあります。


Q. 大文字で検索すると見つかりません。

検索キーワードと作品情報の両方を小文字にしているか確認してください。

final keyword = query.trim().toLowerCase();
final title = movie.title.toLowerCase();

片方だけ小文字にしても、正しく比較できない場合があります。


Q. 空白を入れると検索できません。

trim() を使って、前後の空白を消しているか確認してください。

final keyword = query.trim().toLowerCase();

これにより、' squid ' のような入力でも、'squid' として検索できます。


Q. 検索結果が0件のときに画面が真っ黒になります。

filteredMovies.isEmpty のときに、EmptySearchResult を表示しているか確認してください。

child: filteredMovies.isEmpty
    ? const EmptySearchResult()
    : ListView.separated(
        ...
      ),

0件のときにメッセージを出すと、ユーザーが状況を理解しやすくなります。


Q. 検索結果をタップしても詳細画面に移動しません。

SearchResultTile の中で、GestureDetectorNavigator が入っているか確認してください。

GestureDetector(
  onTap: () {
    Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (context) => MovieDetailPage(movie: movie),
      ),
    );
  },
  child: Container(
    ...
  ),
)

検索結果カード全体をタップできるようにしておくと、操作しやすくなります。


チャレンジ

チャレンジ1:検索対象にseasonLabelを追加しよう

次のコードに、

final title = movie.title.toLowerCase();
final subtitle = movie.subtitle.toLowerCase();
final description = movie.description.toLowerCase();
final category = movie.category.toLowerCase();

次の1行を追加します。

final seasonLabel = movie.seasonLabel.toLowerCase();

そして、検索条件に次を追加します。

seasonLabel.contains(keyword)

チャレンジ2:検索欄のヒント文字を日本語にしよう

次のコードを探してください。

hintText: 'Search titles, genres, stories...',

これを次のように変更します。

hintText: '作品名・ジャンル・説明文で検索',

検索欄の案内文が日本語になるか確認してください。


チャレンジ3:検索結果の説明文を2行までにしよう

次のコードを探してください。

maxLines: 3,

説明文の方の maxLines を、次のように変更します。

maxLines: 2,

検索結果カードが少しコンパクトになります。


チャレンジ4:検索結果がないときの文言を日本語にしよう

次のコードを探してください。

'No titles found.'

これを次のように変更します。

'該当する作品が見つかりませんでした。'

検索結果が0件のとき、日本語のメッセージが表示されます。


チャレンジの答え

チャレンジ1の答え

変更前:

final title = movie.title.toLowerCase();
final subtitle = movie.subtitle.toLowerCase();
final description = movie.description.toLowerCase();
final category = movie.category.toLowerCase();

return title.contains(keyword) ||
    subtitle.contains(keyword) ||
    description.contains(keyword) ||
    category.contains(keyword);

変更後:

final title = movie.title.toLowerCase();
final subtitle = movie.subtitle.toLowerCase();
final description = movie.description.toLowerCase();
final category = movie.category.toLowerCase();
final seasonLabel = movie.seasonLabel.toLowerCase();

return title.contains(keyword) ||
    subtitle.contains(keyword) ||
    description.contains(keyword) ||
    category.contains(keyword) ||
    seasonLabel.contains(keyword);

Season 1 などの情報からも検索できるようになります。


チャレンジ2の答え

変更前:

hintText: 'Search titles, genres, stories...',

変更後:

hintText: '作品名・ジャンル・説明文で検索',

検索欄のヒント文字が日本語になります。


チャレンジ3の答え

変更前:

maxLines: 3,

変更後:

maxLines: 2,

説明文が最大2行まで表示され、検索結果カードが少しコンパクトになります。


チャレンジ4の答え

変更前:

'No titles found.'

変更後:

'該当する作品が見つかりませんでした。'

検索結果が0件のとき、日本語のメッセージが表示されます。


この節のまとめ

この節では、TextField に入力した文字で作品をリアルタイム検索する方法を学びました。

大切なポイントは次の通りです。

  • 検索画面は、入力文字によって表示が変わるため StatefulWidget にする。
  • query に、現在の検索文字を保存する。
  • TextField は、ユーザーが文字を入力するためのWidget。
  • onChanged は、入力文字が変わるたびに呼ばれる。
  • setStatequery を更新すると、画面が再描画される。
  • filteredMovies で、検索条件に合う作品だけを返す。
  • trim() を使うと、前後の空白を削除できる。
  • toLowerCase() を使うと、大文字小文字をそろえられる。
  • where を使うと、条件に合うデータだけを取り出せる。
  • contains を使うと、文字列に検索キーワードが含まれるか確認できる。
  • || を使うと、タイトル・サブタイトル・説明文・カテゴリのどれかに一致した作品を表示できる。
  • ListView.separated を使うと、検索結果を余白つきで一覧表示できる。
  • EmptySearchResult を用意すると、検索結果が0件のときも分かりやすい。
  • SearchResultTile を作ると、検索結果1件分の表示を部品化できる。
  • 検索結果カードをタップすると、MovieDetailPage(movie: movie) に移動できる。

次のステップ

次の節では、My Netflix画面を作ります。

プロフィール画像、通知、ダウンロード、My Listなどをまとめて表示し、アカウント画面らしいUIを作っていきます。

BottomNavigationの最後のタブとして、アプリ全体の完成度を高める画面になります。

教材トップへ戻る