【Flutter】table_calendarの見た目をカスタマイズする方法

## はじめに

みなさんはFlutterのカレンダーUIはどのように作っていますか？
私はおそらく一番人気であろうtable_calendarを使うことが多いです。便利です。
今回はそんなtable_calendarの見た目、特に日付部分をいじる機会があったのでまとめます。
対象はtable_calendar自体はある程度使っている方を想定し、概要と基本機能は省略します。
しれっとProviderとLocalizationを使っていますがこちらも今回は省略させてください。

https://pub.dev/packages/table_calendar

完成版コードはGithubにもあります。

https://github.com/shin-yamamoto/custom_table_calendar

## 完成イメージ

今回のポイントは3つです。

- カレンダーの日付に罫線をつけたい
- 日曜日を赤色、土曜日を青色にしたい
- 午前午後の予定を点々ではなく一覧でわかる表現にしたい

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2274682/c7903fc6-47f7-cee6-047c-a7d25686b4f6.jpeg" width="300">

## 解説

前から順番に解説するパターンでいきます。
**※途中の解説コードは軽量化しているため単体では完成イメージと見た目が異なります。動かす際はご注意ください。**
全体コードは完成イメージと同じ見た目になります。

### カレンダーの構造とCalendarBuildersを理解する

完成イメージを作成するには、TableCalendarクラスの各パラメータと合わせてCalendarBuildersという項目を設定します。
CalendarBuildersが今回の主役です。CalendarBuildersはカレンダーの各セルを作る関数をまとめたものです。
さて、ここでいうセルとはなんでしょうか？
まずはtable_calendarがどのようにレンダリングされているか確認してみましょう。
一言でいうとカレンダーはWidget（Container）で表現されたセルの集合体です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2274682/5224f761-8b5e-837c-039b-c57d3d780c3b.png)

さらに各セルは選択されている日付、その他の日付、というように異なる属性を持ちます。
CalendarBuildersには各属性のセルを作成する関数名が予め定義されており、変更したい部分に対応する関数に実処理を与える形でカスタマイズしていきます。
今回は以下のような感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2274682/25c5553d-4817-24d2-c081-f14c8180651e.png)

### カレンダーの日付に罫線をつけたい

では具体的に項目を設定していきましょう。
table_calendarには見た限り表組みされたカレンダーにあとから線を付け足す機能はないようです。
なので今回の戦略は「各セルを枠線のついたContainerに置き換える」です。
とりあえずデフォルトのセルを枠線付きにしてみましょう。

```dart:lib\calendar_page.dart
TableCalendar<dynamic>(
  focusedDay: DateTime(2021,12,1),
  firstDay: DateTime(2021,12,1),
  lastDay: DateTime(2021,12,31),
  // カスタマイズ用の関数を渡してやりましょう
  calendarBuilders: CalendarBuilders(
    defaultBuilder: (
        BuildContext context, DateTime day, DateTime focusedDay) {
      return AnimatedContainer(
        duration: const Duration(milliseconds: 250),
        margin: EdgeInsets.zero,
        decoration: BoxDecoration(
          border: Border.all(
            color: Colors.green[600]!,
            width: 0.5,
          ),
        ),
        alignment: Alignment.topCenter,
        child: Text(
          day.day.toString(),
          style: TextStyle(
            color: Colors.black87,
          ),
        ),
      );
    },
  ),
),
```

defaultBuilderはじめとしたセル作成関数はBuildContext、何月何日のセルを作成しようとしているかを示すDateTime、フォーカスの当たっているところを示すDateTimeの3つを引数にとります。
基本的には第2引数を気にすれば大丈夫です。
返す内容はBorderを付与して中に第2引数の日付部分の文字列を入れただけのContainerとシンプルです。
関数まとめ役のCalendarBuildersクラスが用意されているので、これに包んでTableCalendarへ渡します。

