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

【検索結果UI】該当作品の一覧と、見つからない時の表示を作る

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

この節で学ぶこと

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

今回の節では、その検索結果をどのように画面に表示するかを学びます。

検索機能では、文字を入力して作品を絞り込むだけでは不十分です。

検索結果があるときは、該当する作品を一覧で表示する必要があります。

検索結果がないときは、「見つかりませんでした」と分かりやすく表示する必要があります。

検索文字を入力する
↓
該当する作品を探す
↓
作品がある場合:一覧で表示する
↓
作品がない場合:見つからない表示を出す

この節では、ListView.separatedSearchResultTileEmptySearchResult を使って、検索結果UIを作っていきます。


今回作るUIの全体像

検索結果画面は、大きく分けると2つの状態があります。

状態表示するもの
検索結果がある該当作品の一覧
検索結果がない見つからない時のメッセージ

画面のイメージは、次のようになります。

検索結果がある場合

┌────────────────────┐
│ Search             │
│ [検索欄]             │
│                    │
│ ┌────────────────┐ │
│ │画像  作品名      │ │
│ │     メタ情報     │ │
│ │     説明文       │ │
│ └────────────────┘ │
│ ┌────────────────┐ │
│ │画像  作品名      │ │
│ │     メタ情報     │ │
│ │     説明文       │ │
│ └────────────────┘ │
└────────────────────┘

検索結果がない場合は、次のようにします。

検索結果がない場合

┌────────────────────┐
│ Search             │
│ [検索欄]             │
│                    │
│                    │
│  No titles found.  │
│                    │
└────────────────────┘

ユーザーにとって大切なのは、「検索できていない」のか「検索した結果0件だった」のかが分かることです。

そのため、0件のときも何かしらの表示を出します。


今回見るコード

検索結果UIで中心になるのは、次の3つです。

filteredMovies.isEmpty
ListView.separated
SearchResultTile
EmptySearchResult

Search画面の中では、次のように使っています。

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);
          },
        ),
),

このコードでは、検索結果が空かどうかによって、表示するWidgetを切り替えています。

filteredMovies.isEmpty が true
↓
EmptySearchResult を表示

filteredMovies.isEmpty が false
↓
ListView.separated を表示

Expandedで検索結果エリアを広げる

検索結果部分は、Expanded で包まれています。

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

Expanded は、Column の中で残りの高さを使うためのWidgetです。

Search画面は、上から順に次のような構成です。

Searchタイトル
↓
検索欄
↓
検索結果エリア

タイトルと検索欄は、高さがだいたい決まっています。

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

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

もし Expanded を使わないと、リストの高さがうまく決まらず、レイアウトエラーが出ることがあります。


filteredMovies.isEmptyで0件かどうかを判定する

検索結果があるかどうかは、filteredMovies.isEmpty で確認します。

filteredMovies.isEmpty

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

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

たとえば、検索結果が0件なら、true になります。

filteredMovies = []
↓
filteredMovies.isEmpty は true

検索結果が2件なら、false になります。

filteredMovies = [作品A, 作品B]
↓
filteredMovies.isEmpty は false

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


三項演算子で表示を切り替える

検索結果の表示切り替えには、三項演算子を使っています。

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

これは、次の意味です。

もし filteredMovies.isEmpty が true なら
    EmptySearchResult を表示する
そうでなければ
    ListView.separated を表示する

普通の言葉で書くと、こうです。

検索結果がないなら「見つかりませんでした」を表示。
検索結果があるなら、作品一覧を表示。

このように、条件によって表示するWidgetを変える場面では、三項演算子がよく使われます。


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,
        ),
      ),
    );
  }
}

検索結果がないとき、何も表示されないと、ユーザーは少し迷います。

何も表示されない
↓
通信エラー?
検索中?
該当なし?

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

これにより、「検索したけれど該当作品がありません」と分かります。


Centerで中央に配置する

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

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

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

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

