【Flutter】汎用的なAlertダイアログクラスを作成する【サンプル付き】
こんな方におすすめの記事です。
- AlertDialogの実装がちょっと面倒に感じる
- 汎用的に使いまわせるコードが欲しい
実務で様々な種類のダイアログを作成しているうちに、ボタンアクションなど纏めれる箇所があるなと思い、試しに作成してみました。
状態管理には、Hooks Riverpodを使用しています。
良ければ使ってみてください。
目指す構成
AlertDialogの前に、汎用的に使えるヘルパークラス(CustomAlertDialog, CustomTextFieldDialog)を用意します。
汎用的なAlertダイアログクラスを作成
CustomAlertDialog
ダイアログのcontent
の内容を自由に実装するCustomAlertDialogを作成します。
import 'package:flutter/material.dart';
class CustomAlertDialog extends StatelessWidget {
const CustomAlertDialog({
Key? key,
required this.title,
required this.contentWidget,
this.cancelActionText,
this.cancelAction,
required this.defaultActionText,
this.action,
}) : super(key: key);
final String title;
final Widget contentWidget;
final String? cancelActionText;
final Function? cancelAction;
final String defaultActionText;
final Function? action;
Widget build(BuildContext context) {
return AlertDialog(
title: Text(title),
content: contentWidget,
actions: [
if (cancelActionText != null)
TextButton(
child: Text(cancelActionText!),
onPressed: () {
if (cancelAction != null) cancelAction!();
Navigator.of(context).pop(false);
},
),
TextButton(
child: Text(defaultActionText),
onPressed: () {
if (action != null) action!();
Navigator.of(context).pop(true);
},
),
],
);
}
}
ポイントは、
- キャンセルボタンは任意とし、予め
actions
プロパティを実装している -
action
,cancelAction
に、アクションボタンタップ時の処理を書く
あとでこれを使ったサンプルを示します。
CustomTextFieldDialog
テキストフィールドを持つダイアログ限定でCustomTextFieldDialogを作成してみました。
正直、CustomAlertDialogを使っても実現できますが、OKボタンでバリデーションチェックを実現するためのFormウィジェットを予め備えています。
import 'package:flutter/material.dart';
class CustomTextFieldDialog extends StatelessWidget {
const CustomTextFieldDialog({
Key? key,
required this.title,
required this.contentWidget,
this.cancelActionText,
this.cancelAction,
required this.defaultActionText,
this.action,
}) : super(key: key);
final String title;
final Widget contentWidget;
final String? cancelActionText;
final Function? cancelAction;
final String defaultActionText;
final Function? action;
Widget build(BuildContext context) {
const key = GlobalObjectKey<FormState>('FORM_KEY');
return AlertDialog(
title: Text(title),
content: Form(
key: key,
child: contentWidget,
),
actions: [
if (cancelActionText != null)
TextButton(
child: Text(cancelActionText!),
onPressed: () {
if (cancelAction != null) cancelAction!();
Navigator.of(context).pop(false);
},
),
TextButton(
child: Text(defaultActionText),
onPressed: () {
if (key.currentState!.validate()) {
if (action != null) action!();
Navigator.of(context).pop(true);
}
},
),
],
);
}
}
サンプル集
上述の汎用的なAlertダイアログクラスを使って、ダイアログを実装してみます。
先に、呼び出し元のクラスを用意しておきます。
dialog_sample_page.dart
import 'package:flutter/material.dart';
import 'widgets/alert_dialog_button_widget.dart';
import 'widgets/dropdown_dialog_button_widget.dart';
import 'widgets/text_field_dialog_button_widget.dart';
import 'widgets/text_field_dialog_button_widget2.dart';
class DialogSamplePage extends StatelessWidget {
const DialogSamplePage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Dialog sample'),
actions: const [],
),
body: _buildBody(context),
);
}
Widget _buildBody(BuildContext context) {
return Center(
child: Column(
children: const [
AlertDialogButtonWidget(),
DropdownDialogButtonWidget(),
TextFieldDialogButtonWidget(),
TextFieldDialogButtonWidget2(),
],
),
);
}
}
基本のダイアログ
ボタンタップで、ダイアログを表示させてみたコードがこちら。
基本のダイアログ サンプル
import 'package:flutter/material.dart';
import 'package:flutter_dialog_sample/presentation/common_widgets/custom_alert_dialog.dart';
class AlertDialogButtonWidget extends StatelessWidget {
const AlertDialogButtonWidget({
Key? key,
}) : super(key: key);
Widget build(BuildContext context) {
return ElevatedButton(
child: const Text('基本のAlertダイアログボタン'),
onPressed: () => showDialog(
context: context,
builder: (context) => CustomAlertDialog(
title: '基本のAlertダイアログ',
contentWidget: const Text('This is an alert dialog.'),
cancelActionText: 'Cancel',
cancelAction: () {},
defaultActionText: 'OK',
action: () {
// TODO: implement method
},
),
),
);
}
}
OK、キャンセルボタンタップ時の処理は適宜実装ください。
ドロップダウンリストを持つダイアログ
ドロップダウンリストを持つダイアログを作成してみました。
ドロップダウンダイアログ サンプル
import 'package:flutter/material.dart';
import 'package:flutter_dialog_sample/presentation/common_widgets/custom_alert_dialog.dart';
import 'package:flutter_dialog_sample/presentation/common_widgets/custom_dropdown.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
enum CityType {
tokyo,
nagoya,
osaka,
}
class City {
static const Map<CityType, String> allItems = {
CityType.tokyo: '東京',
CityType.nagoya: '名古屋',
CityType.osaka: '大阪',
};
}
class DropdownDialogButtonWidget extends HookConsumerWidget {
const DropdownDialogButtonWidget({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
final cityType = useState<CityType>(CityType.tokyo);
return ElevatedButton(
child: const Text('ドロップダウンダイアログボタン'),
onPressed: () => showDialog(
context: context,
builder: (context) => CustomAlertDialog(
title: 'ドロップダウンダイアログ',
contentWidget: Column(
mainAxisSize: MainAxisSize.min,
children: [
CustomDropdown<CityType>(
labelText: '',
list: City.allItems.keys.toList(),
allTitles: City.allItems.entries
.map(
(e) => e.value,
)
.toList(),
selectedValue: cityType.value,
onChanged: (cityType) => cityType.value = cityType!,
),
],
),
cancelActionText: 'Cancel',
cancelAction: () {},
defaultActionText: 'OK',
action: () {
// TODO: implement method
},
),
),
);
}
}
DropdownMenuItemのリスト作成に関しては、別クラスCustomDropdownクラスを用意して実現しました。enumで定義したクラスとデータリストをこいつに渡します。
DropdownMenuItemのリスト作成を実現するCustomDropdownクラスです。
custom_dropdown.dart
import 'package:flutter/material.dart';
class CustomDropdown<T> extends StatefulWidget {
const CustomDropdown({
Key? key,
required this.labelText,
required this.list,
required this.allTitles,
required this.selectedValue,
required this.onChanged,
}) : super(key: key);
final String labelText;
final List<T> list;
final List<String> allTitles;
final T selectedValue;
final Function(dynamic) onChanged;
_CustomDropdownState<T> createState() => _CustomDropdownState<T>();
}
class _CustomDropdownState<T> extends State<CustomDropdown> {
late T _selectedValue;
void initState() {
super.initState();
_selectedValue = widget.selectedValue;
}
Widget build(BuildContext context) {
final List<DropdownMenuItem<T>> _dropDownMenuModelNameItems = [];
for (int i = 0; i < widget.list.length; i++) {
_dropDownMenuModelNameItems.add(
DropdownMenuItem(
child: Text(
widget.allTitles[i],
),
value: widget.list[i],
),
);
}
return InputDecorator(
decoration: InputDecoration(
labelText: widget.labelText,
),
child: DropdownButtonHideUnderline(
child: DropdownButton<T>(
isExpanded: true,
isDense: true,
value: _selectedValue,
items: _dropDownMenuModelNameItems,
onChanged: (value) {
setState(() => _selectedValue = value!);
widget.onChanged(value);
},
),
),
);
}
}
本来なら、OKボタンタップ時に、選択した項目を保存したりすると思います。
以下、StateNotifierクラスにsaveメソッドを用意してみたサンプルです。(永続化層は未実装です)
StateNotifierクラスを用意して保存処理を実装 サンプル
import 'package:flutter_dialog_sample/presentation/pages/widgets/dropdown_dialog_button_widget.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'dialog_sample_state.freezed.dart';
// part 'dialog_sample_state.g.dart';
class DialogSampleState with _$DialogSampleState {
factory DialogSampleState({
(CityType.tokyo) CityType cityType,
('') String name,
('') String number,
}) = _DialogSampleState;
// factory DialogSampleState.fromJson(Map<String, dynamic> json) =>
// _$DialogSampleStateFromJson(json);
}
import 'package:flutter_dialog_sample/presentation/pages/widgets/dropdown_dialog_button_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'dialog_sample_state.dart';
final dialogSampleStateProvider =
StateNotifierProvider.autoDispose<DialogSampleNotifier, DialogSampleState>(
(ref) => DialogSampleNotifier(),
);
class DialogSampleNotifier extends StateNotifier<DialogSampleState> {
DialogSampleNotifier() : super(DialogSampleState());
void read() {}
void save({
CityType? cityType,
String? name,
String? number,
}) {
if (cityType != null) {
state = state.copyWith(cityType: cityType);
}
if (name != null) {
state = state.copyWith(name: name);
}
if (number != null) {
state = state.copyWith(number: number);
}
}
}
import 'package:flutter/material.dart';
import 'package:flutter_dialog_sample/presentation/common_widgets/custom_alert_dialog.dart';
import 'package:flutter_dialog_sample/presentation/common_widgets/custom_dropdown.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+ import '../dialog_sample_notifier.dart';
enum CityType {
tokyo,
nagoya,
osaka,
}
class City {
static const Map<CityType, String> allItems = {
CityType.tokyo: '東京',
CityType.nagoya: '名古屋',
CityType.osaka: '大阪',
};
}
class DropdownDialogButtonWidget extends HookConsumerWidget {
const DropdownDialogButtonWidget({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
+ final state = ref.watch(dialogSampleStateProvider);
+ final notifier = ref.watch(dialogSampleStateProvider.notifier);
- final cityType = useState<CityType>(CityType.tokyo);
+ final cityType = useState<CityType>(state.cityType);
return ElevatedButton(
child: const Text('ドロップダウンダイアログボタン'),
onPressed: () => showDialog(
context: context,
builder: (context) => CustomAlertDialog(
title: 'ドロップダウンダイアログ',
contentWidget: Column(
mainAxisSize: MainAxisSize.min,
children: [
CustomDropdown<CityType>(
labelText: '',
list: City.allItems.keys.toList(),
allTitles: City.allItems.entries
.map(
(e) => e.value,
)
.toList(),
selectedValue: cityType.value,
onChanged: (value) => cityType.value = value!,
),
],
),
cancelActionText: 'Cancel',
cancelAction: () {
+ cityType.value = state.cityType;
},
defaultActionText: 'OK',
action: () {
+ notifier.save(
+ cityType: cityType.value,
+ );
},
),
),
);
}
}
テキストフィールドを持つダイアログ
テキストフィールドを持つ上図のようなダイアログを実装してみます。
テキストフィールドダイアログ サンプル
import 'package:flutter/material.dart';
import 'package:flutter_dialog_sample/presentation/common_widgets/custom_text_field_dialog.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class TextFieldDialogButtonWidget extends HookConsumerWidget {
const TextFieldDialogButtonWidget({
Key? key,
}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
final nameController = useTextEditingController();
final numberController = useTextEditingController();
return ElevatedButton(
child: const Text('テキストフィールドダイアログボタン'),
onPressed: () => showDialog(
context: context,
builder: (context) => CustomTextFieldDialog(
title: 'テキストフィールドダイアログ',
contentWidget: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nameController,
maxLength: 10,
autovalidateMode: AutovalidateMode.onUserInteraction,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: '名前',
errorMaxLines: 2,
),
validator: (value) {
if (value == null || value.isEmpty) {
// return 'Name must not be null or empty.';
return '名前を入力してください。';
}
if (value.length > 10) {
return '';
}
return null;
},
),
TextFormField(
controller: numberController,
maxLength: 10,
autovalidateMode: AutovalidateMode.onUserInteraction,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: '番号',
errorMaxLines: 2,
),
validator: (value) {
if (value == null || value.isEmpty) {
// return 'Number must not be null or empty.';
return '番号を入力してください。';
}
if (value.length > 10) {
return '';
}
return null;
},
),
],
),
cancelActionText: 'Cancel',
cancelAction: () {},
defaultActionText: 'OK',
action: () {
// TODO: implement method
},
),
),
);
}
}
入力時と、OKボタンタップでバリデーションチェックが行われています。
こちらも保存処理は適宜実装必要ですが、一例を示すと、
保存処理実装 サンプル
+ import '../dialog_sample_notifier.dart';
class TextFieldDialogButtonWidget extends HookConsumerWidget {
const TextFieldDialogButtonWidget({
Key? key,
}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
+ final state = ref.watch(dialogSampleStateProvider);
+ final notifier = ref.watch(dialogSampleStateProvider.notifier);
- final nameController = useTextEditingController();
+ final nameController = useTextEditingController(text: state.name);
- final numberController = useTextEditingController();
+ final numberController = useTextEditingController(text: state.number);
return ElevatedButton(
child: const Text('テキストフィールドダイアログボタン'),
onPressed: () => showDialog(
context: context,
builder: (context) => CustomTextFieldDialog(
title: 'テキストフィールドダイアログ',
contentWidget: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nameController,
maxLength: 10,
autovalidateMode: AutovalidateMode.onUserInteraction,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: '名前',
errorMaxLines: 2,
),
validator: (value) {
if (value == null || value.isEmpty) {
// return 'Name must not be null or empty.';
return '名前を入力してください。';
}
if (value.length > 10) {
return '';
}
return null;
},
),
TextFormField(
controller: numberController,
maxLength: 10,
autovalidateMode: AutovalidateMode.onUserInteraction,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: '番号',
errorMaxLines: 2,
),
validator: (value) {
if (value == null || value.isEmpty) {
// return 'Number must not be null or empty.';
return '番号を入力してください。';
}
if (int.tryParse(value) == null) {
return '番号を入力してください。';
}
if (value.length > 10) {
return '';
}
return null;
},
),
],
),
cancelActionText: 'Cancel',
cancelAction: () {
nameController.text = state.name;
numberController.text = state.number;
},
defaultActionText: 'OK',
action: () {
notifier.save(
name: nameController.text,
number: numberController.text,
);
},
),
),
);
}
}
AlertDialogとCupertinoAlertDialogを出し分ける
iOS風のダイアログCupertinoAlertDialogの場合は、TextFormFieldを実装しようとすると、ビルド時にWidgetエラーが出てしまいます。
※詳細は下記参照ください。
結論としては、以下のように、evelation:0.0
のCardウィジェットでラップしてあげればOKです。
AlertDialogとCupertinoAlertDialogを出し分けサンプル
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialog_sample/presentation/common_widgets/custom_text_field_dialog.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class TextFieldDialogButtonWidget2 extends HookConsumerWidget {
const TextFieldDialogButtonWidget2({
Key? key,
}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
final nameController = useTextEditingController();
final numberController = useTextEditingController();
final builder = CustomTextFieldDialog(
title: 'テキストフィールドダイアログ2',
contentWidget: Card(
color: Colors.transparent,
elevation: 0.0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nameController,
maxLength: 10,
autovalidateMode: AutovalidateMode.onUserInteraction,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: '名前',
errorMaxLines: 2,
),
validator: (value) {
if (value == null || value.isEmpty) {
// return 'Name must not be null or empty.';
return '名前を入力してください。';
}
if (value.length > 10) {
return '';
}
return null;
},
),
TextFormField(
controller: numberController,
maxLength: 10,
autovalidateMode: AutovalidateMode.onUserInteraction,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: '番号',
errorMaxLines: 2,
),
validator: (value) {
if (value == null || value.isEmpty) {
// return 'Number must not be null or empty.';
return '番号を入力してください。';
}
if (value.length > 10) {
return '';
}
return null;
},
),
],
),
),
cancelActionText: 'Cancel',
cancelAction: () {},
defaultActionText: 'OK',
action: () {
// TODO: implement method
},
);
return ElevatedButton(
child: const Text('テキストフィールドダイアログボタン2'),
onPressed: () {
if (kIsWeb || Platform.isAndroid) {
showDialog(
context: context,
builder: (context) => builder,
);
} else {
showCupertinoDialog(
context: context,
builder: (context) => builder,
);
}
},
);
}
}
Platform.isAndroid
で、showDialog
とshowCupertinoDialog
の出し分けをしています。
builder部分は、先に定義しておいたCardウィジェットでラップしたものを共通で使用しています(showDialogのほうもCardウィジェットでラップしてもちゃんと表示できました)。
(2022.4.1追記)
▼CustomTextFieldDialogもCupertinoAlertDialogへの対応が必要でしたので追記しました🙇
CustomTextFieldDialog
// ignore_for_file: avoid_print
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class CustomTextFieldDialog extends StatelessWidget {
const CustomTextFieldDialog({
Key? key,
required this.title,
required this.contentWidget,
this.cancelActionText,
this.cancelAction,
required this.defaultActionText,
this.action,
}) : super(key: key);
final String title;
final Widget contentWidget;
final String? cancelActionText;
final Function? cancelAction;
final String defaultActionText;
final Function? action;
Widget build(BuildContext context) {
const key = GlobalObjectKey<FormState>('FORM_KEY');
if (kIsWeb || Platform.isAndroid) {
return AlertDialog(
title: Text(title),
content: Form(
key: key,
child: contentWidget,
),
actions: [
if (cancelActionText != null)
TextButton(
child: Text(cancelActionText!),
onPressed: () {
if (cancelAction != null) cancelAction!();
Navigator.of(context).pop(false);
},
),
TextButton(
child: Text(defaultActionText),
onPressed: () {
if (key.currentState!.validate()) {
print('Validate OK');
if (action != null) action!();
Navigator.of(context).pop(true);
} else {
print('Validate NG');
}
},
),
],
);
} else {
return CupertinoAlertDialog(
title: Text(title),
content: Form(
key: key,
child: contentWidget,
),
actions: [
if (cancelActionText != null)
CupertinoDialogAction(
child: Text(cancelActionText!),
onPressed: () {
if (cancelAction != null) cancelAction!();
Navigator.of(context).pop(false);
},
),
CupertinoDialogAction(
child: Text(defaultActionText),
onPressed: () {
if (key.currentState!.validate()) {
print('Validate OK');
if (action != null) action!();
Navigator.of(context).pop(true);
} else {
print('Validate NG');
}
},
),
],
);
}
}
}
さいごに
コード量が結構な量になってしまいましたね。
ご要望ありましたら、githubのリンクを公開したいと思います。
参考
Discussion