Flutterに入門してみた
概要
会社でFlutterの講座を受けることがあったのですが、思ったより簡単にレイアウトを作ることができて、興味を持ちました。
本を読んで体系的に勉強したので、学んだことを残しておきます。
対象読者
- Flutter入門レベルの方(環境構築までは行っている)
- ゲームなどではない、グラフィック描画を使用しないスマホアプリを作ってみたい方
1. 基本
画面を構成するもの
Flutterでは画面表示はウィジェットという部品によって作成されます。
ウィジェットは、ボタンのように操作できるものがあったり、他のウィジェットをまとめたり、レイアウトを整えるようないろいろなものが存在します。
それらを組み合わせて画面を構成します。
アプリの画面はウィジェットの中にウィジェットを階層的に組み込んで作成されます。(イメージとしてはHTML)
StalelessWidget
StatelessWidgetはState(状態)を持たないWidgetのベースになるクラスです。
ウィジェットは状態を持たないStatelessWidgetか、StatefulWidgetのいずれかを継承して作成します。
MaterialAppクラス
MaterialAppクラスはマテリアルデザインのアプリを管理するクラスです。
MaterialAppクラスを使用することで、マテリアルデザインによるアプリが表示されるようになります。
2. 画面の作成
シンプルな画面
StatelessWidget
とMaterialApp
を組み合わせて最小の画面を作ってみました。
Scaffold
には、マテリアルデザインの基本的なデザインとレイアウトが組み込まれています。
ここに肉付けしていくことで、一般的なデザインのアプリが作成されます。
AppBarを指定することにより画面上部によくあるバーを作ることができます。
dartではconst
キーワードをつけると、コンパイル時に値が決定するようになるらしい。(メモリの書き換えもできないらしい)
参考: https://zenn.dev/razokulover/articles/61380323a73e00572789
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key});
Widget build(BuildContext context) {
return Scaffold(
// 画面上部のアプリケーションバー部分
appBar: AppBar(
title: const Text('App Name'),
),
// アプリケーションバーの下の空白エリア
body: const Text(
"Hello Flutter!",
),
);
}
}
ステート(状態)を操作してみる
このままだと、何も動かないアプリになってしまいます。
これをボタンをクリックするとテキストが変わるようにしてみました。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: MyHomePage(),
);
}
}
// 継承元をStatefulWidgetに変更
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
// 対応するStateのクラスを作成
State<StatefulWidget> createState() => MyHomePageState();
}
// MyHomePageの状態に関する部分を対応する
class MyHomePageState extends State<MyHomePage> {
String _message = 'Hello';
void _setMessage() {
// setState()で囲うことで実行後に画面が再描画される(Reactのようなイメージ)
setState(() {
_message = 'タップしました!';
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('App Name'),
),
body: Text(
_message,
),
// 右下に表示される浮いているボタン
floatingActionButton: FloatingActionButton(
// タップしたときに実行する関数を渡す
onPressed: _setMessage,
tooltip: 'set message.',
child: const Icon(Icons.star),
),
);
}
}
レイアウトに使用できるもの
Center
中央寄せをすることができるWidgetです。
中央寄せしたいものをChildに指定します。
Center(
child: Text('test')
)
Container
細かな配置、余白などの設定を行えるWidgetです。
Container(
// 配置場所、上下左右を9箇所に分けており、どれかを指定する
Alignnment: Alignment.bottomCenter
// 余白幅の設定
padding: EdgeInsets.all(10.0),
)
Column
Columnはchildrenで指定した複数のWidgetを縦に並べて配置することができるWidgetです。
Column(
// Column自身の配置位置を指定する
mainAxisAligment: MainAxisAligment.start,
// childrenの配置位置を指定する
crossAxisAlignment: CrossAxisAlignment.center,
// ウィジェットのサイズを指定
mainAxisSize: MainAxisSize.max,
// 表示したい要素たち
children: <Widget>[
Text("first"),
Text("second"),
],
)
ROW
Rowはchildrenで指定した複数のWidgetを横に並べて配置することができるWidgetです。
Row(
// Row自身の配置位置を指定する
mainAxisAligment: MainAxisAligment.start,
// childrenの配置位置を指定する
crossAxisAlignment: CrossAxisAlignment.center,
// ウィジェットのサイズを指定
mainAxisSize: MainAxisSize.max,
// 表示したい要素たち
children: <Widget>[
Text("first"),
Text("second"),
],
)
3. UIに使用できるWidget
Button
Buttonには以下のような種類があります。
- TextButton
- テキストを表示できるのボタン
- ElevatedButton
- すこし立体的に見えるボタン
- IconButton
- アイコンを表示できるボタン
- FloatingActionButton
- scaffoldのfloatingActionButtonに指定することで、画面の右下に表示されるボタン
- 一応他のボタンと同じように使う事もできる
- RawMaterialButton
- テーマなどの影響を受けないボタン、自分で使用する色をすべて設定して利用する
TextField
文字を入力することのできるWidgetです。
実際に使用してみます。
このプログラムはTextFieldに文字を入力してボタンを押すと、you said:
の後に入力した文字を表示してくれます。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
State<StatefulWidget> createState() => MyHomePageState();
}
class MyHomePageState extends State<MyHomePage> {
static var _message = 'Hello';
// 値を管理するクラス
static final _controller = TextEditingController();
void buttonPressed() {
setState(() {
// .textで入力されている文字を取得することができる
_message = "you said: ${_controller.text}";
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('App Name'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
_message,
style: const TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w400,
fontFamily: "Roboto",
),
),
),
Padding(
padding: const EdgeInsets.all(10.0),
child: TextField(
// controllerを指定することで値が変わると_controller.textに値が反映される
controller: _controller,
style: const TextStyle(
fontSize: 28.0,
color: Color(0xff000000),
fontWeight: FontWeight.w400,
fontFamily: "Roboto",
),
),
),
ElevatedButton(
onPressed: buttonPressed,
child: const Text(
"Push me",
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w400,
fontFamily: "Roboto",
),
),
),
],
),
),
);
}
}
また、例は記載しませんが、TextField
のonChanged
に関数を設定することで入力されるたびに発火するようになります。
Checkbox/Switch
二者択一の値を入力するのに使われるのがCheckbox/SwitchというWidgetです。
CheckboxとSwicthは外観が違うだけで、提供されている機能は同一です。
Checkbox(
// valueにはチェックされたかを保持したい変数を指定する
value: _checked,
// チェック状況が変わるたびに実行したい関数を指定する
onChanged: donothing()
)
Radio
複数の値から1つを選択させたいときに使用するWidgetです。
Row(
children: <Widget>[
Radio<String>(
// 選択されたときの値
value: 'A',
// グループで選択された値を示す(この値がvalueと等しいとき、radioが選択された状態になる
groupValue: _selected,
// チェック状況が変わるたびに実行したい関数を指定する
onChanged: checkChanged,
),
Radio<String>(
value: 'B',
groupValue: _selected,
onChanged: checkChanged,
)
]
)
// valueには選択された値が入っている
void checkChanged(String? value) {
setState(() {
_selected = value ?? 'nodoka';
});
}
DropdownButton
Radioと同じく、複数の値から1つを選択させたいときに使用するWidgetです。
DropdownButton<String>(
onChanged: popupSelected,
value: _selected,
// 選択肢
items: <DropdownMenuItem<String>>[
const DropdownMenuItem<String>(value: 'one', child: const Text('one')),
const DropdownMenuItem<String>(value: 'two', child: const Text('two')),
const DropdownMenuItem<String>(value: 'three', child: const Text('three')),
]
)
void popupSelected(String? value) {
setState(() {
_selected = value ?? 'not selected...';
});
}
PopupMenuButton
ポップアップメニューを呼び出すための専用ボタンです。
使い方は、DropdownButtonと同じです。
Slider
数字をアナログ的に入力するのに用いられるWidgetです。
void _onChanged(double value) {
setState(() {
_value = value.floorToDouble();
})
}
Slider(
onChanged: _onChanged,
// 最小値
min: 0.0,
// 最大値
max: 100.0,
// 分割数、今回の場合は100 - 0 / 20 で5ずつ値が選択される。指定しない場合は分割されずなめらかに値が変化する
divisions: 20,
// 現在選択されている値
value: _value
)
showDialog
アラートなどを画面に表示するのに使用する関数です。
showDialog(
// どのウィジェット上に表示するか
context: context,
// 表示したいwidgetをreturnする
builder: (BuildContext context) => AlertDialog(
// アラートのタイトル
title: Text('hello'),
// 内容
content: Text('this is sample'),
)
)
4. 複雑な構造のウィジェット
AppBar
最初にも紹介したAppBarですが、いろいろな指定が行なえます
AppBar(
// 表示したいタイトル
title: Text('title'),
// 左端に表示される
leading: TextButton(
child: Text('test')
),
// タイトルの右側に表示される
actions: <Widget>[],
// 下に表示される内容
bottom: PreferredSize(
// 拡張する長さ
preferredSize: const Size.fromHeight(30),
// 表示する内容
child: Text('testtest')
),
)
BottomNavigationBar
AppBarは画面の上部にバーを表示していましたが、BottomNavigationBarを使用すれば、下部にもバーを表示することができます。
BottomNavigationBar(
// 現在選択されているindex
currentIndex: _index,
// タップ時のevent, valueには選択した要素のindexが入る
onTap: (int value) {
var items = ['Android', 'Favorite', 'Home'];
setState(
() {
_index = value;
_message = 'selected: ${items[_index]}';
},
);
},
items: const [
// それぞれのアイコン
BottomNavigationBarItem(
label: 'Android',
icon: Icon(
Icons.android,
color: Colors.black,
),
),
BottomNavigationBarItem(
label: 'Favorite',
icon: Icon(
Icons.favorite,
color: Colors.red,
),
),
BottomNavigationBarItem(
label: 'Home',
icon: Icon(
Icons.home,
color: Colors.white,
),
),
],
)
ListView
リストを表示するのに使うWidgetです。
通常は、後述するListTileと組み合わせ使用します。
ListTile
List表示は通常、表示されている要素の1つをタップして操作するなどの使い方をします。
それを簡単に実現できるのがListTileです。
ListView(
// 追加された項目に応じて、大きさを変えるかどうか
shrinkWrap: true,
// 余白
padding: EdgeInsets.all(20.0),
// リスト表示したい要素
children: <Widget>[
ListTile(
// 現在選択されているかどうか
selected: /* 省略 */,
// 左端に表示するアイコン
leading: Icon(Icons.star),
// 項目に表示する内容
title: Text('title'),
// タップされたときのevent
onTap: _onTap,
// 長時間押されたときのEvent
onLongPress: _onLongPress
),
ListTile(
selected: /* 省略 */,
leading: Icon(Icons.home),
title: Text('title'),
onTap: _onTap,
onLongPress: _onLongPress
)
]
)
SingleChildScrollView
1つのウィジェットを子供にもち、子供の長さによって自動でスクロール可能にしてくれます。
SingleChildScrollView(
child: /* スクロールしたい要素 */
)
ナビゲーション/ルーティング
Webアプリケーションと同じように、画面が1つで完結していない場合が多いと思います。
画面を遷移したいときに使用するのがナビゲーション(Navigator)です。
以下のような動きを行います
- 移動先のウィジェットを追加するとそのウィジェットに移動する(進む)
Navigator.push()
- 保管されたウィジェットを取り出すと、そのウィジェットに移動する(戻る)
Navigator.pop()
Navigatorの移動を行うコードになります。
class _FirstScreen extends StatelessWidget {
final int _screenIndex = 1;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('firstScreen')),
body: Center(
child: Container(
child: const Text('Body content'),
),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _screenIndex,
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
label: 'Home',
icon: Icon(Icons.home, size: 32),
),
BottomNavigationBarItem(
label: 'next',
icon: Icon(Icons.navigate_next, size: 32),
),
],
// valueにはタップされたitemsの添字が入る
onTap: (int value) {
if (value == _screenIndex) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => _SecondScreen()),
);
}
},
),
);
}
}
class _SecondScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('SecondScreen')),
body: Center(
child: Container(
child: const Text('Body content'),
),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: 1,
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
label: 'back',
icon: Icon(Icons.navigate_before, size: 32),
),
BottomNavigationBarItem(
label: '?',
icon: Icon(Icons.question_mark, size: 32),
),
],
onTap: (int value) {
if (value == 0) {
// 遷移元に戻る
Navigator.pop(context);
}
},
),
);
}
}
また、routes
というプロパティに対応するアドレス、呼び出すwidgetを定義しておくと値や変数を使ってルーティングすることができます。
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Generated App',
theme: ThemeData(
primarySwatch: Colors.blue,
primaryColor: const Color(0xff2196f3),
canvasColor: const Color(0xfffafafa),
),
routes: {
'/': (context) => _FirstScreen(),
'/second': (context) => const _SecondScreen('second screen'),
'/third': (context) => const _SecondScreen('third screen'),
},
);
}
}
// 遷移したいとき
Navigator.pushNamed(context, '/second');
タブ
複数の表示を切り替え表示するのにタブは使われています。
タブは、以下の要素から構成されています。
- 表示を切り替えるための部分
- コンテンツの内容
このUIを作成するのに使用するのが、TabBar
とTabBarView
です。
以下は、簡単なタブによる表示の切り替えをおこなうコードです。
// アニメーションのコールバック呼び出しに関するTickerを使いたいため、withで指定
class MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
static const List<Tab> tabs = <Tab>[
Tab(text: 'One'),
Tab(text: 'two'),
Tab(text: 'three')
];
// コンストラクタ作成時に初期化しないため、lateで遅延評価
late TabController _tabController;
// initState()をオーバーライドすると、インスタンス作成後に実行される。
void initState() {
super.initState();
_tabController = TabController(length: tabs.length, vsync: this);
}
Widget createTab(Tab tab) {
return Center(
child: Text(
'This is ${tab.text} Tab.',
style: const TextStyle(fontSize: 32.0, color: Colors.blue),
),
);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My app'),
bottom: TabBar(controller: _tabController, tabs: tabs),
)
body: TabBarView(
controller: _tabController,
children: tabs.map((Tab tab) {
return createTab(tab);
}).toList(),
),
);
}
}
ドロワー
新しいスマホアプリだと、三本線のアイコンをクリックすると、画面端からリスト表示するようなUIを見かけます。
Scaffold
のdrawer
にDrawer
を設定することで、作成できます。
class MyHomePageState extends State<MyHomePage> {
static var _items = <Widget>[];
static var _message = 'ok.';
static var _tapped = 0;
void tapItem() {
// 表示されているドロワーを閉じている
Navigator.pop(context);
setState(() {
_message = 'tapped:[$_tapped]';
});
}
void initState() {
super.initState();
for (var i = 0; i < 5; i++) {
var item = ListTile(
leading: const Icon(Icons.android),
title: Text('No, $i'),
onTap: () {
_tapped = i;
tapItem();
},
);
_items.add(item);
}
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My app'),
),
body: Center(
child: Text(_message),
),
drawer: Drawer(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(20.0),
children: _items,
),
),
);
}
}
5. データアクセス
ファイルアクセス
アプリのさまざまな状態などは、何もしないと終了したときに削除されてしまいます。
データを永続化するのにはいくつか方法がありますが、一番シンプルなのは端末内にファイルとして保存しておくことだと思います。
ファイルの操作を行うためにはFile
というクラスを使用します。
File(ファイルへのパス)
ファイルへの書き出し
ファイルへの書き出しにはwriteAsString
を使用します。
この処理は非同期(終了を待たない)で実行されるため同期で実行する場合はwriteAsStringSync
を利用します。
File('./test.txt').writeAsString(['test', 'test'])
ファイルからの読み込み
ファイルからの読み込みはreadAsString
を使用します。
書き出しの場合と同じく非同期で実行されるため、同期で実行する場合はreadAsStringSync
を利用します。
// 例外がthrowされることがあるため、tryの中で実行する
try {
f = File('./test.txt').readAsString();
} catch (e) {
// 必要であれば、エラーハンドリングを行う
}
設定情報の利用
アプリの設定情報などは、テキストファイルでやり取りをすると、真偽値の値や数値、文字列の保存など、考えなくてはいけないことが多く面倒です。
そういったアプリ固有の単純な値を保存したいときは、Shared Preferences
を使うことで、簡単に保存、取得が行なえます。
SharedPreferences.getInstance().then((SharedPreferences prefs) {
// volume というkeyに100を入れる。
prefs.setInt('volume', 100);
// 真偽値を保存したい場合は、setBool
prefs.setBool('agreeNotification', true);
// 保存した値を取得したい場合は、getIntなどで取得できる
int volume = prefs.getInt('volume', 100);
})
ネットワークアクセス
WebのAPI経由で、データを取得/表示したいことがあると思います。
その時にはHttpClient
というクラスを使うことができます。
以下のような使い方になります。
// インスタンス作成は非同期で行われるためawaitで作成を待つ
HttpClient client = await HttpClient();
// HTTPでGETをする場合
HttpClientRequest req = await client.get('example.com', '80', '/test');
// HTTPSでGETをする場合
await client.getUrl(Url('https://example.com/test'));
// POSTの場合は追加でbodyに書き込みが必要
HttpClientRequest postReq = await client.postUrl(Url('https://example.com/test'));
postReq.write({ 'id': 'test' });
// ここでリクエストが完了
HttpClientResponse res = req.close();
// レスポンスをutf8でデコードして、レスポンスのbodyを取得
final value = await res.transform(utf8.decoder).join();
Discussion