先日、社内で Flutter のハンズオンを行ったので、その際に使用した資料を公開します。
Flutter とは?
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 および 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 をインストールします。
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 にログインした状態で以下のページにアクセスします。
表示する
ボタンを押し、パスワードを入力するとアクセストークンが表示されます。
このアクセストークンは、他人に知られないようにしてください。
もし知られてしまった場合は、再生成する
ボタンでアクセストークンを再生成することをお勧めします。
アクセストークンをプロジェクトに組み込む
アクセストークンが取得できたら、リポジトリの直下に.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
ボタンを押して実行してみましょう。
ボタンが有効になっていない場合は、以下の項目について確認してみてください。
- Fluter のプラグインがインストールされていることを確認してください。
- 実行デバイスが指定されていることを確認してください。
No Device
と表示されている場所をクリックし、シミュレータを指定すれば大丈夫です。
また、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
こんな画面が表示されたら、準備完了です。
Flutter のコードを書いてみよう!
それでは、ここから実際に Flutter のコードを書いてアプリケーションを作っていきます。
ご注意!
SUZURI の公開 API にはレートリミットが設定されています。短時間に大量のリクエストを送信してしまうと、一時的に API の利用が制限される場合があります。制限の範囲内でご利用ください。
API に関する詳細はこちらをご参照ください。
商品のリストを作成する
まずは、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
はシンプルなマテリアルデザインライクの見た目を提供してくれるウィジェットで、title
とbody
を与えると、それを元に画面を生成します。
では、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 ボタンを押してみましょう。
リロードが終わると、以下のような画面に切り替わります。
API から商品のリストを取得して、アプリ内で保持できるようにする
SUZURI の公開 API には、商品のリストを取得するエンドポイントがあります。 このエンドポイントから商品の情報を取得して、アプリ内で保持できるようにしてみましょう。
商品のリストを取得するためのリクエストは、lib/requests/product_request.dart
に用意してあります。
StatelessWidget は状態を持たないと説明しましたが、今回は商品のリストという状態を管理する必要があります。
Flutter にはProvider
という、StatelessWidget が状態を意識することなく状態管理ができる便利なライブラリがあります。
詳細は省略しますが、ChangeNotifier
とChangeNotifierProvider
を使って、状態の管理・状態の変更通知を管理できます。
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)
の呼び出しを避けることを推奨しています。
- https://github.com/rrousselGit/provider/issues/382
- https://github.com/rrousselGit/provider/issues/236
Provider v4.0.0からは、context.read
というメソッドでlisten: false
なChangeNotifierProvider
への参照を取得できるようになりました。
主な違いとしては、context.read
をbuild
メソッドの内部で呼び出すと実行時エラーが発生し、間違った使い方であることを開発者に知らせてくれます。
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 Reload との違いは、Hot Reload がウィジェットの見た目などを変えた時にそれを反映するのに対し、Hot Restart はアプリを再起動させるのに近いです。 Hot Reload した時に「なんか表示がおかしいかも?」と思ったら、Hot Restart することをお勧めします。
Hot Reload に関する詳細は、こちらのドキュメントをご参照ください。
エラーが発生した場合
もしProductRequest
で実行時エラーが発生した場合、以下のような原因が考えられます。
.env
に格納した SUZURI のアクセストークンに問題がある- ネットワーク環境が不安定(例えば、VPN を有効にしている等)
エラーの詳細はresponse
というオブジェクトに格納されています。Debug Console から確認すると調査しやすいでしょう。
例えば上記のようなエラーメッセージが格納されていた場合、アクセストークンが有効ではないためにステータスコード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
というモデルクラスに格納されています。
次は、商品名と値段を画像の下に表示させてみましょう。
まずは、商品の情報を含んだ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
ウィジェットが作れたら、それをGridView
のitemBuilder
で使ってみましょう。
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 を実行すると、商品名と金額が表示されるようになります。
商品をタップしたら、画面遷移をさせて商品の詳細情報を表示する
これまでの実装で、商品の一覧が表示できるようになりました。 今度は、商品をタップしたら詳細画面が表示されるようにしてみましょう。
商品詳細の表示をするための大まかな流れは以下の通りです。
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 に表示されるので、実際にタップして試してみましょう。
タップが検出できるようになったので、今度は遷移先のウィジェットを作っていきます。
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(
/*省略*/
);
}
}
ここまで実装できたら、タップで画面遷移ができるようになっているはずです。
左上の戻るボタンを押すと、前の画面に戻れます。
このボタンは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),
),
],
),
);
}
}
ここまで実装できたら、商品詳細を開いてみましょう。 説明が存在する商品であれば、こんな感じに表示されるはずです。 説明がない商品もあるので、表示されていなかったら別の商品を選んでみてください。
これで商品一覧を表示して、タップで商品詳細に移動できるミニ SUZURI クライアントの完成です。 お疲れ様でした!
Next Step
Flutter への理解と興味が深まった方は、タブによる複数ページの出しわけや、ログイン機能の実装にもチャレンジしてみましょう。
login
というブランチが用意されているので、そちらをチェックアウトするとどんな機能か確認ができます。
おわりに
先日、ペパボ・はてな技術大会〜@オンライン にて登壇しました。
発表した内容の通り、現在 SUZURI の Android アプリを Flutter で作っています。 Flutter に興味がある方の採用募集をしていますので、気になった方はぜひご連絡ください!