flutter

社内でFlutterのハンズオンを行ったので、資料を公開します

flutter

先日、社内で Flutter のハンズオンを行ったので、その際に使用した資料を公開します。

Flutter とは?

flutter_logo.png

Made by Google Flutter is Google’s UI toolkit for building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.

Flutter™️ は Google が作った、単一のコードベースで iOS、 Android、Web のアプリケーションを作成できるツールキットのことです。 アプリケーションの大部分を Dart という言語で記述します。

Flutter Official Document

※Flutter および Flutter のロゴは Google LLC の商標です。

Flutter の特徴

  • 全てはウィジェット
  • コードを宣言的に記述する

全てはウィジェット

Flutter では、以下のようなモバイルアプリ開発においてよく用いられる UI のパーツは全てウィジェットとして定義されています。

  • Text
  • Container
  • Row
  • Column
  • Image
  • Icon

これらのウィジェットを組み合わせて階層構造(ウィジェットツリー)を形成することでアプリを開発していくことになります。

コードを宣言的に記述する

UI プログラミングには、大きく分けて二通りの手法があります。

  • 手続き型 UI プログラミング
  • 宣言型 UI プログラミング

手続き型 UI プログラミングとは、A が B になったら、このように表示を変える といったコードの書き方をします。

// JavaScriptに似たなんらかの言語
button.onTap((event) {
  currentStatus = !currentStatus;
  if (currentStatus == true) {
    button.color = Colors.blue;
    button.attributes.disabled = false;
  } else {
    button.color = Colors.red;
    button.attributes.disabled = true;
  }
});

一方で宣言型 UI プログラミングは、A に B という状態が与えられたら、このように表示する といったコードの書き方をします。

// Flutterのコード
Widget _button(BuildContext context, bool enabled, Color color) {
  return CustomButton(
    enabled: enabled,
    color: color,
  );
}

宣言的 UI プログラミングでは、状態の更新と UI の描画に関するコードが分離されます。これにより、UI に関するコードは「前の状態」を意識する必要がなくなり、コードが書きやすくなります。 その代わりに、ボイラープレートコードが手続き型 UI プログラミングと比較して増加する傾向にあります。

Flutter はこのようなボイラープレートコードを簡単に生成できるプラグインを Android Studio/VS Code で提供しているので、面倒なコードの記述を省きながら宣言的プログラミングの恩恵を得ることができます。

Flutter の実行環境を整備する

それでは、Flutter の実行環境を整備していきましょう。 私は macOS で Flutter アプリの開発を行っているので、macOS での実行環境整備について説明していきます。

Linux, Windows を使っている方は、以下のドキュメントに沿ってインストールを行ってください。

前提条件

macOS 10.14.5

Flutter SDK のインストール

以下のページから、Flutter SDK をインストールします。

Flutter Install

iOS / Android それぞれの環境でアプリをビルドできるようにするには、Xcode と Android Studio のインストールとセットアップが必要になります。このハンズオンの内容は特定のプラットフォームに依存してないので、どちらか好きな方を選ぶと良いでしょう。(このハンズオンは iOS をターゲットとして説明していきます)

flutter doctorコマンドで、実行したい環境に対して警告が出なくなれば OK です。

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, v1.12.13+hotfix.9, on Mac OS X 10.14.5 18F203, locale ja-DE)

[✓] Android toolchain - develop for Android devices (Android SDK version 28.0.3)
[✓] Xcode - develop for iOS and macOS (Xcode 11.2.1)
[✓] Android Studio (version 3.5)
[✓] VS Code (version 1.44.2)
[✓] Connected device (1 available)

• No issues found!

VS Code と VS Code の Flutter Plugin のインストールは Flutter の開発に必須ではありませんが、開発に便利な機能が揃っているのでインストールすることをお勧めします。 このハンズオンでは、VS Code がインストールされていることを前提に進めていきます。

警告が消えない場合

Connected deviceの項目で警告が出ることがあるかもしれません。iOS/Android シミュレータを起動することで警告が出なくなります。

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, v1.12.13+hotfix.9, on Mac OS X 10.14.5 18F203, locale ja-DE)