AnimatedContainerにしていますが、これはtable_calendarのデフォルト設定をパクっています。duration、margin、alignmentも同様。
デフォルト設定はライブラリ内部で確認できます。
TableCalendar→CellContentクラスのbuildメソッドに記述があるので参考にしてみてください。

同じ要領で他のセル作成関数も設定してしまいましょう。

```dart:lib\calendar_page.dart
TableCalendar<dynamic>(
  focusedDay: DateTime(2021,12,1),
  firstDay: DateTime(2021,12,1),
  lastDay: DateTime(2021,12,31),
  // カスタマイズ用の関数を渡してやりましょう
  calendarBuilders: CalendarBuilders(
    daysOfWeekBuilder: (BuildContext context, DateTime day) {
      // <TableCalendarの中身からコピペ>
      // アプリの言語設定読み込み
      final locale = Localizations.localeOf(context).languageCode;

      // アプリの言語設定に曜日の文字を対応させる
      final dowText =
          const DaysOfWeekStyle().dowTextFormatter?.call(day, locale) ??
              DateFormat.E(locale).format(day);
      // </ TableCalendarの中身からコピペ>

      return Container(
        decoration: BoxDecoration(
          border: Border.all(
            width: 0.5,
            color: Colors.green[600]!,
          ),
        ),
        child: Center(
          child: Text(
            dowText,
            style: TextStyle(
              color: Colors.black87,
            ),
          ),
        ),
      );
    },
    defaultBuilder: (
        BuildContext context, DateTime day, DateTime focusedDay) {
      return AnimatedContainer(
        duration: const Duration(milliseconds: 250),
        margin: EdgeInsets.zero,
        decoration: BoxDecoration(
          border: Border.all(
            color: Colors.green[600]!,
            width: 0.5,
          ),
        ),
        alignment: Alignment.topCenter,
        child: Text(
          day.day.toString(),
          style: TextStyle(
            color: Colors.black87,
          ),
        ),
      );
    },
    /// 有効範囲（firstDay~lastDay）以外の日付部分を生成する
    disabledBuilder: (
        BuildContext context, DateTime day, DateTime focusedDay) {
      return AnimatedContainer(
        duration: const Duration(milliseconds: 250),
        margin: EdgeInsets.zero,
        decoration: BoxDecoration(
          border: Border.all(
            color: Colors.green[600]!,
            width: 0.5,
          ),
        ),
        alignment: Alignment.topCenter,
        child: Text(
          day.day.toString(),
          style: TextStyle(
            color: Colors.grey,
          ),
        ),
      );
    },
    selectedBuilder: (
        BuildContext context, DateTime day, DateTime focusedDay) {
      return AnimatedContainer(
        duration: const Duration(milliseconds: 250),
        margin: EdgeInsets.zero,
        decoration: BoxDecoration(
          border: Border.all(
            color: Colors.red[800]!,
            width: 3.0,
          ),
        ),
        alignment: Alignment.topCenter,
        child: Text(
          day.day.toString(),
          style: TextStyle(
            color: Colors.black87,
          ),
        ),
      );
    },
  ),
),
```

disabledBuilderはfirstDay～lastDayの範囲外を示します。日付文字列を薄くしました。
selectedBuilderは選択されているセルを示します。枠を赤く、太くしました。
dowBuilderは若干おまじないですがだいたいやることは同じです。
作成しようとしている日付の曜日を枠付きContainerに入れます。
これで罫線が引けました！

ちなみにこのやり方だと隣接するセルの枠線が2重になります。これにより格子部分と外枠部分の太さが異なってきます。
Borderのwidthの値はあまり大きくすると2重と1重の差が激しくなって目立つので注意しましょう。
このあたりうまくやる方法募集してます。

### 日曜日を赤色、土曜日を青色にしたい

ここはおまけみたいなものです。休日を赤色にするだけであれば、weekendDaysやholidayPredicateによって週末や祝日を正しく設定することで自動的に文字色が変わります。
しかしながら土曜日を青にする機能はないので個別に設定してやります。
たとえば以下のような関数を作ってセル作成関数から呼び出せば大丈夫です。
dowBuilderとselectedBuilderにも設定しないと一部だけ色が変わるので注意です。

