この章では検索ボックスを使って検索した結果をスクロール可能なリストとして表示する 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
の記述も書いておきましょう。
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.dart
にArticleContainer
を配置してみましょう。
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
で囲って余白を作ります。
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
クラスのインスタンスを受け取るようにします。
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.dart
のArticleContainer
のインスタンスを作成する際にArticle
クラスのインスタンスを渡すようにします。
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 で使うので、そちらのファイルでインポートしてください。
// createdAt
Text(
DateFormat('yyyy/MM/dd').format(article.createdAt),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
タイトル
Text
widget には行数を限定する 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,
),
),
タグ
タグは複数あるので、List
のjoin
メソッドを使って#
で区切って表示します。
また 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 を元に画像をダウンロードして表示する NetworkImage
widget を使います。
またこういった アイコン画像を丸く表示するのに便利なCircleAvatar
widget というのを使います。
今回は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
を配置しましょう。
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: [] // ← ここに各パーツを配置していく
+ )
),
);
}
}
その中へ投稿日、タイトル、タグの順に配置していきます。
投稿日、タイトル、タグを追加
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
を使って横に並ばせた「ハートアイコンといいね数」と「投稿者のアイコンと投稿者名」を配置します。
ハートアイコンといいね数、投稿者のアイコンと投稿者名を追加
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,
+ ),
+ ),
],
),
],
),
]
)
),
しかしこのままだといいねと投稿者アイコンが下記のように左に寄っています。
そこでRow
の mainAxisAlignment
というRow
の主軸方向(水平方向)にどう子要素を配置するかを指定できるプロパティに MainAxisAlignment.spaceBetween
を指定します。
MainAxisAlignment.spaceBetween
を指定することで、Row
の幅いっぱいを使いつつ、子要素の間には等間隔のスペースを配置してくれます。
Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
children:[
// ハートアイコンといいね数
// 投稿者のアイコンと投稿者名
],
),
ArticleContainer
の完成
🙌完成したコード
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 です。
ListView
はchildren
にWidget
を指定することで、そのWidget
をスクロール可能なリストとして表示してくれます。
search_sreen.dart
で作成した検索ボックスの下に下記のLiseView
を追加しましょう。
Expanded(
child: ListView(
children: articles
.map((article) => ArticleContainer(article: article))
.toList(),
),
),
データから Widget への変換
children
の中では、Article
データを格納した配列を展開し、ArticleContainer
widget に変換し、さらにtoList()
で Widget のリストに変換しています。
[データの配列].map(([データ]){
// データを使ってWidgetを生成する
return Widget();
}).toList()
上記のような「データ配列 → 複数の Widget」への変換はよく行う処理なので、覚えておくと便利です。
Expanded
残っている領域いっぱいに広がる追加したListView
は Expanded
という Widget で囲われています。これはなんでしょうか?
ListView
単体は画面サイズに関わりなく縦方向にどこまでも大きくなろうとする習性があります。その為、ListView
はListView
に対してサイズ制限を伝える Widget で囲う必要があります。
しかしながらColumn
はListView
同様、縦方向にどこまでも大きくなろうとする習性があり、自分の子 Widget に対してサイズ制限を伝えません。その為、Column
の子要素にListView
をそのまま配置するとエラーになってしまいます。
Expanded
はそんな時に使える Widget です。Column
やRow
の中で使われることを前提とした Widget で、Column
やRow
の子要素を並べたときに残っている画面領域を計算し、その領域をサイズ制限として自身の子要素に伝えてくれます。
ここには複雑な計算ロジックが関わっているので現時点では 「子要素を残っている領域いっぱいに広がてくれる」 と考えてもらえれば大丈夫です。
今回はListView
をExpanded
で囲うことで検索ボックスを配置して、残った領域いっぱいのListView
を配置してもらいます。
🎉 検索結果一覧の完成
完成したコード
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(),
+ ),
+ ),
],
),
);
}
}
まとめ
これで検索画面が完成しました!お疲れ様でした!!
後は次章の画面遷移を実装して終わりです
後ちょっとです!