📖

Flutterに入門してみた

2022/12/18に公開約18,100字

概要

会社でFlutterの講座を受けることがあったのですが、思ったより簡単にレイアウトを作ることができて、興味を持ちました。

本を読んで体系的に勉強したので、学んだことを残しておきます。

対象読者

  • Flutter入門レベルの方(環境構築までは行っている)
  • ゲームなどではない、グラフィック描画を使用しないスマホアプリを作ってみたい方

1. 基本

画面を構成するもの

Flutterでは画面表示はウィジェットという部品によって作成されます。
ウィジェットは、ボタンのように操作できるものがあったり、他のウィジェットをまとめたり、レイアウトを整えるようないろいろなものが存在します。
それらを組み合わせて画面を構成します。
アプリの画面はウィジェットの中にウィジェットを階層的に組み込んで作成されます。(イメージとしてはHTML)

StalelessWidget

StatelessWidgetはState(状態)を持たないWidgetのベースになるクラスです。
ウィジェットは状態を持たないStatelessWidgetか、StatefulWidgetのいずれかを継承して作成します。

MaterialAppクラス

MaterialAppクラスはマテリアルデザインのアプリを管理するクラスです。
MaterialAppクラスを使用することで、マテリアルデザインによるアプリが表示されるようになります。

2. 画面の作成

シンプルな画面

StatelessWidgetMaterialAppを組み合わせて最小の画面を作ってみました。
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を使用した画面

また、例は記載しませんが、TextFieldonChangedに関数を設定することで入力されるたびに発火するようになります。

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';
  });
}

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,
      ),
    ),
  ],
)

BottomNavigationBar

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を作成するのに使用するのが、TabBarTabBarViewです。
以下は、簡単なタブによる表示の切り替えをおこなうコードです。


// アニメーションのコールバック呼び出しに関する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(),
      ),
    );
  }
}

TabView

ドロワー

新しいスマホアプリだと、三本線のアイコンをクリックすると、画面端からリスト表示するようなUIを見かけます。
ScaffolddrawerDrawerを設定することで、作成できます。

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,
        ),
      ),
    );
  }
}

drawer_header

drawer_detail

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();
GitHubで編集を提案

Discussion

ログインするとコメントできます