目的
Flutter自体は何度か使ったことがあるものの、ただ「使える」だけでなく、ちゃんと理解して使えるようになるべく、一度基本に立ち戻り、公式のドキュメントにしっかり目を通すことにした。
記事の内容はほぼ以下のページの和訳(+意訳)ですが、1週間後の自分と、自分と同じように英語のドキュメントを敬遠している方のために記録しました。
今回読んだ公式ドキュメントのページ
内容
Hello world
import 'package:flutter/material.dart';
void main() {
runApp(
Center(
child: Text(
'Hello, world!',
textDirection: TextDirection.ltr,
),
),
);
}
- 最小限のFlutterはアプリは、上記のように
runApp()
の引数にWidgetを指定することで作成できる - 上記の例では、
Center
Widgetとその子WidgetであるText
Widgetが使われている。 - 上記では、
textDirection
が指定されているが、Center
Widgetの代わりにMaterialApp
Widgetを指定すると、自動で設定される。 - Widgetには、
StatelessWidget
とStatefulWidget
の2種類がある。 - そのWidgetが状態を持つか否かに応じて、どちらかの拡張として新たなWidgetを定義する。
- Widgetのメインとなるのが
build
関数である。
Basic widgets
- Flutterには次のような強力な基本Widgetが備わっている
Text
- スタイルが適用されたテキストを作成できる。
Row, Column
- 水平方向(
Row
)と垂直方向(Column
)といったflexibleなレイアウトを作成できる。 - Webのflexboxレイアウトがベースとなっている。
(Vue.jsで言うところの
v-row
,v-col
、SwiftUIのVStack
,HStack
と似ているのかな?)
Stack
- 縦か横に並べる代わりに、
Stack
を使うことで、描画された順に重ねるように配置することができる。 -
Stack
の子として、Positioned
Widgetを使うと、Stack
の上端、下端、右端、左端に相対的に配置できる。 - Webで言うところの、absoluteレイアウトがベースとなっている。
Container
- 長方形の要素を作成できる。
-
BoxDecoration
と合わせて使うことで、背景や境界、陰影のような装飾ができる。 - size, margin, padding, constraintも適用することができる。
- さらに、matrixを使うことで3次元空間に変換できる(!?)
マテリアルアイコン
pubspec.yaml
にのflutter
セクションにuses-material-design: true
を指定することで、マテリアルアイコンが使えるようになる。
上記Widgetの使用例
See the Pen flutter-hello-world-2 by popy1017 (@popy1017) on CodePen.
-
MyAppBar
Widgetは、高さ56px(デバイス依存)・左右のpaddingが8pxのContainer
Widgetを作成している。 -
MyAppBar
Widgetは、子WidgetとしてRow
Widgetを使用している。 -
title
Widgetは、Expanded
でマークされている。 -
Expanded
Widgetは、他の子WIdgetによって消費されていない残りのスペースを満たすように展開されることを意味している。 - 引数
flex
を指定し利用するスペースの割合を決めることで、複数の子Expanded
Widgetを持つことができる。 -
MyScaffold
Widgetは、垂直の列に子Widgetを並べている。 - 列の最上部には、
MyAppBar
Widgetを配置し、タイトルで使うためにText
Widgetを渡している。 - 他のWidgetに引数としてWidgetを渡すのは、様々な使い道で再利用できる一般的なWidgetを作る強力な方法である。
-
MyScaffold
Widgetは、最後にExpanded
を使ってbodyの残りのスペースを埋めている。
Using Material Components
- Flutterは、Material Designに従ったアプリを作るのに役立つ多くのWidgetを提供している。
- マテリアルアプリは、
MaterialApp
Widgetで始める。 -
MaterialApp
Widgetは、アプリのRootに多く便利なWidget()を作成してくれる。 -
Navigator
はその一つで、文字列で識別されるWidgetのスタック(Route)を管理する。 -
Navigator
を使うと、画面間の移動がスムーズにできる。 -
MaterialApp
は使っても使わなくても良いが、良い方法である。 - 上記のサンプルを書き換えると、以下のようになる。
See the Pen flutter_materialApp_widget by popy1017 (@popy1017) on CodePen.
-
MyAppBar
widgetはAppBar
widgetで、MyScaffold
widgetはScaffold
widgetで置き換えている。 - これらは、
material.dart
のもので、App barでは陰影があったり、フローティングアクションボタンがあったりなど、よりマテリアルっぽく見えるだろう。 - Widgetは、他のWidgetに引数として渡されることに注意する。
-
Scaffold
widgetは、名前付きの引数として様々なWidgetを受け取り、それぞれはScaffold
レイアウトの適切な場所に配置される。 - 同様に、
AppBar
widgetには、leading
widgetや、title
widget、action
widgetを渡せる - このパターンはフレームワークを通して繰り返され、独自のWidgetを作る際には検討する必要がある。
- もう1つのデザインとして、iOSっぽい見た目の
Cupertino
デザインが用意されている。
Material デザインのWidgetの方が充実しているらしい。
Handling gestures
class MyButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
print('MyButton was tapped!');
},
child: Container(
height: 36.0,
padding: const EdgeInsets.all(8.0),
margin: const EdgeInsets.symmetric(horizontal: 8.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.0),
color: Colors.lightGreen[500],
),
child: Center(
child: Text('Engage'),
),
),
);
}
}
-
GestureDetector
widgetは視覚表現は持たないが、代わりにユーザーによるジェスチャーを検出する。 - ユーザーが
Container
をタップしたとき、GestureDetector
はonTap()
関数を呼ぶ。 -
GestureDetector
ではタップの他にも、ドラッグやスケールといったジェスチャーも検出できる。 -
GestureDetector
は他のWidgetに任意のコールバックを提供している。 - 例えば、
IconButton, RaisedButton, FloatingActionButton
widgetは、タップ時に発火するonPressed()
コールバックを持っている。
Changing widgets in response to input
-
StatelessWidget
は、親のWidgetから引数を受け取り、それをfinal
メンバ変数に保持している。 - Widgetが
build()
を要求されると、これらの保存された値を使用して、Widgetが作成される。 - 例えば、ユーザーのインプットを受け付けるような、より複雑なアプリを作るためには、いくつかの状態を保持する必要がある。
- Flutterは、状態を保持するために、
StatefulWidge
を使っている。
簡単なアプリ以外では、なるべく
StatefultWidget
を使わず、BLoC
やProvider
で状態管理をした方が良いらしい。2020年5月現在はProvider
による状態管理が主流らしいが、それについては後日勉強する。
class Counter extends StatefulWidget {
// This class is the configuration for the state. It holds the
// values (in this case nothing) provided by the parent and used
// by the build method of the State. Fields in a Widget
// subclass are always marked "final".
@override
_CounterState createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _counter = 0;
void _increment() {
setState(() {
// This call to setState tells the Flutter framework that
// something has changed in this State, which causes it to rerun
// the build method below so that the display can reflect the
// updated values. If you change _counter without calling
// setState(), then the build method won't be called again,
// and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called,
// for instance, as done by the _increment method above.
// The Flutter framework has been optimized to make rerunning
// build methods fast, so that you can just rebuild anything that
// needs updating rather than having to individually change
// instances of widgets.
return Row(
children: <Widget>[
RaisedButton(
onPressed: _increment,
child: Text('Increment'),
),
Text('Count: $_counter'),
],
);
}
}
-
StatefulWidget
とState
が分かれているのは、ライフサイクルが異なるためである。 -
Widget
は一時的なオブジェクトであり、現在の状態におけるアプリの表現に使われる -
State
オブジェクトは、build()
の呼び出し間では永続的であり、情報を記憶することができる - 上記の例では、ユーザーの入力を直接
build()
で使っている。 - より複雑なアプリでは、Widgetの階層の様々な部分が問題の原因の可能性がある。
- 例えば、あるWidgetで日付や場所といった情報を入力させ、別のWidgetでその情報を表示する場合など
- Flutterでは、変更の通知はコールバックによってWidget階層を上に流れる
- 現在の状態は、
StatelessWidget
に下に流れる
class CounterDisplay extends StatelessWidget {
CounterDisplay({this.count});
final int count;
@override
Widget build(BuildContext context) {
return Text('Count: $count');
}
}
class CounterIncrementor extends StatelessWidget {
CounterIncrementor({this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return RaisedButton(
onPressed: onPressed,
child: Text('Increment'),
);
}
}
class Counter extends StatefulWidget {
@override
_CounterState createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _counter = 0;
void _increment() {
setState(() {
++_counter;
});
}
@override
Widget build(BuildContext context) {
return Row(children: <Widget>[
CounterIncrementor(onPressed: _increment),
CounterDisplay(count: _counter),
]);
}
}
- 上記の例では、カウンターを表示する部分(
CounterDisplay
)と、カウンターを変更する部分(CounterIncrementor
)とが明示的に分けてある - 最終的な結果は同じだが、責任の分離ができ、単純さを維持しながら、個々のWigetでより複雑なものをカプセル化できる。
Bringing it all together
- ここでは、架空のショッピングアプリを使って、上記の概念を盛り込んだより完全な例を示す
- ショッピングアプリでは、以下ができる
- 様々な商品を表示する
- ショッピングカートを維持する
class Product {
const Product({this.name});
final String name;
}
typedef void CartChangedCallback(Product product, bool inCart);
class ShoppingListItem extends StatelessWidget {
ShoppingListItem({this.product, this.inCart, this.onCartChanged})
: super(key: ObjectKey(product));
final Product product;
final bool inCart;
final CartChangedCallback onCartChanged;
Color _getColor(BuildContext context) {
// The theme depends on the BuildContext because different parts
// of the tree can have different themes.
// The BuildContext indicates where the build is
// taking place and therefore which theme to use.
return inCart ? Colors.black54 : Theme.of(context).primaryColor;
}
TextStyle _getTextStyle(BuildContext context) {
if (!inCart) return null;
return TextStyle(
color: Colors.black54,
decoration: TextDecoration.lineThrough,
);
}
@override
Widget build(BuildContext context) {
return ListTile(
onTap: () {
onCartChanged(product, inCart);
},
leading: CircleAvatar(
backgroundColor: _getColor(context),
child: Text(product.name[0]),
),
title: Text(product.name, style: _getTextStyle(context)),
);
}
}
-
ShoppingListItem
widgetは、StatelessWidget
の一般的パターンに従っている - コンストラクタで
final
メンバ変数に受け取った値を保持し、build()
で使う - 例えば、booleanの`inCartは、現在のテーマのPrimaryカラーと、グレーの2種類の視覚的変化を与える。
- リストのアイテムをタップしたとき、Widgetは
inCart
の値を直接変えるのではない。 - 代わりに、親Widgetから受け取った
onCartChanged
関数を呼ぶ。 - このパターンでは、高い階層のWidgetで状態を管理することができ、状態を長く保持することが可能である。
- 極端な話、
runApp()
に渡されたWidgetに保存された状態は、アプリの存続期間中保持することができる - 親が
onCartChanged
を受け取ると、親は内部的な状態を更新し、それが新しいinCart
の値を持ったShoppingListItem
の新しいインスタンスのリビルド、作成のトリガーとなる - リビルド時は
ShoppingListItem
の新しいインスタンスを生成するが、フレームワークが前のWidgetと新しいWidgetの差分を比較しているため、軽い操作でできる。
以下は、変更可能な状態を保持する親Widgetの例である。
class ShoppingList extends StatefulWidget {
ShoppingList({Key key, this.products}) : super(key: key);
final List<Product> products;
// The framework calls createState the first time a widget
// appears at a given location in the tree.
// If the parent rebuilds and uses the same type of
// widget (with the same key), the framework re-uses the State object
// instead of creating a new State object.
@override
_ShoppingListState createState() => _ShoppingListState();
}
class _ShoppingListState extends State<ShoppingList> {
Set<Product> _shoppingCart = Set<Product>();
void _handleCartChanged(Product product, bool inCart) {
setState(() {
// When a user changes what's in the cart, you need to change
// _shoppingCart inside a setState call to trigger a rebuild.
// The framework then calls build, below,
// which updates the visual appearance of the app.
if (!inCart)
_shoppingCart.add(product);
else
_shoppingCart.remove(product);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Shopping List'),
),
body: ListView(
padding: EdgeInsets.symmetric(vertical: 8.0),
children: widget.products.map((Product product) {
return ShoppingListItem(
product: product,
inCart: _shoppingCart.contains(product),
onCartChanged: _handleCartChanged,
);
}).toList(),
),
);
}
}
void main() {
runApp(MaterialApp(
title: 'Shopping App',
home: ShoppingList(
products: <Product>[
Product(name: 'Eggs'),
Product(name: 'Flour'),
Product(name: 'Chocolate chips'),
],
),
));
}
-
ShoppingList
クラスは、Widgetが可変な状態を保持するStatefulWidget
の拡張である。 -
ShoppingList
widgetが最初にツリーに追加されるとき、ツリー内のその場所に関連づける新しいインスタンス_ShoppingListState
を作成するために、フレームワークはcreateState()
関数を呼び出す。 -
State
のサブクラスはたいてい、privateであることを示すためにアンダースコアで始まる名前をつける - このWidgetの親がリビルドされるとき、親Widgetは
ShoppingList
の新しいインスタンスを作るが、フレームワークはcreateState()
を再度呼び出すのではなく、すでにツリーにある_ShoppingListState
を再利用する。 - 現在の
ShoppingList
のプロパティにアクセスするために、_ShoppingListState
のプロパティを使うことができる。 - 親Widgetが新しい
ShoppingList
をリビルドして作成する場合、_ShoppingListState
は新しいWidget値で再構築する - Widgetのプロパティが変わったときに通知を受け取りたい場合は、
didUpdateWidget()
をオーバーライドする - それには、
oldWidget
が渡され、古いWidgetと現在のWidget の比較ができる。 -
onCartChanged
コールバックを処理する際、_ShoppingListState
は_shoppingCart
に製品を追加または削除することにより、内部状態を変更する - 内部状態の変更をフレームワークに通知するためには、それらの呼び出しを
setState()
でラップする -
setState
を呼び出すと、このWidgetはdirtyとしてマークされ、スクリーンを更新する必要がある次のタイミングでリビルドされるようにスケジュールされる。 - Widgetの内部状態を変更する際、
setState
を呼び出すのを忘れると、フレームワークはWidgetがdirtyであることを認識できず、build()
を呼ばないかもしれない。つまり状態の変更がUIに反映されない - このやり方で状態を管理することで、子Widgetの作成と更新の分かれたコードを書かなくてすむ
- その代わりに、
build()
を書くだけ
Responding to widget lifecycle events
-
StatefulWidget
のcreateState()
を呼び出した後、フレームワークはツリーに新しい状態オブジェクトを挿入し、その状態オブジェクトのinitState()
を呼び出す。 -
State
のサブクラスは、一度だけ行う必要がある処理を実行するためにinitState()
をオーバーライドできる - 例えば、アニメーションを設定したりとか
- 状態オブジェクトが不要になったときは、
dispose()
が実行される
Keys
- キーを使用して、Widgetのリビルド時に、他のWidgetと一致するWidgetを制御する
- デフォルトでは、
runtimeType
と表示される順番に応じて現在と以前のWidgetを照合する - キーを使用すると、2つのWidgetが同じキーと、同じ
runtimeType
を持つ必要がある - キーは、同じタイプのWidgetのインスタンスをたくさん使うWidgetで役立つ
- 例えば、
ShoppingList
widgetは、ShoppingListItem
インスタンスを表示領域いっぱいに並べている- キーがない場合、現在のbuildの最初のエントリは、スクロールして見えなくなったとしても、いつでも前のbuildの最初のエントリと同期する。(意味がわからん)
Keyの使い方について調査した。
英語の動画だが、日本語字幕もありわかりやすかった。
https://www.youtube.com/watch?v=kn0EOS-ZiIc
なんとなくだが、StatefulWidget
の場合、keyとruntimeTypeを見て再描画するかの判定を行うが、デフォルトではkeyがnullになっているため、runtimeTypeが同じWidgetは変更する必要なしと判定されてしまうっぽい。Vue.jsのv-for
にkey
をつけるのとなんとなく似ている気がする。