[✓] Android toolchain - develop for Android devices (Android SDK version 28.0.3)
[✓] Xcode - develop for iOS and macOS (Xcode 7076811.2.1)
[✓] Android Studio (version 3.5)
[✓] VS Code (version 1.44.2)
[!] Connected device
    ! No devices available

! Doctor found issues in 1 category.

# 以下のコマンドでiOSシミュレータを起動
$ open -a Simulator

ハンズオンのリポジトリをフォークする

ハンズオンに使うリポジトリをフォークして下さい。

SUZURI のアクセストークンを準備する

このハンズオンでは、SUZURI の公開 API を利用します。API を使うためにはアクセストークンが必要になります。

SUZURI のアカウントを作成する

まずは 以下のページにアクセスし、SUZURI のアカウントを作成しましょう。

すでにアカウントを持っている方は、このステップを飛ばしても構いません。

SUZURI サインアップ

アクセストークンを取得する

SUZURI にログインした状態で以下のページにアクセスします。

SUZURI Developer

token_description.jpg

表示するボタンを押し、パスワードを入力するとアクセストークンが表示されます。

token_image.jpg

このアクセストークンは、他人に知られないようにしてください。 もし知られてしまった場合は、再生成するボタンでアクセストークンを再生成することをお勧めします。

アクセストークンをプロジェクトに組み込む

アクセストークンが取得できたら、リポジトリの直下に.envというファイルを作成し、トークンを以下のように記述して保存します。

TOKEN='your_access_token'
ご注意!

.envはソース管理下に置かれていなかったとしてもビルドの成果物に含まれてしまうため、第三者が容易にトークンを取得できてしまいます。アプリのパッケージを公開する場合は十分にご注意ください。

回避策の一例として、環境変数を使う手法があります。詳しくはこちらのドキュメントをご参照ください。

依存パッケージをインストールする

Flutter では、依存パッケージのインストールにflutter pub getというコマンドを使います。 リポジトリのディレクトリに移動して、コマンドを実行してみましょう。

$ flutter pub get
Running "flutter pub get" in flutter_hands_on...                    0.8s

上記のようなメッセージが表示されたらインストール完了です。 依存パッケージはpubspec.yamlに記述されているので、興味のある方は見てみると良いでしょう。

起動してみる

ここまで準備ができたら、ハンズオン用の Flutter アプリが起動できるはずです。 VS Code でプロジェクトのディレクトリを開き、lib/main.dartを開いたら、Run and Debugボタンを押して実行してみましょう。

ボタンが有効になっていない場合は、以下の項目について確認してみてください。

run_and_debug.jpg

  • Fluter のプラグインがインストールされていることを確認してください。
  • 実行デバイスが指定されていることを確認してください。No Deviceと表示されている場所をクリックし、シミュレータを指定すれば大丈夫です。

device.jpg

また、launch.jsonを定義していない場合、何らかの Dart ファイルを開いていないと Flutter アプリの実行ができないのでご注意ください。

コマンドラインで実行する場合は、flutter runというコマンドを使ってみましょう。

$ flutter run

 Launching lib/main.dart on iPhone 11 Pro Max in debug mode...
 Running Xcode build...

  ├─Assembling Flutter resources...                          15.7s
  └─Compiling, linking and signing...                         9.2s
 Xcode build done.                                           28.9s
 Syncing files to device iPhone 11 Pro Max...
 11,139ms (!)

 🔥  To hot reload changes while running, press "r". To hot restart (and rebuild state), press "R".

No supported devices connected.と表示される場合は、シミュレータを起動しておきます。

$ flutter run
No supported devices connected.
$ open -a Simulator

こんな画面が表示されたら、準備完了です。

hello_suzuri.png

Flutter のコードを書いてみよう!

それでは、ここから実際に Flutter のコードを書いてアプリケーションを作っていきます。

ご注意!

SUZURI の公開 API にはレートリミットが設定されています。短時間に大量のリクエストを送信してしまうと、一時的に API の利用が制限される場合があります。制限の範囲内でご利用ください。

API に関する詳細はこちらをご参照ください。

SUZURI API Document

商品のリストを作成する

