Chapter 22

  検索結果一覧の実装

heyhey1028
heyhey1028
2023.02.25に更新

この章では検索ボックスを使って検索した結果をスクロール可能なリストとして表示する UI を実装していきます。

実装

検索結果一覧は一つ一つの記事を表示する Widget と、それらをリストに並べる Widget で構成されます。

検索結果を表示するボックスを実装する

まずはリストに並べる各記事の内容を表示する Widget を実装しましょう。

article_container.dartファイルを作成

中身の内容は後ほど実装するとしてまずは外観を作りましょう。

ファイルを配置するディレクトリはお好みですが、今回 UI パーツはlib直下にcomponentsというディレクトリを作り、そこに配置することにします。

名前はarticle_container.dartにします。

lib
├── components
│   └── article_container.dart // ← 追加
├── main.dart
├── models
│   ├── article.dart
│   └── user.dart
└── screens
    └── search_screen.dart

ArticleContainerクラスを作成

カスタム Widget を作成する際には、StatelessWidgetまたはStatefulWidgetを継承したクラスを作成します。

今回は状態値を持つ必要がないので、StatelessWidgetを継承したArticleContainerクラスを作成します。

コンストラクタの定義も必要となります。Keyの記述も書いておきましょう。

article_container.dart
class ArticleContainer extends StatelessWidget {
  const ArticleContainer({ // ← コンストラクタ
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    return Container(
        height: 180, // ← 高さを指定
        decoration: const BoxDecoration(
            color: Color(0xFF55C500), // ← 背景色を指定
            borderRadius: BorderRadius.all( // ← 角丸を設定
                Radius.circular(32),
            ),
        ),
    );
  }
}

高さは180とします。色や角丸の設定などはdecorationプロパティにBoxDecorationを設定することで行うことができます。

背景色には Qiita のテーマカラーであるColor(0xFF55C500)を設定し、角丸にすることができるBorderRadiusを設定します。

Radius.circular(32)は角丸の半径を指定するもので、今回は32とします。

表示を確認するためにsearch_screen.dartArticleContainerを配置してみましょう。

search_screen.dart
search_screen.dart
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Qiita Search'),
      ),
      body: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.symmetric(
              vertical: 12,
              horizontal: 36,
            ),
            child: TextField(
              style: const TextStyle(
                fontSize: 16,
                color: Colors.black,
              ),
              decoration: const InputDecoration(
                hintText: '検索ワードを入力してください',
                hintStyle: TextStyle(
                  fontSize: 16,
                  color: Colors.black54,
                  fontStyle: FontStyle.italic,
                ),
              ),
              onSubmitted: (value) async {
                final result = await searchQiita(value);
                setState(() => articles = result);
              },
            ),
          ),
+          ArticleContainer()
        ],
      ),
    );
  }

これで以下のようなボックスが作成されました。

ArticleContainer に余白を作る

このままだと ArticleContainer が並んだ際に隙間がなくなってしまい不恰好なので余白を作っておきましょう。

検索ボックスの時と同じようにPaddingで囲って余白を作ります。

article_container.dart
class ArticleContainer extends StatelessWidget {
  const ArticleContainer({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
-    return Container(
+    return Padding(
+      padding: const EdgeInsets.symmetric(
+        vertical: 12,
+        horizontal: 16,
+      ),
+      child: Container(
        height: 180,
        decoration: const BoxDecoration(
          color: Color(0xFF55C500),
          borderRadius: BorderRadius.all(
            Radius.circular(32),
          ),
        ),
      ),
    );
  }
}

記事の情報を受け取る

外観ができたところで、中に表示する情報を受け取るようにしましょう。

記事の情報はArticleクラスで定義されているので、ArticleContainerのコンストラクタにArticleクラスのインスタンスを受け取るようにします。

article_container.dart
class ArticleContainer extends StatelessWidget {
  const ArticleContainer({
    Key? key,
+    required this.article, // ← 追加
  }) : super(key: key);

+  final Article article; // ← 追加

  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(
        vertical: 12,
        horizontal: 16,
      ),
      child: Container(
        height: 180,
        decoration: const BoxDecoration(
          color: Color(0xFF55C500),
          borderRadius: BorderRadius.all(
            Radius.circular(32),
          ),
        ),
      ),
    );
  }
}

これと同時にsearch_screen.dartArticleContainerのインスタンスを作成する際にArticleクラスのインスタンスを渡すようにします。

