はじめに
今回は会社のイベントで Advent Calendar に参加しての記事になります。
私が Qiita の記事を書こうとしたキッカケは、今回の「2024 Qiita Advent Calendar 」です。
このキッカケがなければ、Qiita に投稿せず記事を拝見するだけだったと思います。
今までの Qiita 記事は、この Advent Calendar 記事を書くための練習、今回はいつも以上に綺麗に書きたいと思います!
(このイベントが終わっても、Qiita への記事は続けます)
今回は、前回の「Flutter サンプルを動かして「おぉ」ってなりました」から、さらに一歩進んで 「Qiita APIを利用して、Flutter で Qiita 記事を表示するアプリ」 を作ります
開発環境
- OS : Windows 11 Pro
- Flutter : 3.24.3
- Dart : 3.5.3
- Cursor(IDE) : 0.42.3
- Android Studio : Koala Feature Drop | 2024.1.2
完成アプリ
画面遷移
ディレクトリ&ファイル
説明に必要な個所だけ
flutter_qiita_search
├─lib
│ │ main.dart -> アプリのエントリーポイント
│ │
│ ├─models
│ │ article.dart -> 記事情報
│ │ user.dart -> ユーザー情報
│ │
│ ├─screens
│ │ article_screen.dart -> 記事画面
│ │ search_screen.dart -> 検索&検索結果画面
│ │
│ └─widgets
│ article_container.dart -> 記事を表示するUIパーツ
│ .env -> 非公開情報
│ .flutter-plugins
│ .flutter-plugins-dependencies
│ analysis_options.yaml
│ pubspec.lock
│ pubspec.yaml
└ README.md
開発のポイント
前回から 1 歩進んだ箇所を"ポイント"として記載します。
- パッケージの利用
- API 通信
- Widget での UI 構築
- 画面遷移
パッケージの利用
「Flutter > flutter clean > flutter pub get で解決できない、、、」で投稿させて頂いた通り、ハマってしまったパッケージのインポートです。
バージョンなどの詳細は、pubspec.yaml
ファイルをご覧ください。
flutter_dotenv
- Qiita へのアクセストークンをコード上にベタ打ちしないため、
.env
ファイルで公開するプログラムソースからアクセストークンを除外します/// メイン処理 Future<void> main() async { // 非同期処理となる為、main関数をFutureに変更 await dotenv.load(fileName: '.env'); // .envファイルを読み込み runApp(const MainApp()); }
- 当たり前ですけど、作成した
.env
ファイルはgitignore
ファイルで Git から対象外にします# env .env
ポイント
- assets 登録
-
pubspec.yaml
ファイルにassets
登録を忘れずに!!!flutter: uses-material-design: true assets: - .env
-
http
- http 通信します
Future<List<Article>> searchQiita(String keyword) async { // 1. http通信に必要なデータを準備をする // URL、クエリパラメータの設定 final uri = Uri.https('qiita.com', '/api/v2/items', { 'query': 'title:$keyword', 'per_page': '10', }); // 2. Qiita APIにリクエストを送る final http.Response res = await http.get(uri, headers: { 'Authorization': 'Bearer $token', });
ポイント
- FlutterSDK の
http
と被るため、http
パッケージはインポートで別名として利用する// http通信で使用。httpという変数を通して、httpパッケージにアクセス import 'package:http/http.dart' as http;
intl
- 日付をフォーマットします
child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 投稿日 Text( DateFormat('yyyy/MM/dd').format(article.createdAt), style: const TextStyle( color: Colors.white, fontSize: 12, ), ),
- 今回は利用しませんでしたが、日付フォーマットの他にも NumberFormat などがあります
webview_flutter
- アプリ内で Qiita 記事( Web サイト)を開きます
/// 記事画面のステートレスエンドポイント。 class _ArticleScreenState extends State<ArticleScreen> { late WebViewController controller = WebViewController() ..loadRequest( Uri.parse(widget.article.url), );
ポイント
- カスケードオペレータ(..)
- ドットを2つ繋げたオペレーターを使う事で、1つのクラスの処理を連鎖的に実行
- late キーワード
- 変数の初期化を遅らせる。変数が宣言された時点ではなく、後で初期化したい場合に利用する
API 通信
Qiita API にリクエストを送り、レスポンスを加工して画面に表示します。
Qiita API エンドポイント
-
[GET] https://qiita.com/api/v2/items
- ブラウザからエンドポイントにアクセスすると、スゴイ量の JSON なので注意
アクセストークン
- アクセストークンは、ページ下部の"参考(感謝)"をご覧ください
Qiita API 通信
- Qiita API をアクセストークンを使って呼び出します
Future<List<Article>> searchQiita(String keyword) async { // 1. http通信に必要なデータを準備をする // URL、クエリパラメータの設定 final uri = Uri.https('qiita.com', '/api/v2/items', { 'query': 'title:$keyword', 'per_page': '10', }); // アクセストークンの取得 final String token = dotenv.env['QIITA_ACCESS_TOKEN'] ?? ''; // 2. Qiita APIにリクエストを送る final http.Response res = await http.get(uri, headers: { 'Authorization': 'Bearer $token', }); // 3. 戻り値をArticleクラスの配列に変換 // 4. 変換したArticleクラスの配列を返す(returnする) if (res.statusCode == 200) { // レスポンスをモデルクラスへ変換 final List<dynamic> body = jsonDecode(res.body); return body.map((dynamic json) => Article.fromJson(json)).toList(); } else { return []; } }
ポイント
- 非同期処理
- Future
- 非同期処理の宣言
- async
- 非同期処理だけど同期させる
- await
- 非同期処理が完了するまで待つ
- Future
- ?? 演算子
- nullの場合に右側の値が適応される
- レスポンスをモデルクラスへ変換
- ここは、お決まりパターンのようです
Widget での UI 構築
各画面を Widget で構築します
「検索&検索結果」画面
- 検索条件 :
TextField
- 余白を入れたいので
TextField
をPadding
で囲んでます -
onSubmitted
で、検索処理を実行 -
setState
で、articles
を保持します
- 余白を入れたいので
- 検索結果一覧 : ListView
-
ListView
で、複数レコード時には縦スクロールを表示 - レコード単位の表示は
ArticleContainer
で表示
// Widget @override Widget build(BuildContext context) { return Scaffold( // 画面にAppBarを作成 appBar: AppBar( title: const Text('Qiita Search Test'), ), body: Column( children: [ // 検索ボックス Padding( // ← Paddingで囲む padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 36, ), child: TextField( style: TextStyle( // ← TextStyleを渡す fontSize: 18, color: Colors.black, ), decoration: InputDecoration( // ← InputDecorationを渡す。Inputの装飾 hintText: '検索ワードを入力してください', ), onSubmitted: (String value) async { final results = await searchQiita(value); // ← 検索処理を実行する setState(() => articles = results); // 検索結果を代入 }, ), ), // 検索結果一覧 Expanded( child: ListView( children: articles .map((article) => ArticleContainer(article: article)) .toList(), ), ), ], ), ); }
-
検索結果のレコード
- 検索結果は
ArticleContainer
で表示 - Widget から利用される各 UI パーツは、
xxxContainer
として定義 - 余白を入れたいので
Padding
で囲んでます - コンストラクタで、記事情報を受け取る
-
child: Column
とRow
で表示の骨格を作る -
Icons.favorite
で、ハートマークのアイコンを表示。"いいね"の数もハートの下に表示 -
NetworkImage(article.user.profileImageUrl)
で、投稿者のアイコンを表示 -
crossAxisAlignment
プロパティで、配置を下部へ修正// Widget @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 16, ), child: GestureDetector( // GestureDetectorでContainerを囲う onTap: () { Navigator.of(context).push( MaterialPageRoute( builder: ((context) => ArticleScreen(article: article)), ), ); }, 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( // Boxの装飾 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, crossAxisAlignment: CrossAxisAlignment.end, 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.profileImageUrl), ), const SizedBox(height: 4), Text( article.user.id, style: const TextStyle( fontSize: 12, color: Colors.white, ), ), ], ), ], ), ], ) ), ), ) ); }
「記事表示」画面
- 記事詳細の "URL" を元に "WebView" で Web サイトを表示
- WebViewController と WebViewWidget
- WebViewController : 開く記事の URL を設定
- WebViewWidget : WebViewController を渡すと表示される
/// 記事画面のステートレスエンドポイント。 class _ArticleScreenState extends State<ArticleScreen> { late WebViewController controller = WebViewController() // カスケードオペレータ(..)を使って、コンストラクタの後にloadRequestを実行 ..loadRequest( Uri.parse(widget.article.url), ); // Widget @override Widget build(BuildContext context) { return Scaffold( // 画面にAppBarを作成 appBar: AppBar( title: const Text('Article Page'), ), body: WebViewWidget(controller: controller), ); } }
画面遷移
-
GestureDetector
Widget を利用して、タップイベントを実装します -
Navigator
クラスにMaterialPageRoute
を使って、ArticleScreen
を遷移先として渡しますchild: GestureDetector( onTap: () { Navigator.of(context).push( MaterialPageRoute( builder: ((context) => ArticleScreen(article: article)), ), ); }, child: Container(
Flutter 画面遷移イメージ
おわりに
前回から、比べると作成時間が大幅にかかりました。
上記で記載した通り新しく覚えた箇所も多かったですが、コードにコメントを多く入れて理解する事を努力しました。
また、前回も「カッコが多い」と感じましたが、今回はもっとカッコが多いです。。。
特に article_containar
で検索結果を 1 レコードごと Widge で処理する部分は、ぐちゃぐちゃです。
この"ぐちゃぐちゃ感"は、いつか慣れてくるのでしょうか?
まだまだ、自分のアイディアを Flutter で 1 から表現するには勉強不足なので、もう 1 歩 Flutter の勉強を続けたいと思います。
まだ理解しきれてない箇所
- カッコでぐちゃぐちゃなコードを綺麗にする方法
- コンストラクタのキーの使い方
- Widget デザインの決め方
-
ArticleScreen
で、setState
しないけど?
参考(感謝)