2021/10/23

【Flutter】カレンダーをモーダルで表示する

こんにちは。
ばんがです。

最近趣味でFlutterを触っています。いやぁ...業務と全く関係のない開発って...楽しいですね笑
モバイルアプリの開発はこれまで業務でも趣味でも全く経験がないので、色々調べながら地道に開発をしています。

開発の中で、カレンダーをモーダルで表示する方法がなかなかわからなかったので備忘録として残そうと思います。

カレンダーのWidgetを作る

まずカレンダーですが、自作すると色々大変なのでtable_calendarというライブラリを使いました。
https://pub.dev/packages/table_calendar

使い方は簡単で、以下のように記述するだけで立派なカレンダーが表示されます。

TableCalendar(
  firstDay: DateTime.utc(2010, 10, 16),
  lastDay: DateTime.utc(2030, 3, 14),
  focusedDay: DateTime.now(),
)


ただ、このままだと表示だけの、日付選択などができないカレンダーなので、タップした日付を選択でできるようにしましょう。

Stateful Widgetを作って、選択した日付の状態を保持するようにします。
最終的に以下のようなWidgetになりました。

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:table_calendar/table_calendar.dart';

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

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

class _CalendarState extends State<Calendar> {
  CalendarFormat _calendarFormat = CalendarFormat.month;
  DateTime _forcusedDay = DateTime.now();
  DateTime? _selectedDay;

  @override
  Widget build(BuildContext context) {
    return TableCalendar(
      firstDay: DateTime.utc(2010, 10, 16),
      lastDay: DateTime.utc(2030, 3, 14),
      focusedDay: DateTime.now(),
      calendarFormat: _calendarFormat,
      selectedDayPredicate: (day) {
        return isSameDay(_selectedDay, day);
      },
      onDaySelected: (selectedDay, forcusedDay) {
        if (!isSameDay(_selectedDay, selectedDay)) {
          setState(() {
            _selectedDay = selectedDay;
            _forcusedDay = forcusedDay;
          });
        }
      },
      onFormatChanged: (format) {
        if (_calendarFormat != format) {
          // Call `setState()` when updating calendar format
          setState(() {
            _calendarFormat = format;
          });
        }
      },
      onPageChanged: (forcusedDay) {
        _forcusedDay = forcusedDay;
      },
    );
  }
}

_selectedDayが選択した日付が入る変数です。
_forcusedDayはカレンダー内でフォーカスしたい日付です。(よくある現在の日付が強調表示されてるやつ)

  • selectedDayPredicateは特定の日付が選択している日付かどうかを判定するのに利用されます
  • onDaySelectedは日付をタップしたときに発火します。ステートを更新する処理を書いています。


カレンダーを日本語化する

デフォルトでは英語表記になっているので、日本語にしましょう。

まずアプリ自体のlocaleデータのセットアップをおこないます。

void main() {
  initializeDateFormatting().then((_) => runApp(const MyApp()));
}


これで他言語対応の準備ができたので、カレンダーのWidgetで言語を指定します。

TableCalendar(
  locale: 'ja_JP',
  ...
)


これでカレンダー内の言語を日本語にすることができました。

モーダル内にカレンダーを表示する

ボタンを押したらカレンダーがモーダルで出てきて、何か操作を完了したらモーダルが閉じて元の画面に戻る、ということをしたかったので、モーダルで表示できるようにしていきます。

調べてみた感じFlutterでモーダル(ダイアログ)を実現する方法は色々ありそうでしたが、今回はModalRouteを継承して独自のダイアログを実装してみました。
ダイアログ実装部分はほぼほぼ以下の記事を参考にさせていただきました。
https://www.egao-inc.co.jp/programming/flutter_custom_dialog/

モーダル部分の実装

ModalRouteは、現在のRouteとインタラクションができないような新規のRouteを実現するクラスのようです。
現在の画面の上にポップアップのように表示されますが、これは新規のRouteで、前のRoute(ポップアップ前の画面)とのインタラクションはできないので、モーダルを実現できる、ということのようです。
(まだはっきりどんなものか理解できておらず....)

実際には、ModalRouteを継承したPopupRouteクラスが今回の用途に適していそうだったので、こちらを継承して独自のモーダルクラスを実装しました。
(PopupRouteの実装を見る限り、ポップアップ用途に合わせてModalRouteの特定のプロパティを指定しているだけ)

import 'package:flutter/material.dart';

class CustomModal extends PopupRoute<void> {
  final Widget contents;

  CustomModal(this.contents): super();

  @override
  Duration get transitionDuration => Duration(milliseconds: 100);
  @override
  bool get barrierDismissible => false;
  @override
  Color get barrierColor => Colors.black.withOpacity(0.5);
  @override
  String? get barrierLabel => null;

  @override
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
    return Material(
      type: MaterialType.transparency,
      child: SafeArea(child: _buildOverlayContent(context))
    );
  }

  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
    return FadeTransition(
      opacity: animation,
      child: ScaleTransition(
        scale: animation,
        child: child
      )
    );
  }

  Widget _buildOverlayContent(BuildContext context) {
    return Container(
        margin: EdgeInsets.symmetric(horizontal: 32, vertical: 64),
        padding: EdgeInsets.all(16),
        color: Colors.white,
        child: Center(
          child: this.contents
        )
    );
  }
}


継承したクラス内で親クラスのメソッドをオーバーライドしてカスタムしていきます。

  • buildPageは新規ルート(モーダル内)のコンテンツを記述します。
  • buildTransitionsではモーダルのルートにどのように遷移するか、という遷移の挙動を指定できます。


モーダルの表示/非表示

最後に作ったモーダルのコンテンツとしてカレンダーを渡し、表示できるようにします。
モーダルはページ遷移と同じように新規ルートへの遷移なので、Navigatorでルートをpushしたりpopして表示/非表示を実現します。

import 'package:flutter/material.dart';
import 'package:weekly_task/custom_modal.dart';
import 'package:weekly_task/calendar.dart';

class CalendarModal {
  BuildContext context;
  CalendarModal(this.context) : super();

  void showCalendarModal() {
    Navigator.push(
      context,
      CustomModal(
          Column(
              children: [
                Calendar(),
                Row(
                  mainAxisAlignment: MainAxisAlignment.end,
                  children: [
                    TextButton(
                        child: const Text('キャンセル'),
                        onPressed: () => hideModal(),
                    ),
                    TextButton(
                      child: const Text('選択'),
                      onPressed: () {},
                    ),
                  ],
                )
              ]
          )
      )
    );
  }

  void hideModal() {
    Navigator.of(context).pop();
  }
}


あとはタップイベントなどで上記のメソッドを呼び出せばカレンダーのモーダルが表示されます。

onTap: () => { CalendarModal(context).showCalendarModal() }



まとめ

Flutterでモーダル(ダイアログ)と調べると、SimpleDialogやAlertDialogがよく出てきます。
SimpleDialogでもカレンダーWidgetを渡せば同じことはできそうでしたが、リファレンスには

A simple dialog offers the user a choice between several options. A simple dialog has an optional title that is displayed above the choices.

と書かれており、今回のような場合の用途とは別なのかな、と思って別の実装をしました。
(ほとんど他の方の記事のままの実装ですが...)

この辺のFlutterの一般的な実装、というのがまだよくわかってないので、今後もっといろんなリポジトリ等を覗いて把握していけたらと思います。

それでは。

参考

https://www.egao-inc.co.jp/programming/fluttercustomdialog/
https://qiita.com/coka__01/items/4c1aea5fb1646e463f91
https://api.flutter.dev/flutter/widgets/ModalRoute-class.html