🍃
シンプルなスマホUIを実装する(Flutter):その2
概要
その1の続き。
値を取得して表示
続いて、入力された値を取得し表示してみます。表示には手軽に使えるSnackBarを利用してみます。
各入力に対応するTextEditingControllerを作成し、マッピングすることで値が取得できます。
contact.dart
import 'package:flutter/material.dart';
// dart format off
class Contact extends StatefulWidget {
const Contact({super.key});
@override
State<Contact> createState() => _ContactState();
}
class _ContactState extends State<Contact> {
+ // 各入力値を保持する変数宣言
+ final _titleController = TextEditingController();
+ final _emailController = TextEditingController();
+ final _messageController = TextEditingController();
+ // Controllerはdisposeとセットで定義
+ @override
+ void dispose(){
+ _titleController.dispose();
+ _emailController.dispose();
+ _messageController.dispose();
+ super.dispose();
+ }
@override
Widget build(BuildContext context) {
// 全体をColumnでレイアウト
return Column(
children: [
//hero
Container(
color: Color(0xFFAAAAAA),
width: double.infinity,
height: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: 40), // spacer
Text("お問い合わせフォーム",
style: TextStyle(color: Colors.white, fontSize: 18)),
Text("お気軽にお問い合わせください",
style: TextStyle(color: Colors.white))
],
),
),
// form
Padding(
padding: EdgeInsets.all(30),
child: Form(
child: Column(
children: [
// title
SizedBox(height: 10),
TextFormField(
decoration: InputDecoration(
labelText: "お問い合わせタイトル",
border: OutlineInputBorder(),
),
+ controller: _titleController,
),
// email
SizedBox(height: 30),
TextFormField(
decoration: InputDecoration(
labelText: "Email",
border: OutlineInputBorder(),
),
+ controller: _emailController,
),
// message
SizedBox(height: 30),
TextFormField(
decoration: InputDecoration(
labelText: "お問い合わせ内容",
border: OutlineInputBorder(),
),
minLines: 3,
maxLines: 5,
keyboardType: TextInputType.multiline,
+ controller: _messageController,
),
// button
SizedBox(height: 30),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF333333),
foregroundColor: Color(0xFFFFFFFF),
),
onPressed: (){
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ "title: ${_titleController.text}\n"
+ "email: ${_emailController.text}\n"
+ "message: ${_messageController.text}"
+ ),
+ ),
+ );
},
child: Text("送信")),
),
],
),
),
),
],
);
}
}
記述が完了したら動作確認してください。
バリデーション
値が取得できるようになったのでバリデーション機能を追加します。
TextFormFieldにvalidatorを追加することで対応できます。
contact.dart
import 'package:flutter/material.dart';
// dart format off
class Contact extends StatefulWidget {
const Contact({super.key});
@override
State<Contact> createState() => _ContactState();
}
class _ContactState extends State<Contact> {
// 各入力値を保持する変数宣言
final _titleController = TextEditingController();
final _emailController = TextEditingController();
final _messageController = TextEditingController();
+ // Formを特定するkeyを生成(訳があって、GlobalKeyとして生成)
+ GlobalKey<FormState> _formKey = GlobalKey<FormState>();
// Controllerはdisposeとセットで定義
@override
void dispose(){
_titleController.dispose();
_emailController.dispose();
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 全体をColumnでレイアウト
return Column(
children: [
//hero
Container(
color: Color(0xFFAAAAAA),
width: double.infinity,
height: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: 40), // spacer
Text("お問い合わせフォーム",
style: TextStyle(color: Colors.white, fontSize: 18)),
Text("お気軽にお問い合わせください",
style: TextStyle(color: Colors.white))
],
),
),
// form
Padding(
padding: EdgeInsets.all(30),
child: Form(
+ key: _formKey,
child: Column(
children: [
// title
SizedBox(height: 10),
TextFormField(
decoration: InputDecoration(
labelText: "お問い合わせタイトル",
border: OutlineInputBorder(),
),
controller: _titleController,
+ validator: (value){
+ if(value == null || value.isEmpty){
+ return "お問い合わせタイトルは必須です。";
+ }
+ if(value.length > 10){
+ return "10文字以下で入力してください。";
+ }
+ return null;
+ },
+ autovalidateMode: AutovalidateMode.onUserInteraction, // 入力時チェックに必要
),
// email
SizedBox(height: 30),
TextFormField(
decoration: InputDecoration(
labelText: "Email",
border: OutlineInputBorder(),
),
controller: _emailController,
validator: (value){
if(value == null || value.isEmpty){
return "Emailは必須です。";
}
final emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if(!emailRegExp.hasMatch(value)){
return "Emailを正しく入力してください。";
}
return null;
},
autovalidateMode: AutovalidateMode.onUserInteraction,
),
// message
SizedBox(height: 30),
TextFormField(
decoration: InputDecoration(
labelText: "お問い合わせ内容",
border: OutlineInputBorder(),
),
minLines: 3,
maxLines: 5,
keyboardType: TextInputType.multiline,
controller: _messageController,
+ validator: (value){
+ if(value == null || value.isEmpty){
+ return "お問い合わせ内容は必須です。";
+ }
+ if(value.length > 20){
+ return "20文字以下で入力してください。";
+ }
+ return null;
+ },
+ autovalidateMode: AutovalidateMode.onUserInteraction,
),
// button
SizedBox(height: 30),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF333333),
foregroundColor: Color(0xFFFFFFFF),
),
onPressed: (){
+ if(_formKey.currentState!.validate()){
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"title: ${_titleController.text}\n"
"email: ${_emailController.text}\n"
"message: ${_messageController.text}"
),
),
);
+ }
},
child: Text("送信")),
),
],
),
),
),
],
);
}
}
動作を確認してください。
_formKeyをfinalにしろという警告がでるかもですが、後で解消するのでとりあえず無視します。
API連携
では、アプリ機能の肝であるAPI連携を行います。
連携するAPIは「シンプルなWebAPIを実装する(Node.js)」のものです。
通信につかうhttpとJSON処理に利用するconvertをimportしてから記述を追加します。
contact.dart
import 'package:flutter/material.dart';
+ import 'package:http/http.dart' as http;
+ import 'dart:convert';
// dart format off
class Contact extends StatefulWidget {
const Contact({super.key});
@override
State<Contact> createState() => _ContactState();
}
class _ContactState extends State<Contact> {
// 各入力値を保持する変数宣言
final _titleController = TextEditingController();
final _emailController = TextEditingController();
final _messageController = TextEditingController();
// Formを特定するkeyを生成(訳があって、GlobalKeyとして生成)
GlobalKey<FormState> _formKey = GlobalKey<FormState>();
// Controllerはdisposeとセットで定義
@override
void dispose(){
_titleController.dispose();
_emailController.dispose();
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 全体をColumnでレイアウト
return Column(
children: [
//hero
Container(
color: Color(0xFFAAAAAA),
width: double.infinity,
height: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: 40), // spacer
Text("お問い合わせフォーム",
style: TextStyle(color: Colors.white, fontSize: 18)),
Text("お気軽にお問い合わせください",
style: TextStyle(color: Colors.white))
],
),
),
// form
Padding(
padding: EdgeInsets.all(30),
child: Form(
key: _formKey,
child: Column(
children: [
// title
SizedBox(height: 10),
TextFormField(
decoration: InputDecoration(
labelText: "お問い合わせタイトル",
border: OutlineInputBorder(),
),
controller: _titleController,
validator: (value){
if(value == null || value.isEmpty){
return "お問い合わせタイトルは必須です。";
}
if(value.length > 10){
return "10文字以下で入力してください。";
}
return null;
},
autovalidateMode: AutovalidateMode.onUserInteraction, // 入力時チェックに必要
),
// email
SizedBox(height: 30),
TextFormField(
decoration: InputDecoration(
labelText: "Email",
border: OutlineInputBorder(),
),
controller: _emailController,
validator: (value){
if(value == null || value.isEmpty){
return "Emailは必須です。";
}
final emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if(!emailRegExp.hasMatch(value)){
return "Emailを正しく入力してください。";
}
return null;
},
autovalidateMode: AutovalidateMode.onUserInteraction,
),
// message
SizedBox(height: 30),
TextFormField(
decoration: InputDecoration(
labelText: "お問い合わせ内容",
border: OutlineInputBorder(),
),
minLines: 3,
maxLines: 5,
keyboardType: TextInputType.multiline,
controller: _messageController,
validator: (value){
if(value == null || value.isEmpty){
return "お問い合わせ内容は必須です。";
}
if(value.length > 20){
return "20文字以下で入力してください。";
}
return null;
},
autovalidateMode: AutovalidateMode.onUserInteraction,
),
// button
SizedBox(height: 30),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF333333),
foregroundColor: Color(0xFFFFFFFF),
),
onPressed: () async {
if(_formKey.currentState!.validate()){
+ final messenger = ScaffoldMessenger.of(context);
+ final result = await sendDataForm(
+ _titleController.text,
+ _emailController.text,
+ _messageController.text,
+ );
+ if(!mounted) return; // ページ移動されてないか確認
+ messenger.showSnackBar(
+ SnackBar(content: Text(result ?? "")),
+ );
}
},
child: Text("送信")),
),
],
),
),
),
],
);
}
}
+ // API連携
+ // sendDataForm定義
+ Future<String?> sendDataForm(String title, String email, String message) async {
+
+ final client = http.Client();
+ final apiUrl = Uri.parse("http://localhost:3333/contacts");
+
+ try{
+
+ final response = await client.post(
+ apiUrl,
+ headers: {"Content-Type": "application/json"},
+ body: jsonEncode({
+ "title": title,
+ "email": email,
+ "message": message,
+ }),
+ );
+
+ final json = jsonDecode(response.body) as Map<String, dynamic>;
+ return json['message'] as String?;
+
+ }catch(e){
+ return jsonEncode({"status": "error", "message": e.toString()});
+ }finally{
+ client.close();
+ }
+
+ }
記述が完了したら動作確認してください。
ボタンの2度押し防止や送信後のリセットなど
では最後にボタンの2度押し防止や送信後の値リセット処理を実装します。
contact.dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
// dart format off
class Contact extends StatefulWidget {
const Contact({super.key});
@override
State<Contact> createState() => _ContactState();
}
class _ContactState extends State<Contact> {
// 各入力値を保持する変数宣言
final _titleController = TextEditingController();
final _emailController = TextEditingController();
final _messageController = TextEditingController();
// Formを特定するkeyを生成(訳があって、GlobalKeyとして生成)
GlobalKey<FormState> _formKey = GlobalKey<FormState>();
+ // 送信中チェック用変数宣言
+ bool isSending = false;
// Controllerはdisposeとセットで定義
@override
void dispose(){
_titleController.dispose();
_emailController.dispose();
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 全体をColumnでレイアウト
return Column(
children: [
//hero
Container(
color: Color(0xFFAAAAAA),
width: double.infinity,
height: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: 40), // spacer
Text("お問い合わせフォーム",
style: TextStyle(color: Colors.white, fontSize: 18)),
Text("お気軽にお問い合わせください",
style: TextStyle(color: Colors.white))
],
),
),
// form
Padding(
padding: EdgeInsets.all(30),
child: Form(
key: _formKey,
child: Column(
children: [
// title
SizedBox(height: 10),
TextFormField(
decoration: InputDecoration(
labelText: "お問い合わせタイトル",
border: OutlineInputBorder(),
),
controller: _titleController,
validator: (value){
if(value == null || value.isEmpty){
return "お問い合わせタイトルは必須です。";
}
if(value.length > 10){
return "10文字以下で入力してください。";
}
return null;
},
autovalidateMode: AutovalidateMode.onUserInteraction, // 入力時チェックに必要
),
// email
SizedBox(height: 30),
TextFormField(
decoration: InputDecoration(
labelText: "Email",
border: OutlineInputBorder(),
),
controller: _emailController,
validator: (value){
if(value == null || value.isEmpty){
return "Emailは必須です。";
}
final emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if(!emailRegExp.hasMatch(value)){
return "Emailを正しく入力してください。";
}
return null;
},
autovalidateMode: AutovalidateMode.onUserInteraction,
),
// message
SizedBox(height: 30),
TextFormField(
decoration: InputDecoration(
labelText: "お問い合わせ内容",
border: OutlineInputBorder(),
),
minLines: 3,
maxLines: 5,
keyboardType: TextInputType.multiline,
controller: _messageController,
validator: (value){
if(value == null || value.isEmpty){
return "お問い合わせ内容は必須です。";
}
if(value.length > 20){
return "20文字以下で入力してください。";
}
return null;
},
autovalidateMode: AutovalidateMode.onUserInteraction,
),
// button
SizedBox(height: 30),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF333333),
foregroundColor: Color(0xFFFFFFFF),
),
+ onPressed: isSending ? null : () async {
+ setState(() {
+ isSending = true;
+ });
if(_formKey.currentState!.validate()){
final messenger = ScaffoldMessenger.of(context);
final result = await sendDataForm(
_titleController.text,
_emailController.text,
_messageController.text,
);
if(!mounted) return; // ページ移動されてないか確認
messenger.showSnackBar(
SnackBar(content: Text(result ?? "")),
);
+ // 値・バリデーション状態のリセット
+ _titleController.clear();
+ _emailController.clear();
+ _messageController.clear();
+ // formごと入れ替える
+ _formKey = GlobalKey<FormState>();
}
+ setState(() {
+ isSending = false;
+ });
},
+ child: Text(isSending ? "送信中..." : "送信")),
),
],
),
),
),
],
);
}
}
// API連携
// sendDataForm定義
Future<String?> sendDataForm(String title, String email, String message) async {
final client = http.Client();
final apiUrl = Uri.parse("http://localhost:3333/contacts");
try{
final response = await client.post(
apiUrl,
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"title": title,
"email": email,
"message": message,
}),
);
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json['message'] as String?;
}catch(e){
return jsonEncode({"status": "error", "message": e.toString()});
}finally{
client.close();
}
}
記述が完了したら動作確認してください。
実装は以上となります。お疲れ様でした。
Android対応
ReactNative(expo)と同様FlutterでもAndroidに特化した対応が必要となりますが、ここではひとまず割愛します。
AIに対応を依頼すると直ぐにしてくれるはずです。
時間のあるときに追記するかも。
参考課題
基本確認
- Flutterとは何ですか?
- Flutterを採用するメリット・デメリットは何ですか?
技術・ツール関連
- ReactNativeと何が違いますか?
その他
PM関連
- WebUIや他のアプリ実装と機能は同じですが要件定義、設計上のどこに影響しますか?
- Flutterで開発することは工数や費用にどう影響しますか?
AI関連
- AIに指示し、本モバイルUI(アプリ)と同じものを実装してください
- できれば2回繰り返してください
デプロイ
- とりあえず実機確認したいのですが、どうしたらいいですか?
- 開発したアプリを公開したいです。どうしたらいいですか?
- Android
- iOS
Discussion