```dart:lib\calendar_page.dart
Color _textColor(DateTime day) {
  const _defaultTextColor = Colors.black87;

  if (day.weekday == DateTime.sunday) {
    return Colors.red;
  }
  if (day.weekday == DateTime.saturday) {
    return Colors.blue[600]!;
  }
  return _defaultTextColor;
}
// ～省略～
defaultBuilder: (
        BuildContext context, DateTime day, DateTime focusedDay) {
      return AnimatedContainer(
        duration: const Duration(milliseconds: 250),
        margin: EdgeInsets.zero,
        decoration: BoxDecoration(
          border: Border.all(
            color: Colors.green[600]!,
            width: 0.5,
          ),
        ),
        alignment: Alignment.topCenter,
        child: Text(
          day.day.toString(),
          style: TextStyle(
            color: _textColor(day),
          ),
        ),
      );
    },
```

### 午前午後の予定を点々ではなく一覧でわかる表現にしたい

マーカーのカスタマイズはmarkerBuilderを使います。これまでのセル作成関数と違い日付は表現しません。
セル作成関数と同時に呼び出されて予定に応じたマーカーを作成し、重ね合わせて表示されます。
イメージは以下。コードも合わせて見ていきましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2274682/4c50862f-afea-1999-e63a-a6644a52f40a.png)

```dart:lib\calendar_page.dart
TableCalendar<dynamic>(
  focusedDay: DateTime(2021,12,1),
  firstDay: DateTime(2021,12,1),
  lastDay: DateTime(2021,12,31),
  // カスタマイズ用の関数を渡してやりましょう
  calendarBuilders: CalendarBuilders(
    // ～省略～
    markerBuilder: (
        BuildContext context, DateTime day, List<dynamic> dailyScheduleList) {
      final am = dailyScheduleList.first ?? '';
      final pm = dailyScheduleList.last ?? '';

      _scheduleText(String schedule) {
        if (schedule == 'on') {
          return const Text(
            '○',
            style: TextStyle(fontWeight: FontWeight.bold),
          );
        } else if (schedule == 'off') {
          return const Text(
            '×',
            style: TextStyle(fontWeight: FontWeight.bold),
          );
        } else {
          return const Text('-');
        }
      }

      return Padding(
        padding: const EdgeInsets.only(top: 24),
        child: Column(
          children: [
            _scheduleText(am),
            _scheduleText(pm),
          ],
        ),
      );
    }
  ),
  eventLoader: (DateTime dateTime) {
    final schedule = {
      '1': ['on', 'off'],
      '2': ['off', 'on'],
      '3': ['on', 'on'],
    };
    return schedule[dateTime.day.toString()] ?? [null, null];
  },
),
```

eventLoaderは作成するセルの日付を受け取り、日次の予定をListで返します。
うまく日付を変換してお好みのデータからキー検索などしてください。
今回は1日につき2要素のListを返してマーカーに変換できるようにデータを用意しました。
eventLoaderの詳しい使い方は公式を参考にしてください。
あとは日次スケジュールの中身をチェックして記号に変換しているだけです。
ここでのポイントは以下です。

- マーカーはColumnなども使えて自由に組める
- Stackするが故にベースのセルとのバランスを考える（変にかぶらないように、溢れないように設計する）

## 全体コード

ここまでをベースに処理の切り出しや諸々の調整を含めたものです。
セル作成関数は共通部分が多くなりがちなのでテンプレWidgetという形でまとめておくと便利だと思います。