search_screen.dart
search_screen.dart
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Qiita Search'),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.symmetric(
              vertical: 12,
              horizontal: 36,
            ),
            child: TextField(
              style: const TextStyle(
                fontSize: 16,
                color: Colors.black,
              ),
              decoration: const InputDecoration(
                hintText: '検索ワードを入力してください',
                hintStyle: TextStyle(
                  fontSize: 16,
                  color: Colors.black54,
                  fontStyle: FontStyle.italic,
                ),
              ),
              onSubmitted: (value) async {
                final result = await searchQiita(value);
                setState(() => articles = result);
              },
            ),
          ),
-          ArticleContainer()
+          ArticleContainer(
+            article: articles[0],
+          ),
        ],
      ),
    );
  }

ArticleContainer 内のレイアウト

ArticleContainerの完成図を分解すると作成日やタイトル、タグなどが上から順番に並んでいることが分かるので Column が使えそうです。

またいいね数と投稿者のアイコンは横並びになっているので Row が使えそうです。

いいね数と投稿者のアイコン

さらにハートのアイコンといいね数、投稿者のアイコンと投稿者名は縦に並んでるのでここも Column が使えそうですね。

ハートのアイコンといいね数、投稿者のアイコンと投稿者名

ArticleContainer内のパーツを作る

投稿日

投稿日の表示ではDateTimeそのままだと見づらいので、intlというパッケージのDateFormatというクラスを使って読みやすいフォーマットに変更しましょう。

intl の導入

flutter プロジェクトのルートで以下のコマンドを実行します。

flutter pub add intl

intl パッケージを使うファイルで以下のようにインポートします。

import 'package:intl/intl.dart';

今回は article_container.dart で使うので、そちらのファイルでインポートしてください。

https://pub.dev/packages/intl

// createdAt
Text(
    DateFormat('yyyy/MM/dd').format(article.createdAt),
    style: const TextStyle(
        color: Colors.white,
        fontSize: 12,
    ),
),

タイトル

Textwidget には行数を限定する maxLinesプロパティ があり、更に表示範囲を超える文字数の場合にどのように表示するかを指定できる overflowプロパティ があります。

タイトルは長いこともあるので、2 行まで表示し、それより長い場合は...で省略してくれる TextOverflow.ellipsis を指定しておきます。

overflow の種類

Text が指定できる overflow の種類は以下の通りです。

clip: 行数を超えた文字は表示されない

fade: 行数を超えた文字は表示されるが、フェードアウトする

ellipsis: 行数を超えた文字は...で省略される

visible: 行数を超えても文字は表示され、場合によってはレイアウトエラーを引き起こします。maxLines を指定した場合は、それを超えた文字は表示されません。

// title
Text(
    article.title,
    maxLines: 2,
    overflow: TextOverflow.ellipsis,
    style: const TextStyle(
        fontSize: 16,
        fontWeight: FontWeight.bold,
        color: Colors.white,
    ),
),

タグ

タグは複数あるので、Listjoinメソッドを使って#で区切って表示します。

また Text のスタイルを変更できるTextStyleでフォントスタイルを変更するfontStyleプロパティを使って、斜体にしてみます。

Text(
    '#${article.tags.join(' #')}', // ←文字列の配列をjoinで結合
    style: const TextStyle(
        fontSize: 12,
        color: Colors.white,
        fontStyle: FontStyle.italic, // ←フォントスタイルを斜体に変更
    ),
),

ハートアイコンといいね数

ハートアイコンについては、Icons.favoriteというクラスが用意されているのでそれを使います。

Column(
    children: [
        const Icon(
            Icons.favorite, // ←ハートアイコン
            color: Colors.white,
        ),
        Text(
            article.likesCount.toString(),
            style: const TextStyle(
            fontSize: 12,
            color: Colors.white,
            ),
        ),
    ],
),

投稿者のアイコンと投稿者名

投稿者のアイコンについては画像の url を保持しているので、url を元に画像をダウンロードして表示する NetworkImagewidget を使います。

またこういった アイコン画像を丸く表示するのに便利なCircleAvatarwidget というのを使います。

今回はcrossAxisAlignmentというプロパティを使って、水平方向のどこに寄せるかを指定します。

今回は右に寄せたいので CrossAxisAlignment.end を指定します。

Column(
    crossAxisAlignment: CrossAxisAlignment.end,
    children: [
        CircleAvatar(
            radius: 26,
            backgroundImage: NetworkImage(article.user.iconUrl),
        ),
        const SizedBox(height: 4),
        Text(
            article.user.id,
            style: const TextStyle(
            fontSize: 12,
            color: Colors.white,
            ),
        ),
    ],
),

ArticleContainer にパーツを配置する

パーツが出揃ったところで、ArticleContainerに配置していきましょう。

まずパーツを上から順に並べる為に、Containerの子要素としてColumnを配置しましょう。

article_container.dart
class ArticleContainer extends StatelessWidget {
  const ArticleContainer({
    super.key,
    required this.article,
  });