まずは、Hello, SUZURIの代わりに、商品リストの骨組みを表示させてみましょう。 lib/main.dartを開いてMyHomePageクラスを変更していきます。

まずは、MyHomePageクラスのコードを見てみましょう。

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("SUZURI"),
      ),
      body: Center(
        child: Text("Hello, SUZURI!"),
      ),
    );
  }
}

このクラスは StatelessWidget を継承しているウィジェットです。この記事の冒頭で説明した通り、Flutter はこのウィジェットを組み合わせて UI を作っていきます。

StatelessWidget とは、名前の通り状態を持たないウィジェットのことです。

StatelessWidget を継承する上で必要なことは 2 つあります。

  • ミュータブルなフィールドを持っていないこと
  • Widgetを返すbuildメソッドをオーバーライドすること

ミュータブルなフィールドとは、finalキーワードが付与されていないフィールドのことです。

class MyWidget extends StatelessWidget {
  // ミュータブルなフィールド。いつでも代入できる。
  int count;
  // イミュータブルなフィールド。
  // インスタンス生成時にコンストラクタで代入され、それ以降代入されることがない。
  final int immutableCount;
}

Flutter は宣言的にコードを記述します。状態を変えたくなったら、状態を変えるのではなく新しい状態を与えてウィジェットを作り、古いウィジェットを破棄します。

buildメソッドは、新しい状態を与えてウィジェットを作る時に利用されます。 このメソッドはBuildContextを引数に取り、Widgetオブジェクトを返します。

最も簡単なカスタムStatelessWidgetは以下のようになります。

class SimpleCustomWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Containerはウィジェットの一つ
    return Container();
  }
}

これらを踏まえて、MyHomePageを変更していきます。 いま、MyHomePageウィジェットはbuildメソッドでScaffoldというウィジェットを返しています。 Scaffoldはシンプルなマテリアルデザインライクの見た目を提供してくれるウィジェットで、titlebodyを与えると、それを元に画面を生成します。

scaffold.jpg

では、bodyを変更して商品のリストを表示する準備をしてみましょう。 まずは商品を表示する代わりに、タイル表示で四角を描画してみます。

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("SUZURI"),
      ),
      // bodyで表示したいウィジェットを別のメソッドに切り出す
      body: _productsList(context),
    );
  }

  // Widgetを返すメソッド
  // 引数はBuildContextで、呼び出し側のbuildで持っているものを渡す
  Widget _productsList(BuildContext context) {
    return Container(
      // GridViewはウィジェットをグリッドで表示してくれるウィジェット
      // iOS UIKitで言うところの UICollectionView
      // GridView.builderというfactory(カスタムコンストラクタ)で初期化する
      child: GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          // グリッド横方向のウィジェット数
          crossAxisCount: 2,
          crossAxisSpacing: 16,
          mainAxisSpacing: 16,
          // グリッド表示するウィジェットの縦横比
          childAspectRatio: 0.7,
        ),
        // グリッドに表示したいウィジェットの数
        itemCount: 6,
        // itemBuilderはGridViewのインデックス毎に表示したいウィジェットを返すデリゲート
        // context, indexを引数にとり、ウィジェットを返す関数を指定してやる
        // itemContの回数だけ呼ばれる、この例では6回
        itemBuilder: (context, index) {
        	// とりあえずグレーのコンテナを表示してみる
          return Container(
            color: Colors.grey,
            margin: EdgeInsets.all(16),
          );
        }),
    );
  }
}

VS Code で作業していれば、変更を保存(Command + S)した時点で、変更がシミュレータで動いているアプリに反映されます。 反映されない場合は、Hot Reload ボタンを押してみましょう。

hot_reload.jpg

リロードが終わると、以下のような画面に切り替わります。

empty_grid_view.png

API から商品のリストを取得して、アプリ内で保持できるようにする

SUZURI の公開 API には、商品のリストを取得するエンドポイントがあります。 このエンドポイントから商品の情報を取得して、アプリ内で保持できるようにしてみましょう。

商品のリストを取得するためのリクエストは、lib/requests/product_request.dartに用意してあります。