```dart:lib\calendar_page.dart
import 'package:custom_table_calendar/calendar_model.dart';
import 'package:custom_table_calendar/custom_calendar_builders.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:table_calendar/table_calendar.dart';

class CalendarPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<CalendarModel>(
      create: (_) => CalendarModel()..init(),
      child: Consumer<CalendarModel>(builder: (context, model, snapshot) {
        final CustomCalendarBuilders customCalendarBuilders =
            CustomCalendarBuilders();

        return Scaffold(
          appBar: AppBar(),
          body: SingleChildScrollView(
            child: Padding(
              padding: EdgeInsets.all(16),
              child: TableCalendar<dynamic>(
                focusedDay: model.focusedDay,
                firstDay: model.firstDayOfMonth,
                lastDay: model.lastDayOfMonth,
                locale: Localizations.localeOf(context).languageCode,
                // markerBuilderの大きさに合わせて調整してください
                rowHeight: 70,
                // 曜日文字の大きさに合わせて調整してください
                // 日本語だとこのくらいで見切れなくなります
                daysOfWeekHeight: 32,
                // 見た目をスッキリさせるためなのでなくても大丈夫です
                headerStyle: const HeaderStyle(
                  titleCentered: true,
                  formatButtonVisible: false,
                  leftChevronVisible: false,
                  rightChevronVisible: false,
                ),
                calendarStyle: const CalendarStyle(
                  // true（デフォルト）の場合は
                  // todayBuilderが呼ばれるので設定しましょう
                  isTodayHighlighted: false,
                ),
                // カスタマイズ用の関数を渡してやりましょう
                calendarBuilders: CalendarBuilders(
                  dowBuilder: customCalendarBuilders.daysOfWeekBuilder,
                  defaultBuilder: customCalendarBuilders.defaultBuilder,
                  disabledBuilder: customCalendarBuilders.disabledBuilder,
                  selectedBuilder: customCalendarBuilders.selectedBuilder,
                  markerBuilder: customCalendarBuilders.markerBuilder,
                ),
                eventLoader: model.fetchScheduleForDay,
                selectedDayPredicate: (day) {
                  return isSameDay(model.selectedDay, day);
                },
                onDaySelected: (selectedDay, focusedDay) {
                  model.selectDay(selectedDay, focusedDay);
                },
              ),
            ),
          ),
        );
      }),
    );
  }
}
```