画面中央
↓
No titles found.

画面の端に小さく出すよりも、状態が伝わりやすくなります。


見つからない時の文字を少し薄くする

EmptySearchResult の文字色は、真っ白ではなく、少し薄い白にしています。

color: NetflixColors.white.withValues(alpha: 0.58),

withValues(alpha: 0.58) は、透明度を調整する指定です。

真っ白だと強すぎるため、少し控えめにしています。

真っ白:強く目立つ
薄い白:補助メッセージらしく見える

検索結果がない時の表示は、重要ではありますが、警告のように強く見せる必要はありません。

そのため、少し薄い色にしています。


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です。

今回のように、カードとカードの間に余白を入れたいときに便利です。

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

itemCountで表示件数を決める

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

itemCount: filteredMovies.length,

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

たとえば、検索結果が3件なら、3件分のカードを作ります。

filteredMovies.length = 3
↓
SearchResultTileを3個作る

検索結果が10件なら、10個作ります。

filteredMovies.length = 10
↓
SearchResultTileを10個作る

このように、itemCount はリスト表示の件数を決める大事な値です。


separatorBuilderでカード同士の余白を作る

カードとカードの間には、separatorBuilder で余白を入れています。

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

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

カード
↓
12pxの余白
↓
カード

検索結果のカードがぴったりくっついていると、少し窮屈に見えます。

余白を入れることで、1つ1つの作品が見やすくなります。


itemBuilderで1件分の検索結果を作る

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

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

  return SearchResultTile(movie: movie);
},

index には、何番目の検索結果かが入ります。

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

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

final movie = filteredMovies[index];

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

return SearchResultTile(movie: movie);

SearchResultTileとは?

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

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

  final MovieItem movie;

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

SearchResultTile
↓
検索結果1件分のカード

検索結果の1件分を部品として切り出しておくと、Search画面本体のコードが読みやすくなります。


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,
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

ここからは、このコードを少しずつ分解して見ていきます。


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),
  ),
);

ここで開いているのは、MovieDetailPage です。

MovieDetailPage(movie: movie)

検索結果で選んだ作品の movie を、そのまま詳細画面へ渡しています。

検索結果で作品をタップ
↓
MovieDetailPageにmovieを渡す
↓
その作品の詳細画面を表示する

これまでの教材で学んだ「画面遷移」と「データ渡し」が、ここでも使われています。


Containerで検索結果カードを作る

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

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

Container は、背景色、余白、サイズなどを指定できる便利なWidgetです。

ここでは、検索結果1件分をカードのように見せるために使っています。


BoxDecorationで背景色と角丸を指定する

カードの見た目は、BoxDecoration で作っています。

decoration: BoxDecoration(
  color: NetflixColors.darkGray,
  borderRadius: BorderRadius.circular(10),
),

背景色は、NetflixColors.darkGray です。

color: NetflixColors.darkGray,

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

角は少し丸めています。

borderRadius: BorderRadius.circular(10),

少し角を丸めることで、カードUIらしい見た目になります。


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,

検索結果の一覧では、画像が大きすぎると1画面に表示できる件数が少なくなります。

逆に小さすぎると、どの作品か分かりにくくなります。

そのため、検索結果ではこのくらいの小さめのポスターサイズにしています。


BoxFit.coverで画像をきれいに収める

ポスター画像には、次の指定があります。

fit: BoxFit.cover,

BoxFit.cover は、指定した枠いっぱいに画像を表示する指定です。

画像の比率が少し違っても、余白が出ないように表示できます。

枠いっぱいに画像を表示
↓
はみ出した部分は自然に切り取る
↓
カード内で整って見える

ここまでのまとめ