  final Article article;

  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(
        vertical: 12,
        horizontal: 16,
      ),
      child: Container(
        height: 180,
        decoration: const BoxDecoration(
          color: Color(0xFF55C500),
          borderRadius: BorderRadius.all(
            Radius.circular(32),
          ),
        ),
+        child: Column(
+          children: []  // ← ここに各パーツを配置していく
+        )
      ),
    );
  }
}

その中へ投稿日、タイトル、タグの順に配置していきます。

投稿日、タイトル、タグを追加
article_container.dart
  child: Container(
    height: 180,
    decoration: const BoxDecoration(
      color: Color(0xFF55C500),
      borderRadius: BorderRadius.all(
        Radius.circular(32),
      ),
    ),
    child: Column(
+     crossAxisAlignment: CrossAxisAlignment.start,
      children: [
+        // 投稿日
+        Text(
+          DateFormat('yyyy/MM/dd').format(article.createdAt),
+          style: const TextStyle(
+            color: Colors.white,
+            fontSize: 12,
+          ),
+        ),
+        // タイトル
+        Text(
+          article.title,
+          maxLines: 2,
+          overflow: TextOverflow.ellipsis,
+          style: const TextStyle(
+            fontSize: 16,
+            fontWeight: FontWeight.bold,
+            color: Colors.white,
+          ),
+        ),
+        // タグ
+        Text(
+          '#${article.tags.join(' #')}',
+          style: const TextStyle(
+            fontSize: 12,
+            color: Colors.white,
+            fontStyle: FontStyle.italic,
+          ),
+        ),
      ]
    )
  ),

現状のままだと日にち、タイトル、タグが中心に寄ってしまっているので、先ほども使ったcrossAxisAlignmentプロパティに CrossAxisAlignment.startを渡すことで左寄せにします。

最後にRowを使って横に並ばせた「ハートアイコンといいね数」と「投稿者のアイコンと投稿者名」を配置します。

ハートアイコンといいね数、投稿者のアイコンと投稿者名を追加
article_container.dart
  child: Container(
    height: 180,
    decoration: const BoxDecoration(
      color: Color(0xFF55C500),
      borderRadius: BorderRadius.all(
        Radius.circular(32),
      ),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 投稿日
        // タイトル
        // タグ
+        Row(
+          children:[
+            // ハートアイコンといいね数
+            Column(
+              children: [
+                const Icon(
+                  Icons.favorite,
+                  color: Colors.white,
+                ),
+                Text(
+                  article.likesCount.toString(),
+                  style: const TextStyle(
+                    fontSize: 12,
+                    color: Colors.white,
+                  ),
+                ),
+              ],
+            ),
+            // 投稿者のアイコンと投稿者名
+            Column(
+              crossAxisAlignment: CrossAxisAlignment.end,
+              children: [
+                CircleAvatar(
+                  radius: 26,
+                  backgroundImage: NetworkImage(article.user.iconUrl),
+                ),
+                const SizedBox(height: 4),
+                Text(
+                  article.user.id,
+                  style: const TextStyle(
+                    fontSize: 12,
+                    color: Colors.white,
+                  ),
+                ),
              ],
            ),
          ],
        ),
      ]
    )
  ),

しかしこのままだといいねと投稿者アイコンが下記のように左に寄っています。

そこでRowmainAxisAlignment というRowの主軸方向(水平方向)にどう子要素を配置するかを指定できるプロパティに MainAxisAlignment.spaceBetween を指定します。

MainAxisAlignment.spaceBetweenを指定することで、Rowの幅いっぱいを使いつつ、子要素の間には等間隔のスペースを配置してくれます。

article_container.dart
  Row(
+    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children:[
      // ハートアイコンといいね数
      // 投稿者のアイコンと投稿者名
    ],
  ),

🙌ArticleContainer の完成

完成したコード
article_container.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:qiita_search/models/article.dart';
import 'package:qiita_search/screens/article_screen.dart';

class ArticleContainer extends StatelessWidget {
  const ArticleContainer({
    super.key,
    required this.article,
  });