StatelessWidget は状態を持たないと説明しましたが、今回は商品のリストという状態を管理する必要があります。

Flutter にはProviderという、StatelessWidget が状態を意識することなく状態管理ができる便利なライブラリがあります。 詳細は省略しますが、ChangeNotifierChangeNotifierProviderを使って、状態の管理・状態の変更通知を管理できます。

Providerの詳細については、こちらのドキュメントをご参照ください。

それでは、商品リストの状態を管理するProductListStoreを作っていきます。 lib/storesというディレクトリを作成し、その中にproduct_list_store.dartファイルを新しく作ります。

ファイルが作れたら、以下の通り実装しましょう。

// import宣言はコピーペーストしても良いですし、VS Codeならクラス参照の記述を補完すると自動で追加されます
import 'package:flutter/material.dart';
import 'package:flutter_hands_on/models/product.dart';
import 'package:flutter_hands_on/requests/product_request.dart';

// httpライブラリを`http`という名前でimportする
import 'package:http/http.dart' as http;

// ChangeNotifierを継承して新しいStoreクラスを作る
class ProductListStore extends ChangeNotifier {

  // 実際に管理される商品のリスト
  List<Product> _products = [];
  // 外側から直接変更されないように、getterのみ公開
  List<Product> get products => _products;

  // リクエスト実行中に再リクエストしないようにしたい
  bool _isFetching = false;
  bool get isFetching => _isFetching;

  // Storeに変更を要求するインターフェイス
  fetchNextProducts() async {
    if (_isFetching) {
      return;
    }
    _isFetching = true;
    // ProductRequestを初期化
    // http.Clientは外側から与える
    final request = ProductRequest(
      client: http.Client(),
      offset: _products.length,
    );

    // request.fetchはList<Product>を返すFutureオブジェクトを返す
    final products = await request.fetch().catchError((e) {
      _isFetching = false;
    });
    // 取得できた商品のリストを追加する
    _products.addAll(products);
    _isFetching = false;
    // 追加できたら、このStoreを購読しているウィジェットに通知する
    notifyListeners();
  }
}

ChangeNotifierを継承して、新しい Store を作りました。 この Store をChangeNotifierProviderというウィジェットに組み込むことで、下位のウィジェットがこの Store を参照できるようになります。

参照できるようになる仕組みの詳細は省きますが、BuildContextに保存されている情報を使っています。興味のある方はこちらのドキュメントを参照してみると良いでしょう。

では、実際にChangeNotifierProviderをウィジェットに組み込んでみましょう。

lib/main.dartを編集していきます。

2021/01/29追記

一部のコードにアンチパターンとの指摘があったため、サンプルコードを修正しました。 このハンズオンを開催した時点では気付けておらず、指摘のコメントをいただき感謝します。

以前のコードではbuildメソッド内でProvider.of(context, listen: false)を呼び出していましたが、これにはいくつかの問題があります。まず、buildメソッドはウィジェットが描画し直される度に呼ばれるため、APIへのリクエストなどの処理を記述してしまうと意図せず何度も呼ばれ、サービスへの大量リクエストを送信してしまう不具合を入れ込んでしまう懸念があります。

また、buildメソッド内でこれらのメソッド呼び出しを行うと、意図せぬ挙動をする可能性があります。Providerの作者も、buildメソッドの中でProvider.of(context, listen: false)の呼び出しを避けることを推奨しています。

Provider v4.0.0からは、context.readというメソッドでlisten: falseChangeNotifierProviderへの参照を取得できるようになりました。 主な違いとしては、context.readbuildメソッドの内部で呼び出すと実行時エラーが発生し、間違った使い方であることを開発者に知らせてくれます。 v4.0.0以降では、Provider.of<T>(context)ではなく、context.read<T>context.watch<T>といったメソッドでChangeNotifierProviderへの参照を取得することを推奨します。

今回のケースでは「ウィジェットの初回描画時に、APIへのリクエストアクションを実行する」ことを目的としているため、以下のような実装を行うのが適切でした。

