フューチャー技術ブログ

FlutterFlowを触ってみる

image.png

はじめに

TIGの宮崎将太です。

Flutter連載6回目としてFlutterFlowについて調べてみました。
※2022年3月時点でFreeプランで検証しています。

What’s Flutter Flow

GoogleI/O’21でFlutterFlowというFlutterのノーコードのサービスが発表されました。

GUIだけでグリグリアプリが作れちゃうという例のアレですね。

個人的にノーコードツールに懐疑的な印象を持っているので、実際に現場に適用できそうかという観点で調べてみました。

機能&料金体型

出落ちになりますが、プラン別の機能と料金体系です。

機能 説明 Free Plan Standard Plan Pro Plan
標準コンポーネント Flutter Flowで事前定義されたコンポーネントの使用可否です。
簡素なモバイルアプリであれば標準コンポーネントのみで構築も可能なくらい多くの部品が事前定義されています。
カスタムWidget カスタムコンポーネントの作成可否です。
全プランでGUIベース、コードベースの双方でコンポーネントを作成可能です。
カスタムFuctiion 自作関数作成可否です。
関数として登録しておくと、複数画面で処理を使い回すことができます。
画面テンプレート 50以上の画面テンプレートの利用可否。
よくあるアプリケーションデザインがテンプレート化されており、これを使うだけでもある程度見れる見た目になります。
アプリケーション実行 FlutterFlow上でのアプリケーション実行可否です。
設定にエラーがなければweb上でアプリケーションを実行できます。
Firebaseとの統合 全環境でbackendとしてFirebaseを統合的に使用できます。
プロジェクト作成時か、後からFirebaseプロジェクトをFlutter Flowプロジェクト側に登録することで利用が可能になります。
※Firebase側でFlutterFlowアカウントに対して権限を付与する必要があります。
3rd Partyライブラリとの統合 pub.devから依存ライブラリをDLしてきて参照できます。
通常のFlutter開発相当のことができると考えてOKです。
チーム共有 FlutterFlowプロジェクトに対してメールアドレスを登録することで共同編集が可能です。
サンプルアプリ Standardプラン以上だと作成済みのサンプルアプリを動作させることができます。
※Freeプランでもサンプルプロジェクトを動かすことはできましたが、アプリとして動作させようとするとエラーが発生していました。
APKダウンロード Standartdプラン以上であればAPKダウンロードが可能です。
ソースコードダウンロード Standartdプラン以上であればFlutterプロジェクトとしてダウンロードが可能です。
Freeプランでもコンポーネント単位でのソース閲覧は可能ですが、プロジェクト丸ごとのDLは不可でした。
AppStore/PlayStoreへのデプロイ Proプランでのみ、直接アプリをストアにデプロイできるとのこと。
カスタムAPI Proプランのみ任意のAPIコールを可能とのこと。
※試せていませんが、FreeとStandadだと相当カスタマイズしない限りFirebase固定になるかも?
Githubとの統合 ProプランのみソースコードをGithubリポジトリベースで管理できるとのこと。
FlutterFlowにもバージョンの概念はありますが、アプリ全体で断面を切るくらいしかできませんでした。
Firebase ContentMangeer ProプランのみFirestoreデータをFlutterFlowGUI上で編集できるようになるとのこと。
料金 per month 0$ 30$ 70$

使い方

FlutterFlowアカウント自体は無料で作成が可能です。
https://app.flutterflow.io/create-account

以降はアカウントを作成した前提で話を進めます。

プロジェクト作成

テンプレートからプロジェクトを作成するかblankプロジェクトを作成するか選択可能です。
Freeプランで利用可能なテンプレートは現時点で8種類あり、大半がFirebase利用を前提としていました。

プロジェクト作成の流れ1 プロジェクト作成の流れ2 プロジェクト作成の流れ3

画面デザイン

メインのデザイン画面はこんな感じです

画面デザイン

コンポーネント選択パネルから部品をDrag&Dropで画面に配置することができます。
TextやColumn、RowなどおなじみのWigdetがデフォルトで登録されています。
配置された部品はプロパティ設定パネルで細かな調整が可能です。
ざっと見た感じWidgetコンストラクタの属性がそのままGUIで設定可能なように見受けられました。
ちなみに、デバイス選択ではiPhone/AndroidのほかにiPadとMac/Windowsも選択可能です。
右上のRunボタンでweb上でアプリ実行もできます。

