🗒️

【Flutter】汎用的なAlertダイアログクラスを作成する【サンプル付き】

2022/03/30に公開

こんな方におすすめの記事です。

  • AlertDialogの実装がちょっと面倒に感じる
  • 汎用的に使いまわせるコードが欲しい

実務で様々な種類のダイアログを作成しているうちに、ボタンアクションなど纏めれる箇所があるなと思い、試しに作成してみました。

状態管理には、Hooks Riverpodを使用しています。

良ければ使ってみてください。

目指す構成

AlertDialogの前に、汎用的に使えるヘルパークラス(CustomAlertDialog, CustomTextFieldDialog)を用意します。

汎用的なAlertダイアログクラスを作成

CustomAlertDialog

ダイアログのcontentの内容を自由に実装するCustomAlertDialogを作成します。

custom_alert_dialog.dart
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ウィジェットを予め備えています。

custom_text_field_dialog.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');
    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

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

基本のダイアログ

ボタンタップで、ダイアログを表示させてみたコードがこちら。

基本のダイアログ サンプル
alert_dialog_button_widget.dart
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、キャンセルボタンタップ時の処理は適宜実装ください。

ドロップダウンリストを持つダイアログ

ドロップダウンリストを持つダイアログを作成してみました。

ドロップダウンダイアログ サンプル
dropdown_dialog_button_widget.dart
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
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クラスを用意して保存処理を実装 サンプル
dialog_sample_state.dart
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);
}
dialog_sample_notifier.dart
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);
    }
  }
}
dropdown_dialog_button_widget.dart
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,
+            );
          },
        ),
      ),
    );
  }
}

テキストフィールドを持つダイアログ

テキストフィールドを持つ上図のようなダイアログを実装してみます。

テキストフィールドダイアログ サンプル
text_field_dialog_button_widget.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 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ボタンタップでバリデーションチェックが行われています。

こちらも保存処理は適宜実装必要ですが、一例を示すと、

保存処理実装 サンプル
text_field_dialog_button_widget.dart

+ 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エラーが出てしまいます。
※詳細は下記参照ください。
https://www.choge-blog.com/programming/fluttercupertinoalertdialogtextfielduse/

結論としては、以下のように、evelation:0.0のCardウィジェットでラップしてあげればOKです。

AlertDialogとCupertinoAlertDialogを出し分けサンプル
text_field_dialog_button_widget.dart
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で、showDialogshowCupertinoDialogの出し分けをしています。
builder部分は、先に定義しておいたCardウィジェットでラップしたものを共通で使用しています(showDialogのほうもCardウィジェットでラップしてもちゃんと表示できました)。

(2022.4.1追記)
▼CustomTextFieldDialogもCupertinoAlertDialogへの対応が必要でしたので追記しました🙇

CustomTextFieldDialog
custom_text_field_dialog.dart
// 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のリンクを公開したいと思います。

参考

https://github.com/bizz84/codewithandrea_flutter_packages

https://qiita.com/hiesiea/items/807e3ca2b57ed37e4a9b

Discussion