MyAppウィジェットをStatefulWidgetで作成します。StatefulWidgetではinitStateという、初期化時に一度のみ実行されるメソッドを記述できます。 次にinitStateメソッドの中でWidgetBindings.instance.addPostFrameCallback()メソッドを呼びます。これはFunctionを引数に取り、初回のbuildメソッドが実行される前に渡されたコールバック関数を実行させるためのメソッドです。もう一つ特徴があり、initStateメソッドの中ではBuildContextへの参照を得られませんが、addPostFrameCallback()に渡されたコールバック関数の中ではBuildContextへの参照を得られるという違いがあり、安全にcontext.readを呼び出せます。

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
// import宣言を追加する
import 'package:flutter_hands_on/stores/product_list_store.dart';
import 'package:provider/provider.dart';

void main() async {
  await DotEnv().load('.env');
  // MultiProviderも、ウィジェットの一種
  runApp(MultiProvider(
    // MultiProviderは複数のChangeNotifierProviderを格納できるProviderのこと
    // providersにList<ChangeNotifierProvider>を指定する
    providers: [
      // ChangeNotifierProviderはcreateにデリゲートを取り、この中でChangeNotifierを初期化して返す
      ChangeNotifierProvider(
        create: (context) => ProductListStore(),
      )
    ],
    // childにはもともと表示に使っていたウィジェットを配置する
    child: MyApp(),
  ));
}

class MyApp extends StatefulWidget {
  MyApp({Key key}) : super(key: key);

  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    // addPostFrameCallbackは、initStateが呼ばれた後に一度のみ実行されるコールバック
    // ウィジェットの描画を行う際、最初の一度のみ実行したい処理を記述する
    WidgetsBinding.instance.addPostFrameCallback((_) {
      // context.read<T>()メソッドでstoreへの参照を取得する
      // Tには、取得したいChangeNotifierのクラスタイプを指定する、今回はProductListStore
      // readは、ChangeNotifierのnotifyListeners()が呼ばれてもウィジェットのリビルドフラグを有効にしない
      // 副作用を伴うアクションを呼ぶ場合は、必ずbuildメソッドの外でreadを使う
      final store = context.read<ProductListStore>();
      if (store.products.isEmpty) {
        store.fetchNextProducts();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("SUZURI"),
      ),
      body: _productsList(context),
    );
  }

  Widget _productsList(BuildContext context) {
    // storeの参照を取得
    // ここではStoreに変更があったらウィジェットに反映したいのでwatchを使う
    final store = context.watch<ProductListStore>();
    final products = store.products;
    // Storeから取得できた商品の数を見て、表示すべきウィジェットを変える
    // 具体的には、0件→空っぽのリスト、1件以上→実際の商品リスト
    if (products.isEmpty) {
      return Container(
        child: GridView.builder(
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 2,
            crossAxisSpacing: 16,
            mainAxisSpacing: 16,
            childAspectRatio: 0.7,
          ),
          itemCount: 6,
          itemBuilder: (context, index) {
            return Container(
              color: Colors.grey,
              margin: EdgeInsets.all(16),
            );
          },
        ),
      );
    } else {
      // 商品のウィジェットをまだ作っていないので、仮でTextを表示してみる
      return Center(child: Text("products"));
    }
  }
}

参考までに、修正前のコードを残しておきます。こちらの書き方は非推奨となっているため、ご注意ください。