ナビゲーションバーでタブを切り替えると画面ごとの表示やWidgetのツリー表示も可能です。

画面ごとの表示やWidgetのツリー表示

Action設定

配置した部品にGUIでActionを設定することができます。
設定可能なActionはデフォルトで用意されているものとカスタムで作成できるものがあり、Navigatorの使用やAlertなんかはデフォルトで用意されています。

GUIを用いたAction設定

コンポーネント作成

GUIとコードベースでコンポーネントを作成できます。
部品として永続化してDRYに書くことは問題なくできそうです。
※コンポーネントとは別にCustom Widgetという概念もありますが、こちらはコードベースで作成するコンポーネントを指すようです。

コンポーネント作成のGifどうが

LocalState

アプリケーショングローバルな値をLocalStateとして設定しておけます。
ローカルDBへの永続化も可能です。
ActionやCustomFunctionから適宜参照、設定ができます。

アプリケーショングローバルな値をLocalStateとして設定

APICall

ここが残念なところ….
FreePlan/Standardでは任意のAPICall設定ができません。Backendを簡単に使用する場合はFirebaseを使うことが縛りになってしまうよう。
※CustomFunctionとしてAPICallをコーディングしておけばなんとでもなる気がするけど、そこまでやるとFlutterFlowを使用する理由が消失する。

API呼び出し

生成ソースコード

最後に、FreePlanでも画面のソース閲覧は可能です。(プロジェクト全体のダウンロードやGithub接続は不可)
ちょっとコードを眺めてみましょう。

コード出力ボタン生成されたコード
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
import '../backend/api_requests/api_calls.dart';
import '../department_highlights_page/department_highlights_page_widget.dart';
import '../flutter_flow/flutter_flow_theme.dart';
import '../flutter_flow/flutter_flow_util.dart';
import '../search_results_page/search_results_page_widget.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

class HomePageWidget extends StatefulWidget {
const HomePageWidget({Key key}) : super(key: key);

@override
_HomePageWidgetState createState() => _HomePageWidgetState();
}

