52
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

image.png

はじめに

今回は会社のイベントで 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_FirstSampleApp.gif

画面遷移

ディレクトリ&ファイル

説明に必要な個所だけ

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
      • 非同期処理が完了するまで待つ
  • ?? 演算子
    • nullの場合に右側の値が適応される
  • レスポンスをモデルクラスへ変換
    • ここは、お決まりパターンのようです

Widget での UI 構築

各画面を Widget で構築します

「検索&検索結果」画面

  • 検索条件 : TextField
    • 余白を入れたいので TextFieldPadding で囲んでます
    • 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: ColumnRow で表示の骨格を作る
  • 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 画面遷移イメージ

  • Navigator クラスに対して、Route クラスでラップしたページを渡す事でページの遷移します
    image.png
    image.png

おわりに

前回から、比べると作成時間が大幅にかかりました。
上記で記載した通り新しく覚えた箇所も多かったですが、コードにコメントを多く入れて理解する事を努力しました。

また、前回も「カッコが多い」と感じましたが、今回はもっとカッコが多いです。。。
特に article_containar で検索結果を 1 レコードごと Widge で処理する部分は、ぐちゃぐちゃです。
この"ぐちゃぐちゃ感"は、いつか慣れてくるのでしょうか?

まだまだ、自分のアイディアを Flutter で 1 から表現するには勉強不足なので、もう 1 歩 Flutter の勉強を続けたいと思います。

まだ理解しきれてない箇所

  • カッコでぐちゃぐちゃなコードを綺麗にする方法
  • コンストラクタのキーの使い方
  • Widget デザインの決め方
  • ArticleScreen で、setState しないけど?

参考(感謝)

52
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?