class MyApp extends StatelessWidget {
  Widget build(BuildContext context) {
   // Provider.of<T>メソッドでstoreへの参照を取得する
   // Tには、取得したいChangeNotifierのクラスタイプを指定する、今回はProductListStore
   // 引数のcontextは必須、listen: falseはオプショナル引数
   // listenは、「ChangeNotifierのnotifyListeners()が呼ばれたらウィジェットをリビルドするか」のフラグ
   // 今回はリビルドされたくないのでfalse
   final store = Provider.of<ProductListStore>(context, listen: false);
   if (store.products.isEmpty && store.isFetching == false) {
     // 表示すべき商品が空っぽで、かつ取得中でなければStoreに商品を読み込んでもらう
     // buildメソッドはウィジェットがリビルドされるたびに呼ばれるため、制御しないと大量のリクエストが飛ぶこともあるので注意
     store.fetchNextProducts();
   }
   return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

追記終わり

ここまで実装できたら、アプリを Hot Restart させてみましょう。 Hot Restart は VS Code であれば、以下のボタンから実行できます。

hot_restart.jpg

Hot Reload との違いは、Hot Reload がウィジェットの見た目などを変えた時にそれを反映するのに対し、Hot Restart はアプリを再起動させるのに近いです。 Hot Reload した時に「なんか表示がおかしいかも?」と思ったら、Hot Restart することをお勧めします。

Hot Reload に関する詳細は、こちらのドキュメントをご参照ください。

エラーが発生した場合

もしProductRequestで実行時エラーが発生した場合、以下のような原因が考えられます。

  • .envに格納した SUZURI のアクセストークンに問題がある
  • ネットワーク環境が不安定(例えば、VPN を有効にしている等)

error.jpg

エラーの詳細はresponseというオブジェクトに格納されています。Debug Console から確認すると調査しやすいでしょう。

error_desc.jpg

例えば上記のようなエラーメッセージが格納されていた場合、アクセストークンが有効ではないためにステータスコード401が返っています。

商品の画像を表示してみよう

次は、取得できた商品の情報を使って画面に表示させてみましょう。 引き続き、lib/main.dartを編集していきます。 _productListメソッドの中身を変更し、商品の情報を使って UI を構築します。

Widget _productsList(BuildContext context) {
  final store = context.watch<ProductListStore>();
  final products = store.products;
  if (products.isEmpty) {
    /*省略*/
  } else {
    return Container(
      // EmptyProductListと同じく、GridView.builderでグリッドビューのウィジェットを表示する
      child: GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          crossAxisSpacing: 16,
          mainAxisSpacing: 16,
          childAspectRatio: 0.7,
        ),
        // storeから取得できたproductsの数を使う
        itemCount: products.length,
        itemBuilder: (context, index) {
          return Container(
            margin: EdgeInsets.all(16),
            // itemBuilderのindexには、表示されるセルの番号が格納されている
            // Image.networkは画像のURLを渡すと、画像のダウンロードと表示をいい感じにやってくれる
            // product.sampleImageUrlには商品のサンプル画像のURLが格納されている
            child: Image.network(products[index].sampleImageUrl),
          );
        },
      ),
    );
  }

Hot Restart を実行します。 以下のように、商品の画像がグリッド表示されるはずです。 API から取得できる商品には個人差があります。

product_images.png

画像以外の商品情報も表示してみる

商品の情報はProductというモデルクラスに格納されています。 次は、商品名と値段を画像の下に表示させてみましょう。

まずは、商品の情報を含んだProductCartというウィジェットを作ります。 lib/componentsというディレクトリを作り、その中にproduct_card.dartファイルを配置します。

import 'package:flutter/material.dart';
import 'package:flutter_hands_on/models/product.dart';

// StatelessWidgetを継承
class ProductCard extends StatelessWidget {
  final Product product;

  // コンストラクタでProductを受け取り、フィールドに格納
  // {}はoptional named parameterと呼ばれるもので、this.productはフィールドにセットするためのシンタックスシュガーです
  ProductCard({this.product});

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.all(16),
      // Columnはウィジェットを縦に積むことができるウィジェット
      child: Column(
        // childrenにはList<Widget>を渡す
        // 上から表示したい順にウィジェットを配置する
        children: <Widget>[
          Image.network(product.sampleImageUrl),
          // SizedBoxはウィジェットのサイズを固定するためのウィジェット
          // heightやwidthを指定すると、childのウィジェットのサイズがその通りになる便利なウィジェット
          SizedBox(
            height: 40,
            // Product.titleは商品名を表す
            child: Text(product.title),
          ),
          // Product.priceにはその商品の金額が格納されている
          Text("${product.price.toString()}円"),
        ],
      ),
    );
  }
}

ProductCardウィジェットが作れたら、それをGridViewitemBuilderで使ってみましょう。

lib/main.dartを以下のように編集します。

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
// ProductCardをimportしておく
import 'package:flutter_hands_on/components/product_card.dart';
import 'package:flutter_hands_on/stores/product_list_store.dart';
import 'package:provider/provider.dart';
/*省略*/