```dart:lib\custom_calendar_builders.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:table_calendar/table_calendar.dart';

class CustomCalendarBuilders {
  final Color _borderColor = Colors.green[600]!;

  Color _textColor(DateTime day) {
    const _defaultTextColor = Colors.black87;

    if (day.weekday == DateTime.sunday) {
      return Colors.red;
    }
    if (day.weekday == DateTime.saturday) {
      return Colors.blue[600]!;
    }
    return _defaultTextColor;
  }

  /// 曜日部分を生成する
  Widget daysOfWeekBuilder(BuildContext context, DateTime day) {
    // <TableCalendarの中身からコピペ>
    // アプリの言語設定読み込み
    final locale = Localizations.localeOf(context).languageCode;

    // アプリの言語設定に曜日の文字を対応させる
    final dowText =
        const DaysOfWeekStyle().dowTextFormatter?.call(day, locale) ??
            DateFormat.E(locale).format(day);
    // </ TableCalendarの中身からコピペ>

    return Container(
      decoration: BoxDecoration(
        border: Border.all(
          width: 0.5,
          color: _borderColor,
        ),
      ),
      child: Center(
        child: Text(
          dowText,
          style: TextStyle(
            color: _textColor(day),
          ),
        ),
      ),
    );
  }

  /// 通常の日付部分を生成する
  Widget defaultBuilder(
      BuildContext context, DateTime day, DateTime focusedDay) {
    return _CalendarCellTemplate(
      dayText: day.day.toString(),
      dayTextColor: _textColor(day),
      borderColor: _borderColor,
    );
  }

  /// 有効範囲（firstDay~lastDay）以外の日付部分を生成する
  Widget disabledBuilder(
      BuildContext context, DateTime day, DateTime focusedDay) {
    return _CalendarCellTemplate(
      dayText: day.day.toString(),
      dayTextColor: Colors.grey,
      borderColor: _borderColor,
    );
  }

  /// 選択された日付部分を生成する
  Widget selectedBuilder(
      BuildContext context, DateTime day, DateTime focusedDay) {
    return _CalendarCellTemplate(
      dayText: day.day.toString(),
      dayTextColor: _textColor(day),
      borderColor: Colors.red[800],
      borderWidth: 3.0,
    );
  }

  /// 予定のマーカー部分を生成する
  Widget markerBuilder(
    BuildContext context,
    DateTime day,
    List<dynamic> dailyScheduleList,
  ) {
    final am = dailyScheduleList.first ?? '';
    final pm = dailyScheduleList.last ?? '';

    _scheduleText(String schedule) {
      if (schedule == 'on') {
        return const Text(
          '○',
          style: TextStyle(fontWeight: FontWeight.bold),
        );
      } else if (schedule == 'off') {
        return const Text(
          '×',
          style: TextStyle(fontWeight: FontWeight.bold),
        );
      } else {
        return const Text('-');
      }
    }

    return Padding(
      padding: const EdgeInsets.only(top: 24),
      child: Column(
        children: [
          _scheduleText(am),
          _scheduleText(pm),
        ],
      ),
    );
  }
}

class _CalendarCellTemplate extends StatelessWidget {
  _CalendarCellTemplate({
    Key? key,
    String? dayText,
    Duration? duration,
    Alignment? textAlign,
    Color? dayTextColor,
    Color? borderColor,
    double? borderWidth,
  })  : dayText = dayText ?? '',
        duration = duration ?? const Duration(milliseconds: 250),
        textAlign = textAlign ?? Alignment.topCenter,
        dayTextColor = dayTextColor ?? Colors.black87,
        borderColor = borderColor ?? Colors.black87,
        borderWidth = borderWidth ?? 0.5,
        super(key: key);

  final String dayText;
  final Color? dayTextColor;
  final Duration duration;
  final Alignment? textAlign;
  final Color? borderColor;
  final double borderWidth;

  @override
  Widget build(BuildContext context) {
    final defaultBorderColor = Theme.of(context).colorScheme.primary;
    return AnimatedContainer(
      duration: duration,
      margin: EdgeInsets.zero,
      decoration: BoxDecoration(
        border: Border.all(
          color: borderColor ?? defaultBorderColor,
          width: borderWidth,
        ),
      ),
      alignment: textAlign,
      child: Text(
        dayText,
        style: TextStyle(
          color: dayTextColor,
        ),
      ),
    );
  }
}
```

```dart:lib\calendar_model.dart
import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart';

class CalendarModel extends ChangeNotifier {
  DateTime now = DateTime.now();
  DateTime focusedDay = DateTime.now();
  DateTime selectedDay = DateTime.now();

  Future<void> init() async {}

  DateTime get firstDayOfMonth => DateTime(now.year, now.month, 1);

  DateTime get lastDayOfMonth => DateTime(now.year, now.month + 1, 0);

  void selectDay(DateTime selectedDay, DateTime focusedDay) {
    if (!isSameDay(this.selectedDay, selectedDay)) {
      this.selectedDay = selectedDay;
      this.focusedDay = focusedDay;
      notifyListeners();
    }
  }

  List<dynamic> fetchScheduleForDay(DateTime dateTime) {
    final schedule = {
      '1': ['on', 'off'],
      '2': ['off', 'on'],
      '3': ['on', 'on'],
    };
    return schedule[dateTime.day.toString()] ?? [null, null];
  }
}
```

## おわりに

いかがでしょうか？初投稿ということで気合が入って思ったより長くなってしまいました...
table_calendarのカスタマイズは少し設定が面倒なところがありますが、希望のレイアウトが作れそうなら自作のカレンダーWidgetを作るより乗っかってしまったほうがいいのかなと思います。
他の機能を活用できたり、ある程度フレームワークに則ったコードになるため統一感が出たりといったメリットが考えられます。
みなさんよいFlutterライフを！
ご感想、ご意見、間違いの指摘などもあればコメントお願いします！
本記事へのリンクは転載自由です。