  final Article article;

  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(
        horizontal: 16,
        vertical: 12,
      ),
      child: GestureDetector(
        onTap: () {
          Navigator.of(context).push(
            MaterialPageRoute(
              builder: ((context) => ArticleScreen(article: article)),
            ),
          );
        },
        child: Container(
          height: 180,
          padding: const EdgeInsets.symmetric(
            horizontal: 20,
            vertical: 16,
          ),
          decoration: const BoxDecoration(
            color: Color(0xFF55C500),
            borderRadius: BorderRadius.all(
              Radius.circular(32),
            ),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                DateFormat('yyyy/MM/dd').format(article.createdAt),
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 12,
                ),
              ),
              Text(
                article.title,
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
                style: const TextStyle(
                  fontSize: 16,
                  fontWeight: FontWeight.bold,
                  color: Colors.white,
                ),
              ),
              Text(
                '#${article.tags.join(' #')}',
                style: const TextStyle(
                  fontSize: 12,
                  color: Colors.white,
                  fontStyle: FontStyle.italic,
                ),
              ),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Column(
                    children: [
                      const Icon(
                        Icons.favorite,
                        color: Colors.white,
                      ),
                      Text(
                        article.likesCount.toString(),
                        style: const TextStyle(
                          fontSize: 12,
                          color: Colors.white,
                        ),
                      ),
                    ],
                  ),
                  Column(
                    crossAxisAlignment: CrossAxisAlignment.end,
                    children: [
                      CircleAvatar(
                        radius: 26,
                        backgroundImage: NetworkImage(article.user.iconUrl),
                      ),
                      const SizedBox(height: 4),
                      Text(
                        article.user.id,
                        style: const TextStyle(
                          fontSize: 12,
                          color: Colors.white,
                        ),
                      ),
                    ],
                  ),
                ],
              )
            ],
          ),
        ),
      ),
    );
  }
}

記事一覧をスクロール可能なリストにする

最後に、ArticleContainerを使って記事のリストを作成します。

取得した記事は 10 件になるのでディスプレイに恐らく収まりません。そのような時はスクロール可能なリストにする必要があります。

そんな時に使えるのが ListView widget です。

ListViewchildrenWidgetを指定することで、そのWidgetをスクロール可能なリストとして表示してくれます。

search_sreen.dartで作成した検索ボックスの下に下記のLiseViewを追加しましょう。

Expanded(
  child: ListView(
    children: articles
        .map((article) => ArticleContainer(article: article))
        .toList(),
  ),
),

データから Widget への変換

childrenの中では、Articleデータを格納した配列を展開し、ArticleContainerwidget に変換し、さらにtoList()で Widget のリストに変換しています。

[データの配列].map(([データ]){
  // データを使ってWidgetを生成する
  return Widget();
}).toList()

上記のような「データ配列 → 複数の Widget」への変換はよく行う処理なので、覚えておくと便利です。

残っている領域いっぱいに広がるExpanded

追加したListViewExpanded という Widget で囲われています。これはなんでしょうか?

ListView単体は画面サイズに関わりなく縦方向にどこまでも大きくなろうとする習性があります。その為、ListViewListViewに対してサイズ制限を伝える Widget で囲う必要があります。

しかしながらColumnListView同様、縦方向にどこまでも大きくなろうとする習性があり、自分の子 Widget に対してサイズ制限を伝えません。その為、Columnの子要素にListViewをそのまま配置するとエラーになってしまいます。

Expanded はそんな時に使える Widget です。ColumnRowの中で使われることを前提とした Widget で、ColumnRowの子要素を並べたときに残っている画面領域を計算し、その領域をサイズ制限として自身の子要素に伝えてくれます。

ここには複雑な計算ロジックが関わっているので現時点では 「子要素を残っている領域いっぱいに広がてくれる」 と考えてもらえれば大丈夫です。

今回はListViewExpandedで囲うことで検索ボックスを配置して、残った領域いっぱいのListViewを配置してもらいます。

🎉 検索結果一覧の完成

完成したコード
search_screen.dart
class SearchScreen extends StatefulWidget {
  const SearchScreen({
    super.key,
  });

  
  State<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  List<Article> articles = [];

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Qiita Search'),
      ),
      body: Column(
        children: <Widget>[
          // 検索ボックス
          Padding(
            padding: const EdgeInsets.symmetric(
              vertical: 12,
              horizontal: 36,
            ),
            child: TextField(
              style: const TextStyle(
                fontSize: 16,
                color: Colors.black,
              ),
              decoration: const InputDecoration(
                hintText: '検索ワードを入力してください',
                hintStyle: TextStyle(
                  fontSize: 16,
                  color: Colors.black54,
                  fontStyle: FontStyle.italic,
                ),
              ),
              onSubmitted: (value) async {
                final result = await searchQiita(value);
                setState(() => articles = result);
              },
            ),
          ),
          // 検索結果リストを追加
-          ArticleContainer(
-            article: articles[0],
-          ),
+          Expanded(
+            child: ListView(
+              children: articles
+                  .map((article) => ArticleContainer(article: article))
+                  .toList(),
+            ),
+          ),
        ],
      ),
    );
  }
}

まとめ

これで検索画面が完成しました!お疲れ様でした!!

後は次章の画面遷移を実装して終わりです

後ちょっとです!