ここまでで、検索結果がある場合とない場合の表示切り替え、そして検索結果カードの基本構造を確認しました。

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

  • 検索結果エリアは Expanded で残りの高さを使う。
  • filteredMovies.isEmpty で検索結果が0件かどうかを判定する。
  • 検索結果が0件なら EmptySearchResult を表示する。
  • 検索結果があるなら ListView.separated を表示する。
  • ListView.separated は、項目間に余白を入れやすい。
  • itemCount には検索結果の件数を指定する。
  • itemBuilder で検索結果1件分を作る。
  • SearchResultTile は、検索結果1件分のカードUI。
  • GestureDetector でカード全体をタップできるようにする。
  • カードをタップすると MovieDetailPage(movie: movie) へ移動する。
  • ContainerBoxDecoration でカードの背景色と角丸を作る。
  • Row でポスター画像と作品情報を横に並べる。
  • ClipRRect でポスター画像を角丸にする。
  • Image.networkBoxFit.cover で画像をカード内にきれいに表示する。

Expandedで文字エリアを広げる

ここからは、SearchResultTile の右側にある文字エリアを見ていきます。

検索結果カードの中では、左にポスター画像、右に作品情報を置いていました。

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,
        ),
      ),
    ],
  ),
),

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

左:画像の固定幅
右:残りの横幅を文字エリアにする

検索結果では、タイトルや説明文が長くなることがあります。

そのため、文字エリアにはできるだけ余裕を持たせます。


Columnで作品情報を縦に並べる

右側の文字エリアでは、Column を使っています。

Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    Text(movie.title),
    const SizedBox(height: 5),
    Text('${movie.matchRate}% Match • ${movie.year} • ${movie.rating}'),
    const SizedBox(height: 8),
    Text(movie.description),
  ],
)

並びは次の通りです。

作品タイトル
↓
マッチ率・公開年・年齢制限
↓
説明文

検索結果カードでは、ユーザーが作品をすばやく判断できることが大切です。

そのため、まずタイトルを見せ、その下に補足情報、最後に説明文を表示しています。


crossAxisAlignmentで左揃えにする

Column には、次の指定があります。

crossAxisAlignment: CrossAxisAlignment.start,

これは、Column の中身を左揃えにする指定です。

左揃え
タイトル
メタ情報
説明文

検索結果のような情報リストでは、左端がそろっている方が読みやすくなります。

中央揃えにすると、各行の開始位置がバラバラに見えて、少し読みにくくなります。


タイトルを1行で表示する

作品タイトルは、次のコードで表示しています。

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

movie.title には、作品名が入っています。

movie.title

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

maxLines: 1,

TextOverflow.ellipsisで省略する

タイトルが長い場合は、入りきらない部分を ... で省略します。

overflow: TextOverflow.ellipsis,

たとえば、タイトルが長くて1行に入りきらない場合は、次のようになります。

The Very Long Movie Title...

これを指定しないと、文字がはみ出したり、レイアウトが崩れたりすることがあります。

検索結果カードのように、横幅が限られているUIでは、TextOverflow.ellipsis がとても便利です。


タイトルを強く見せる

タイトルのスタイルは、白く、少し太くしています。

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

タイトルは検索結果の中で一番重要な情報です。

そのため、他のテキストよりも強く表示します。

情報見せ方
タイトル白・太字
メタ情報少し薄い白
説明文さらに控えめな白

このように強弱をつけることで、ユーザーは情報を読み取りやすくなります。


メタ情報を1行で表示する

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

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

ここでは、次の3つを表示しています。

マッチ率
公開年
年齢制限

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

98% Match • 2021 • 18+

検索結果のカードでは、情報を詰め込みすぎないことが大切です。

カテゴリやシーズン情報まで入れると、1行が長くなりすぎる場合があります。


文字列の中に変数を入れる

メタ情報では、${} を使って文字列の中に変数を入れています。

'${movie.matchRate}% Match • ${movie.year} • ${movie.rating}'

これは、Dartの文字列補間です。

たとえば、次の値が入っているとします。

movie.matchRate = 98
movie.year = 2021
movie.rating = 18+