Widget _productsList(BuildContext context) {
  final store = context.watch<ProductListStore>(context);
  final products = store.products;
  if (products.isEmpty) {
    /*省略*/
  } else {
    return Container(
      child: GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          crossAxisSpacing: 16,
          mainAxisSpacing: 16,
          childAspectRatio: 0.7,
        ),
        itemCount: products.length,
        itemBuilder: (context, index) {
          // itemBuilderで直接Imageウィジェットを返すのではなく、ProductCardウィジェットを返す
          return ProductCard(product: products[index]);
        },
      ),
    );
  }

Hot Reload を実行すると、商品名と金額が表示されるようになります。

product_cards.png

商品をタップしたら、画面遷移をさせて商品の詳細情報を表示する

これまでの実装で、商品の一覧が表示できるようになりました。 今度は、商品をタップしたら詳細画面が表示されるようにしてみましょう。

商品詳細の表示をするための大まかな流れは以下の通りです。

  • ProductCardウィジェットがタップされたら、それを検知する
  • タップされた商品の情報を元に商品詳細画面のウィジェットを用意して、遷移する

では、まずタップを検知できるようにします。 タップを検知するためには、GestureDetectorという便利なウィジェットを使います。

lib/components/product_card.dartを編集します。

class ProductCard extends StatelessWidget {
  /*省略*/
  @override
  Widget build(BuildContext context) {
    // ContainerをGestureDetectorでラップする
    return GestureDetector(
      // onTapは、childウィジェットがタップされたら発火する
      onTap: () async {
        print("tapped");
      },
      // Container自体は変更不要
      child: Container(
        margin: EdgeInsets.all(16),
        child: Column(
          children: <Widget>[
            Image.network(product.sampleImageUrl),
            SizedBox(
              height: 40,
              child: Text(product.title),
            ),
            Text("${product.price.toString()}円"),
          ],
        ),
      ),
    );
  }
}

このようにGestureDetectorウィジェットでラップするだけで、childのウィジェットに対するタップイベントが検知できます。 タップイベントが発生したら、onTapに配置したデリゲートが発火します。

今回の例では単純にprintするだけの処理が記述されています。これは Debug Console に表示されるので、実際にタップして試してみましょう。

tap_event.jpg

タップが検出できるようになったので、今度は遷移先のウィジェットを作っていきます。 lib/pagesディレクトリを作り、その中にproduct_detail.dartファイルを作成します。

import 'package:flutter/material.dart';
import 'package:flutter_hands_on/models/product.dart';

class ProductDetail extends StatelessWidget {
  // 画面を遷移するために必要なウィジェットの名前を定義する
  static const routeName = "/productDetail";

  @override
  Widget build(BuildContext context) {
    // 画面遷移する際に渡した引数が格納されている(後述)
    // この引数はObject型として扱われるので、明示的にProduct型を指定する必要がある
    final Product product = ModalRoute.of(context).settings.arguments;
    // 新しい画面を表示したいので、Scaffoldウィジェットを返す
    return Scaffold(
      appBar: AppBar(
        title: Text("商品詳細"),
      ),
      body: Container(
        child: Center(
          child: Text(
            // とりあえず商品名でも表示してみる
            product.title,
          ),
        ),
      ),
    );
  }
}

遷移先のウィジェットが作れたので、遷移できるための前準備をします。

2021/01/29追記

こちらのコードもアンチパターンを含んでいたため、修正しました。

追記終わり

lib/main.dartを編集します。

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_hands_on/components/product_card.dart';
// ProductDetailをimport
import 'package:flutter_hands_on/pages/product_detail.dart';
import 'package:flutter_hands_on/stores/product_list_store.dart';
import 'package:provider/provider.dart';

/*省略*/
class MyApp extends StatefulWidget {
  MyApp({Key key}) : super(key: key);

  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      final store = context.read<ProductListStore>();
      if (store.products.isEmpty) {
        store.fetchNextProducts();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
      // MaterialAppのroutesに、遷移したいウィジェットの情報を格納する
      // { ルーティング名: (context) => 表示したいウィジェット, }という形式で記述する
      // {}はMapを表す、ここでは Map<String, Widget Function(BuildContext)>のこと
      routes: {
        ProductDetail.routeName: (context) => ProductDetail(),
      },
    );
  }
}

