Flutterを使ったアプリを作ってみる - GUI編
前回の記事ではアプリの開発をする一歩手前までの事前準備として基礎編をやってみました。今回はスマフォ用ではなく、1ヶ月前にリリースされたFlutter 3.0を利用して、デスクトップアプリを作ってみようと思います。
もともと、Electronでアプリをよく作っていた自分としてはデスクトップ用途でのFlutterに結構期待してて、v3.0ではM1 Mac対応なども入ってきてるので、簡単なアプリを作れないかなぁと思っています。本当は置き換えられたらいいんだけれど。今回のパートは前回の続きとなるので、色々な設定は以下のエントリーを参考にしてみてください。
今回利用する環境
適当なフォルダを作成し、ターミナルからflutter create myappと叩くと、作成に必要なプロジェクトファイルが生成されます。今回はflutmasterというフォルダ内に作成し、このフォルダをVSCodeで読み込ませてスタートです。
libフォルダ内にあるmain.dartが起点となるファイルになります。Android Studioで作成しても良いのですが、今回は使い慣れたVSCodeで行ってみようと思います。書いて、F5キーを押せばデスクトップアプリとしてデバッグ起動されます。
図:使い慣れたエディタでデバッグも実行できる
GUI部分を作ってみる
UI作成の基本
Flutterはmain.dartの中にある「Widget build」のセクションで、コードにてGUIを構築してゆきます。Electronのようにメインとレンダラーで別れてるわけではなく、1アプリ1ウィンドウを基本としてる「モバイルアプリ」な印象です。すでに作成直後のプロジェクトには超簡単なUI部分が用意されており、ここにウィジェットと呼ばれるコードを直接書き込みをしていくスタイルです。こちらにドキュメントが用意されています。
Body以下がアプリのメイン部分、appBarがアプリのタイトルバーを構成している内容です。HTMLとCSSでUIを構築するElectronとはかなり構造が異なるので、はじめからコードでガリガリ書くのはなかなかハードル高いイメージです。
このBody直下にいきなりウィジェットを置くことも可能ですが、管理しにくくなるので、通常はColumnやRowといった縦横配置用のウィジェットを用意してその中にテキストなどを配置していくスタイルになります。
※コードで構築する場合、Awesome Flutter SnippetsというプラグインをVSCodeに入れておくとコード補完が効くので便利
図:ウィジェットを組んでいくスタイル
UI構成の基本
HTML5でアプリを作成してきていると、FlutterのUI構成を構築は非常に特殊なものに見えます。割と好き勝手に構築できるHTMLの構造と異なり、FlutterはWidgetというパーツを「上から下に向けて順番に構築していく」というスタイルになるので、その構築も逐一「divboxのような入れ物」を用意してから、その中にネストして入れていく」といった形になります。
以下はその中でも最もよく使うパターンです。(class MyApp extends StatelessWidgetの中のWidget build(BuildContext context)の中に構築してゆきます)
Columnで縦に積み上げ
home: Scaffoldの一番外側の括りの中に、メインパーツを配置していくbody:の中の括りが基本になりますが、そのメイン部分で画面の中で「縦にパーツを組んでいく」為の使う、極めて基本的なパーツがColumn。一番外側のDIVの箱みたいなものです。その中にchildren: <Widget>を記述した中に個別のパーツを配置していきます。テキストラベルを3つ縦に並べてみました。
※Columnを何個も縦に並べることは出来ません。1つのウィジェット内で1つだけ
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
import 'package:flutter/material.dart'; //メイン呼び出し void main() => runApp(MyApp()); //ウィジェット構築 class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'フラッとテスト', theme: ThemeData(primarySwatch: Colors.blue), home: Scaffold( //メイン画面 body: Column( //ここからウィジェットを積み上げていく children: const <Widget>[ // デフォルト表示 Text('ノーマル'), // 太さを指定 Text('2つ目'), // スタイルを指定 Text('3つ目', style: TextStyle(fontStyle: FontStyle.italic)), ], ), ), ); } } |
図:しっかり3個のパーツが縦に並びました。
Containerでサイズ等の指定
前述のままだと、幅の内領域にただパーツを置いてるだけになります。これでは以降で左右中心などに配置であったりの指定時に思ったような配置になりません。しっかりと入れ物の横幅であったり、罫線、余白、背景などを指定する為の外枠がContainerです。Columnの下に所属して、個別に装飾してあげます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
import 'package:flutter/material.dart'; //メイン呼び出し void main() => runApp(MyApp()); //ウィジェット構築 class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'フラッとテスト', theme: ThemeData(primarySwatch: Colors.blue), home: Scaffold( //メイン画面 body: Column( children: <Widget>[ Container( // 横幅 width: 200, // 縦幅 height: 50, decoration: const BoxDecoration( color: Colors.yellow, ), child: const Text('イエローボックス'), ), Container( // 横幅 width: 500, // 縦幅 height: 150, decoration: BoxDecoration( // 枠線 border: Border.all(color: Colors.blue, width: 2), // 角丸 borderRadius: BorderRadius.circular(8), //背景色 color: const Color.fromARGB(255, 211, 254, 212)), child: const Text('枠線を装飾'), ) ], ), ), ); } } |
注意点として、Container自身Colorプロパティを持ってるのですが、BoxDecorationを使った場合、BoxDecorationが持ってるColorプロパティと共存出来ないという問題点。例外エラーになります。なので、自分は色指定はBoxDecorationで統一しています。
図:箱に幅と枠線、背景色を付けた
GUI作成ツールを使ってみる
コードでガリガリ書くというのは、入門者にとってはかなりハードルが高いです。VSCodeで書いていてもエラーや警告がバンバン出て、やる気がなくなります。そこでUIのようなものはコードではなく、ウェブサービスを使って構築してみようと思います。
色々とあるみたいですが、最もベーシックなFlutter Studioは現在のFlutterで利用しようとするとエラーがたくさん出ます。どうもだいぶ前にアプデが止まってるようで、現在のFlutterの大幅な変更に対応できておらず活用できません。ということで、FlutterFlowというツールを使ってみます。
- Googleアカウント等でログインするとアカウントが作成される
- アンケート画面が出てくるので適当に回答する
- Create Newをクリックし、プロジェクト名を入力する
- Blank AppのCreate Newをクリックすると空のプロジェクトが生成される
- 上部のCanvas Sizeを変更でモバイルやMacbook Airのサイズ等で表示してくれる(今回は1000x600のカスタムサイズを指定)
- 左下の歯車クリックで、アプリの概要や様々な細かな設定を施す事が可能になっています(言語やアイコン等)。
- 右上部にあるアイコンの中にあるDeveloper Menuのアイコンをクリックして、View Codeをクリックするとアプリ全体のコードを表示する事が可能です。
でも、はじめはサンプルで用意されてるものを使って、基本的なGUI構成をどうやって作り上げているのか?じっくり観察してからのほうが、ストレスが少ないのではないかと思います。
※ただし余計なコードもてんこ盛りなので、基礎学習には向いていないかなと思います。
図:GUI構築画面。機能が豊富
個々のウィジェットについて丁寧に知っていく
自動生成されたテンプレートに加えていく形で、1個1個のウィジェットパーツの挙動や、特にレイアウト系のパーツの細かな設定方法をまずは学習していく必要があります。ボタンの挙動やDart言語についてはずっと後回しで学習すれば良いです。
前述の通り、アプリとしての思ったようなデザインやレイアウト配置を構築するのになかなかの時間が掛かります。特にボタンといったわかりやすいパーツではなく、PaddingであったりContainerであったり、Columnであったりこれらレイアウト系パーツを多用して組んでいく必要があるため、どういう構造になってるのか?知らないとなかなか、モチベーションも続かないです。
また、レイアウトの調整のたびにシミュレータを起動するのではなく、上に出てるツールバーの「⚡」なアイコンをクリックすると、修正内容が直ちに反映される(ホットリロード機能と呼ぶ)ので、これを活用して丁寧に挙動を頭に叩き込んでいきましょう。
※Flutterで一番厄介なのは、Dart構文やパッケージの機能を使いこなすことではなく、如何にこのUIを構築するかにあると思います。
図:ホットリロード機能を活用しよう
個別のウィジェットについて
ドロワーを追加する
スマフォアプリなどによくある「ハンバーガーメニュー(≡)」をタップすると、サイドメニューが出てくるアレをドロワーと呼びます。Flutterの場合は比較的簡単に追加する事が可能です。自分もVuetifyなどで構築する時には最近はサイドメニューを使う機会が多いです。
これは以下のコードをウィジェットエリアのBodyの一個上、appBarの下あたりにでも以下のコードを追記するだけ。サイドメニューの中身は、Body以下同様に別のウィジェットを貼り付けて(例えば、Listview等)、メニューを構成します。対象のリファレンスはこちらのAdd a Drawer to a Screenを参照します。
1 |
drawer: const Drawer(child: Center(child: Text("サブメニュー"))), |
図:アプリのメニューなどをここに用意する
ダイアログボックスを表示する
アプリの問い合わせなどで頻繁に使う「ダイアログボックス」。これも、ウィジェットの1つとして実装する事になります。通常はボタンなどをクリックした時に、そのボタンのonPressedなどのイベントに対して、Dialogを呼び出す設定を書き足します。対象のリファレンスはこちらのAlertDialog classを参照します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
Center( child: TextButton( child: const Text( 'テキストボタンだよ', style: TextStyle(fontSize: 20), ), onPressed: () => showDialog<String>( context: context, builder: (BuildContext context) => AlertDialog( title: const Text('自爆スイッチ'), content: const Text('自爆しますか?やり直しはできません。'), actions: <Widget>[ TextButton( onPressed: () => { Navigator.pop(context, 'キャンセル'), debugPrint("キャンセルらしい"), }, child: const Text('Cancel'), ), TextButton( onPressed: () => { Navigator.pop(context, '自爆実行'), debugPrint("OKらしい"), }, child: const Text('OK'), ), ], ), ), ), ), |
ユーザがOKやキャンセルのボタンを押したら、処理を実行し、Navigator.popにてダイアログを閉じる動作を入れておきます。ここで別途用意していた別の処理系に投げて返り値を受け取るような処理を通常は実装する事になるかと思います。
図:こんな感じでユーザに問い合わせをする
追加パッケージを導入する
Flutterには標準のウィジェット等の他にも、多くの人が開発し公開されてるパッケージが存在します。こちらのサイトに公開されており、これらパッケージを追加する事で、機能を追加出来るようになっています(Node.jsのnpmのように)。これらのパッケージの追加をし、実際に利用してみる手順です。ここでは、SVG画像を表示するパッケージを追加してみようと思います。
SVG画像を追加する事例
まずはプロジェクトにSVG画像を追加し設定ファイルに追記して参照出来るようにしておきます。Node.jsのpackage.jsonのようなものです。
- 今回はこちらのサイトのキノコの画像を利用します。ダウンロードしておきます。
- プロジェクトフォルダ直下に「assets」フォルダを作成し、1.の画像を入れておく
- テキストエディタでプロジェクト直下にある「pubspec.yaml」を開く
- flutterセクションに以下のようにassetsフォルダを参照できるように追記する(コメントアウトされてるので解除して記入)。個別指定も可能だけれど面倒なので、フォルダ毎指定しています。
123flutter:assets:- assets/ #assetsフォルダ以下を参照できるようにする - 続けて、dependenciesセクションに以下のようにパッケージの情報を追記する。2022年7月5日時点でVersion 1.1.1+1なので、その旨を書き足します。
1234dependencies:flutter:sdk: flutterflutter_svg: ^1.1.1+1
バージョン番号の前に、「^」を入力する必要があるので注意。上記のようにパッケージ名:バージョンでパッケージの追加が可能です。ただし、現在のFlutterに於いてはdeprecatedとしてインストールが拒否されることがあります。
パッケージのダウンロード
このままではまだ、ライブラリのメソッドが利用できないので、main.dartの上部で、以下の1文を追加しライブラリのロードを行います。
1 |
import 'package:flutter_svg/flutter_svg.dart'; |
この状態だとVSCode上ではまだ、パッケージがダウンロードできていないので、赤波線のエラー表記ですが、カーソルをあわせて、flutter pub getを実行するとVSCode上で対象のパッケージのダウンロードが開始されます(バージョン修正などもしてくれますが、pubspec.yamlのassetsが上書きされてコメントアウトに戻されたりするので、よく確認すること)
すると、VSCode右下のDEPENDENCIESの項目にダウンロードされたパッケージが表示されます。
図:パッケージのダウンロード中の様子
ウィジェットの追加
このパッケージを使うためのウィジェットをWidget buildのBody以下に追加します。以下のようなコードを追記します。1つ目の引数にSVGファイルへのパスをいれて、3つ目、4つ目にはサイズの縦横を指定します。
1 2 3 4 5 6 |
SvgPicture.asset( 'assets/mushroom.svg', semanticsLabel: 'Fungi', width: 100, height: 100, ), |
表示させてみる
今回使った画像をロードしたら、コンソールにエラーがでました。「flutter: Warning: Flutter SVG only supports the following formats for width
and height
on the SVG root」。どうやら、SVGファイルが少しでもオカシイものだと、受け付けてくれないようで。一旦GIMPでPNGに変換し、オンラインコンバータを使って再度、SVGに変換してからリロードしてみました。
無事にSVG画像を表示する事ができました。
図:SVG画像がアプリ上でダイレクトに表示
その他のテクニック
アプリアイコンを変更する
アプリのアイコンの変更は地味ですがとっても重要な作業です。デフォルトだとFlutterのアイコンなので、これではよろしくない。しかし、このアイコンの変更ですが、OSによって異なるので、ちょっと手間が必要です。
iOS・Androidの場合
pubspec.yamlに如何のパッケージを追加して、設定を追加する事で変更する事が可能です。別途assetsフォルダへアクセス出来るようにしておく必要があります(前述)
1 2 3 4 5 6 7 |
dev_dependencies: flutter_launcher_icons: "^0.9.2" flutter_icons: android: true ios: true image_path: "assets/appicon.png" |
上記の例だと、assetsにあるappicon.pngをアプリのアイコンとして利用出来るようになります。
Windows・macOSの場合
Desktopアプリとしてのアイコン設定はスマフォの場合と異なり、プロジェクト直下にあるwindowsとmacOSそれぞれのフォルダの奥深くにファイルがあるのでそれを置き換えます。Linuxに関してはテンプレートが用意されておらず、こちらにやり方があるようですが。
macOSの場合は、macos ⇒ Runner ⇒ Assets.xcassets ⇒ AppIcon.appiconsetの中にapp_icon_32.pngといったファイル名でそれぞれの大きさ別に用意されてるので、これを置き換えます。
ただしデバッグ中は適用されないので、flutter build macosを実行してリリース品を作った時に反映されます。
図:こんな感じで置き換える
図:ビルドするとアイコンが変わる
状態管理
Vue.jsなどの場合、Vue内の変数の値を書き換えただけで、それを利用してるパーツの内容を自動的に変更してくれるので、非常に便利です。そうではないフレームワークの場合は、変数の中身を書き換えても別途画面パーツのリフレッシュコードなどを入れなければ反映されず、故にVueのようなフレームワークが多くの人に利用されていたりします。
Flutterの場合は、変数の値を直接書き換えたとしても、画面上のパーツになんら変化はありません。反映させる為にはsetStateを利用して変数を書き換える事で、変数の中身を変更しつつ利用してるパーツの状態を変更してくれます。この処理は、StatelessWidgetで利用することが可能です。プロジェクト作成直後のmain.dart内では、アイコンをタップするとカウントアップする部分でこれが利用されています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
class _MyHomePageState extends State<MyHomePage> { //カウンタを用意 int countUp = 0; //カウンタを回す為の関数 void _upcounter() { setState(() { countUp++; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), drawer: const Drawer(child: Center(child: Text("サブメニュー"))), body: Column(children: [ Center( child: TextButton( child: const Text( 'テキストボタンだよ', style: TextStyle(fontSize: 20), ), onPressed: () => Navigator.of(context) .push(MaterialPageRoute(builder: (context) { return const subView1(); })), ), ), Text('$countUp', style: const TextStyle(fontSize: 60, color: Colors.red)), ]), floatingActionButton: FloatingActionButton( //カウンタを回す処理 onPressed: () => {_upcounter()}, child: const Icon(Icons.timer)), ); } } |
- フローティングボタンを別途用意して、_upcounterを実行するようにonPressedに割り当てる
- _MyHomePageStateクラス内にcountUpという変数を用意。
- 同じくvoid _upcounter()というカウンタを加算する処理をする関数を用意し、その中でsetStateを使って処理を実行する
- 画面上のText('$countUp')の内容が変数を随時読み取り、数値を更新してくれる
図:setStateしないと変数の値が反映しない
ファイルを分ける
アプリを作成しつづけているとだんだん、main.dartにあらゆるものが詰め込まれた状態になり、何かを修正しようと思っても、毎回検索したり探すといった面倒な事が起きがちです。これはどんな言語でも言える事ですが、ある程度目的別にファイルを分けてそれを別途ロードして利用するといったスタイルにしていく時がいずれ来ます(Node.jsでもindex.jsが何万行にもなってるイメージです)
そんな時にファイルをわけますが、今回は別の画面を用意してそれを呼び出す仕組みを別ファイルに切り出してロードしてみます。新しいファイルはsubview.dartとして命名し、ボタンをタップするとそれを呼び出し、画面遷移するといった仕組みです。ファイルはlibフォルダの下に今回配置します。
main.dart側
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import 'subview.dart'; ・・・中略・・・ class _MyHomePageState extends State<MyHomePage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), drawer: const Drawer(child: Center(child: Text("サブメニュー"))), body: Column(children: [ Center( child: TextButton( child: const Text( 'テキストボタンだよ', style: TextStyle(fontSize: 20), ), onPressed: () => Navigator.of(context) .push(MaterialPageRoute(builder: (context) { return const subView1(); })), ), ), ])); } } |
- 冒頭でlib以下にあるsubview.dartをimportする文を記述しておきます。
- TextButtonをタップするとonPressedが動き、Navigatorにて遷移が始まる。この時、subview.dart内に用意したsubView1クラスを返すようにしておく
subview.dart側
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import 'package:flutter/material.dart'; // ignore: camel_case_types class subView1 extends StatelessWidget { const subView1({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('2つ目の画面だよ'), ), body: const Center( child: Text('ここは異世界です'), ), ); } } |
- subview.dart側は2つ目の画面のクラスだけを定義しています。
- 自動的に戻るボタンをFlutter側が用意してくれるので便利です。
ウィジェットだけじゃなく処理系のファイルも別に切り出しておく事で、1つのファイルに押し込んでわけがわからなくなるのを防げたり、チームで仕事をする場合は、クラス単位でわけておくと捗るでしょう。
図:画面遷移して表示された
デバッグ方法
プログラム開発の基本として、デバッグするにはどうしたらよいか?VSCodeで行う場合には、例として以下のような方法でデバッグを行います。
- ボタンクリックのイベントのonPressedに対して、debugPrintメソッドを使って出力する
- その際にVSCodeのメニューから表示⇒デバッグコンソールを表示してあげる
- クリックすると、コンソールに内容が表示されるので、これを元にデバッグを行っていく
- この時、macOSじゃなくChromeで立ち上がってしまう場合、VSCodeの右下のDart DevToolsがmacOS(darwin)になってるか確認しましょう。
以下はボタンクリックでコンソールに内容を表示する事例です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class _MyHomePageState extends State<MyHomePage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: TextButton( child: const Text( 'OutlinedButton', style: TextStyle(fontSize: 20), ), onPressed: () => debugPrint('クリックされたらしいですよ'), ), ), ); } } |
図:無事にデバッグ出力ができた