この場合、画面には次のように表示されます。

98% Match • 2021 • 18+

データの値を文字列の中に自然に組み込めるので、アプリ開発ではよく使います。


で情報を区切る

メタ情報では、 という記号で情報を区切っています。

'${movie.matchRate}% Match • ${movie.year} • ${movie.rating}'

これは、文字情報をコンパクトに見せるための区切り記号です。

98% Match • 2021 • 18+

カンマやスラッシュでもよいですが、動画アプリ風のUIでは を使うと落ち着いた見た目になります。


メタ情報を少し薄くする

メタ情報の色は、真っ白ではありません。

color: NetflixColors.white.withValues(alpha: 0.72),

タイトルよりも少し薄くしています。

タイトル:強く表示
メタ情報:少し控えめ
説明文:さらに控えめ

このように階層をつけることで、ユーザーは自然にタイトルから読み始められます。


説明文を表示する

メタ情報の下には、作品説明文を表示しています。

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

movie.description には、作品の説明文が入っています。

検索結果一覧では、説明文をすべて表示すると長くなりすぎます。

そのため、最大3行にしています。

maxLines: 3,

説明文も省略表示にする

説明文にも、TextOverflow.ellipsis を指定しています。

overflow: TextOverflow.ellipsis,

説明文が3行に収まらない場合は、最後を ... で省略します。

Hundreds of cash-strapped players accept a strange invitation...

検索結果では、すべてを読ませるのではなく、「気になったら詳細画面へ進む」ことを想定します。

そのため、検索結果カードでは説明文を短く見せる方が使いやすくなります。


heightで行間を調整する

説明文のスタイルには、height があります。

height: 1.35,

これは、行間を調整する指定です。

説明文は複数行になることがあるので、行間が詰まりすぎると読みにくくなります。

行間が狭い
↓
文字が詰まって見える

行間を少し広げる
↓
読みやすくなる

検索結果カードではスペースが限られているため、広げすぎず、1.35 くらいにしています。


検索結果UIの流れを整理する

ここまでの流れを、もう一度整理します。

filteredMoviesを作る
↓
filteredMovies.isEmptyを確認
↓
0件ならEmptySearchResult
↓
1件以上ならListView.separated
↓
itemBuilderでSearchResultTileを作る
↓
SearchResultTileにmovieを渡す
↓
左にポスター画像
↓
右にタイトル・メタ情報・説明文
↓
カードを押すと詳細画面へ移動

検索結果UIでは、ただリストを出すだけではなく、0件の時の表示、カードの余白、文字の省略、詳細画面への導線まで考える必要があります。


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

まずは、検索結果が見つからない時の文言を日本語にしてみましょう。

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

'No titles found.'

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

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

日本語のアプリとして見せたい場合は、この方が自然です。


検索結果カードの余白を広げてみよう

カード同士の間隔は、separatorBuilder で決めています。

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

もっとゆったり見せたい場合は、次のように変更します。

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

カード間の余白が広がり、見た目にゆとりが出ます。


ポスター画像を少し大きくしてみよう

ポスター画像のサイズは、次のコードで決めています。

width: 78,
height: 112,

少し大きくしたい場合は、次のようにできます。

width: 88,
height: 126,

ただし、画像を大きくすると、1画面に表示できる検索結果の数は少なくなります。

一覧性を優先するか、ビジュアルの迫力を優先するかで調整しましょう。


説明文を2行にしてコンパクトにする

検索結果カードをもっとコンパクトにしたい場合は、説明文を2行にします。

maxLines: 3,

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

maxLines: 2,

カードの高さが少し低くなり、一覧として見やすくなります。


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

Q. 検索結果が0件の時、何も表示されません。

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

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

0件の時に何も表示しないと、ユーザーには状態が伝わりません。


Q. ListView が表示されず、レイアウトエラーになります。

Column の中で ListView を使う場合は、Expanded で包むことが多いです。