routesが記述できたら遷移の準備は完了です。onTapで遷移させる処理を書きましょう。

lib/components/product_card.dartを編集します。

import 'package:flutter/material.dart';
import 'package:flutter_hands_on/models/product.dart';
// ProductDetailをimport
import 'package:flutter_hands_on/pages/product_detail.dart';

class ProductCard extends StatelessWidget {
  final Product product;

  ProductCard({this.product});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () async {
        // Navigator.of(context).pushNamedで遷移を実行する
        // 第一引数はルーティング名、argumentsはoptionalでパラメータを渡せる
        // ProductDetailで書いた通り、遷移先のウィジェットでは、
        // ModalRoute.of(context).settings.arguments でこの引数が取得できる
        Navigator.of(context).pushNamed(
          ProductDetail.routeName,
          arguments: this.product,
        );
      },
      child: Container(
        /*省略*/
    );
  }
}

ここまで実装できたら、タップで画面遷移ができるようになっているはずです。

empty_product_detail.png

左上の戻るボタンを押すと、前の画面に戻れます。 このボタンはScaffoldが自動で追加してくれています。とても便利ですね。

商品詳細を充実させる

ここまでの実装で、画面遷移ができるようになりましたが、まだ詳細と言えるような情報は表示できていません。 拡充させましょう。

lib/pages/product_detail.dartを編集して、情報を追加していきます。

import 'package:flutter/material.dart';
import 'package:flutter_hands_on/models/product.dart';

class ProductDetail extends StatelessWidget {
  static const routeName = "/productDetail";

  @override
  Widget build(BuildContext context) {
    /*省略*/
  }

  Widget _body(BuildContext context, Product product) {
    // SingleChildScrollViewは、childウィジェットが画面に収まりきらない時にスクロールで表示できるようにするウィジェット
    return SingleChildScrollView(
      // Columnは下にどんどん伸びていくので、ScrollViewと相性が良い
      child: Column(
        children: <Widget>[
          Center(
            child: Image.network(product.sampleImageUrl),
          ),
          Text(
            product.title,
            style: TextStyle(
              fontWeight: FontWeight.bold,
            ),
          ),
          Text(product.item.humanizeName),
          Text("${product.price.toString()}円"),
          Text("作った人: ${product.material.user.name}"),
          // product.material.descriptionは商品の説明
          // 空っぽであることもあるので、三項演算子で出すウィジェットを変える
          // String.isEmtpyは空文字の時trueを返す
          product.material.description.isEmpty
            ? Container()
            : _descriptionSection(context, product),
        ],
      ),
    );
  }

  Widget _descriptionSection(BuildContext context, Product product) {
    return Container(
      margin: EdgeInsets.only(top: 16.0),
      child: Column(
        children: <Widget>[
          Text(
            "このアイテムについて",
            style: TextStyle(
              color: Colors.grey,
            ),
          ),
          Container(
            margin: EdgeInsets.all(16.0),
            child: Text(product.material.description),
          ),
        ],
      ),
    );
  }
}

ここまで実装できたら、商品詳細を開いてみましょう。 説明が存在する商品であれば、こんな感じに表示されるはずです。 説明がない商品もあるので、表示されていなかったら別の商品を選んでみてください。

product_detail.png

これで商品一覧を表示して、タップで商品詳細に移動できるミニ SUZURI クライアントの完成です。 お疲れ様でした!

Next Step

Flutter への理解と興味が深まった方は、タブによる複数ページの出しわけや、ログイン機能の実装にもチャレンジしてみましょう。 loginというブランチが用意されているので、そちらをチェックアウトするとどんな機能か確認ができます。

おわりに

先日、ペパボ・はてな技術大会〜@オンライン にて登壇しました。

発表した内容の通り、現在 SUZURI の Android アプリを Flutter で作っています。 Flutter に興味がある方の採用募集をしていますので、気になった方はぜひご連絡ください!