class _HomePageWidgetState extends State<HomePageWidget> {
TextEditingController textController;
final scaffoldKey = GlobalKey<ScaffoldState>();

@override
void initState() {
super.initState();
textController = TextEditingController();
}

@override
Widget build(BuildContext context) {
return Scaffold(
key: scaffoldKey,
backgroundColor: FlutterFlowTheme.of(context).secondaryColor,
body: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
Stack(
children: [
Align(
alignment: AlignmentDirectional(0, 0),
child: Image.asset(
'assets/images/home_image.png',
width: double.infinity,
height: 255,
fit: BoxFit.cover,
),
),
Align(
alignment: AlignmentDirectional(0, 0),
child: Padding(
padding: EdgeInsetsDirectional.fromSTEB(20, 60, 20, 0),
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
Padding(
padding: EdgeInsetsDirectional.fromSTEB(0, 0, 0, 17),
child: Image.asset(
'assets/images/logo_flutterMet_white.png',
width: 120,
fit: BoxFit.cover,
),
),
Text(
'Your place for searching ART.',
style: FlutterFlowTheme.of(context)
.bodyText1
.override(
fontFamily: 'Playfair Display',
color:
FlutterFlowTheme.of(context).secondaryColor,
fontSize: 16,
fontStyle: FontStyle.italic,
),
),
Padding(
padding: EdgeInsetsDirectional.fromSTEB(0, 27, 0, 0),
child: Container(
width: double.infinity,
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding:
EdgeInsetsDirectional.fromSTEB(15, 0, 15, 0),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
InkWell(
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
SearchResultsPageWidget(
searchTerm: textController.text,
),
),
);
await GetDepartmentsCall.call();
},
child: Icon(
Icons.search,
color: FlutterFlowTheme.of(context)
.tertiaryColor,
size: 24,
),
),
Expanded(
child: Padding(
padding: EdgeInsetsDirectional.fromSTEB(
5, 0, 0, 2),
child: TextFormField(
controller: textController,
obscureText: false,
decoration: InputDecoration(
hintText:
'Search artist, maker, department...',
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Color(0x00000000),
width: 1,
),
borderRadius:
const BorderRadius.only(
topLeft: Radius.circular(4.0),
topRight: Radius.circular(4.0),
),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Color(0x00000000),
width: 1,
),
borderRadius:
const BorderRadius.only(
topLeft: Radius.circular(4.0),
topRight: Radius.circular(4.0),
),
),
),
style: FlutterFlowTheme.of(context)
.bodyText1
.override(
fontFamily: 'Playfair Display',
fontSize: 16,
),
),
),
),
],
),
),
),
),
Align(
alignment: AlignmentDirectional(-1, 0),
child: Padding(
padding:
EdgeInsetsDirectional.fromSTEB(10, 15, 0, 20),
child: Text(
'Museum Departments',
style: FlutterFlowTheme.of(context)
.bodyText1
.override(
fontFamily: 'Playfair Display',
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
FutureBuilder<ApiCallResponse>(
future: GetDepartmentsCall.call(),
builder: (context, snapshot) {
// Customize what your widget looks like when it's loading.
if (!snapshot.hasData) {
return Center(
child: SizedBox(
width: 50,
height: 50,
child: CircularProgressIndicator(
color: FlutterFlowTheme.of(context)
.primaryColor,
),
),
);
}
final gridViewGetDepartmentsResponse =
snapshot.data;
return Builder(
builder: (context) {
final departments = (getJsonField(
(gridViewGetDepartmentsResponse
?.jsonBody ??
''),
r'''$.departments''',
)?.toList() ??
[])
.take(30)
.toList();
return GridView.builder(
padding: EdgeInsets.zero,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.6,
),
primary: false,
shrinkWrap: true,
scrollDirection: Axis.vertical,
itemCount: departments.length,
itemBuilder: (context, departmentsIndex) {
final departmentsItem =
departments[departmentsIndex];
return InkWell(
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
DepartmentHighlightsPageWidget(
departmentId: getJsonField(
departmentsItem,
r'''$.departmentId''',
),
displayName: getJsonField(
departmentsItem,
r'''$.displayName''',
).toString(),
),
),
);
},
child: Card(
clipBehavior:
Clip.antiAliasWithSaveLayer,
color: Colors.white,
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(8),
),
child: Align(
alignment: AlignmentDirectional(0, 0),
child: Padding(
padding:
EdgeInsetsDirectional.fromSTEB(
5, 0, 5, 0),
child: Text(
getJsonField(
departmentsItem,
r'''$.displayName''',
).toString(),
textAlign: TextAlign.center,
style:
FlutterFlowTheme.of(context)
.title1,
),
),
),
),
);
},
);
},
);
},
),
],
),
),
),
],
),
],
),
),
);
}
}

テンプレート画面をそのまま出力したこともあり、Scaffoldの中にゴリゴリ実装されていますね。
コンポーネントをうまく使えばもうちょっとプロダクト寄りの実装にはなりそうです。
ただし、素でStatefulWidgetを使用している部分はどうにもならなそうで、このままコピペは難しそう。
あくまでデザイン部分の参考程度の使い道にな理想です。
ちなみに、テーマはFlutterFlowプロジェクトでグローバル設定が可能で、FlutterFlowThemeはその設定にアクセスしているものと思われます。

使い所

軽く触ってみた感触と料金体系を見た感じ、以下の感触でした。

  • ProPlanであればPoCプロジェクトは十分に回せそう。ただし、状態管理などより作りを意識する必要があるプロダクト版開発はProPlanでも限定的な使い方になる。(デザイン部分だけをFlutterFlowで作るなど。)
  • Firebase前提でプロトタイプのみを作成するのであればStandardPlanが適当。ソースダウンロードができるので、どこかのリポジトリに保存しておけばプロジェクト終了後も月額費を払い続けるということは必要なし。
  • FreePlanはデザインコードの参考程度の使い道。画面単位でソースコード表示はできるので、部分的にコピペすることで若干開発は早くなるか。

有料プランでも14日間は使用ができそうなので、次はProPlanを試してみようと思います。
正直FreeとStandardでは実プロジェクトへの導入はなかなか難しそう..
Proでも料金は70$/月程度なので、プロジェクトで数アカウントだけ取るのはギリギリありかどうか..?といったところです。