Expanded(
  child: ListView.separated(
    ...
  ),
)

ListView は縦方向に広がろうとするため、親から高さを決めてもらう必要があります。

Expanded を使うことで、残りの高さをリストに与えられます。


Q. タイトルが長くて右にはみ出します。

Text に次の指定があるか確認してください。

maxLines: 1,
overflow: TextOverflow.ellipsis,

これにより、長いタイトルは1行で省略されます。


Q. 説明文が長すぎてカードが大きくなります。

説明文に、次の指定があるか確認してください。

maxLines: 3,
overflow: TextOverflow.ellipsis,

さらにコンパクトにしたい場合は、maxLines: 2 に変更してもよいです。


Q. 検索結果カードを押しても詳細画面に移動しません。

SearchResultTileGestureDetector で包まれているか確認してください。

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

MovieDetailPage(movie: movie) に、選んだ作品データを渡すことも忘れないようにしましょう。


チャレンジ

チャレンジ1:見つからない時の文言を日本語にしよう

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

'No titles found.'

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

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

検索結果が0件のとき、日本語のメッセージが表示されるか確認してください。


チャレンジ2:検索結果カードの間隔を広げよう

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

return const SizedBox(height: 12);

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

return const SizedBox(height: 18);

カード同士の間に余白が増えるか確認してください。


チャレンジ3:ポスター画像を少し大きくしよう

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

width: 78,
height: 112,

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

width: 88,
height: 126,

検索結果カードのポスター画像が少し大きくなるか確認してください。


チャレンジ4:説明文を2行までにしよう

説明文の Text にある次のコードを探してください。

maxLines: 3,

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

maxLines: 2,

検索結果カードが少しコンパクトになるか確認してください。


チャレンジの答え

チャレンジ1の答え

変更前:

'No titles found.'

変更後:

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

0件表示が日本語になります。


チャレンジ2の答え

変更前:

return const SizedBox(height: 12);

変更後:

return const SizedBox(height: 18);

検索結果カード同士の間隔が広がります。


チャレンジ3の答え

変更前:

width: 78,
height: 112,

変更後:

width: 88,
height: 126,

ポスター画像が少し大きくなります。


チャレンジ4の答え

変更前:

maxLines: 3,

変更後:

maxLines: 2,

説明文が最大2行になり、検索結果カードがコンパクトになります。


この節のまとめ

この節では、検索結果がある場合の一覧表示と、見つからない時の表示を作りました。

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

  • 検索結果UIでは、結果がある場合とない場合の両方を考える。
  • filteredMovies.isEmpty を使うと、検索結果が0件かどうかを判定できる。
  • 0件の時は EmptySearchResult を表示する。
  • EmptySearchResult を用意すると、ユーザーに「該当なし」が伝わりやすい。
  • Center を使うと、見つからない時のメッセージを画面中央に表示できる。
  • ListView.separated を使うと、検索結果カードの間に余白を入れられる。
  • itemCount には検索結果の件数を指定する。
  • itemBuilder で検索結果1件分のWidgetを作る。
  • SearchResultTile によって、検索結果1件分を部品化できる。
  • GestureDetector でカード全体をタップできるようにする。
  • Navigator.of(context).push で詳細画面へ移動できる。
  • MovieDetailPage(movie: movie) に作品データを渡すことで、選んだ作品の詳細を表示できる。
  • ContainerBoxDecoration でカードの背景色と角丸を作れる。
  • Row で画像と文字を横に並べられる。
  • ClipRRect で画像を角丸にできる。
  • TextOverflow.ellipsis を使うと、長いタイトルや説明文を省略できる。
  • maxLines を使うと、表示する行数を制限できる。

次のステップ

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

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

検索画面まで作ると、作品を「探す」導線ができます。

次は、ユーザー自身の情報や保存した作品を見せる画面を作って、アプリ全体の完成度を高めていきましょう。

教材トップへ戻る