FlutterでGoogle Calendarを作ってみる

この記事は [Flutter #1 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/flutter) 5日目の記事です。

# 完成図
Google Calendarのように画面いっぱいに日付のセルが広がり、ページングで表示する月を更新するウィジェットを作ります。
![tmp_v4738558438970830761 2](https://user-images.githubusercontent.com/43510799/101240300-c7cda380-3731-11eb-8be6-f62e86c73c26.gif)


# カレンダーのレイアウトを作る
ここでいう「レイアウトを作る」は、以下のような曜日と月のラベル、そしてからっぽのカレンダーのセルが画面いっぱいに描画された状態を作ることを指します。
<img src= https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488239/f97299ab-8f21-6df9-265e-3188e981c6ce.png width= 330>

### レイアウトの大枠を作る

**CalendarPageViewクラス**

```dart
class CalendarPageView extends StatelessWidget {
  const CalendarPageView({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: EdgeInsets.symmetric(vertical: 12, horizontal: 12),
          child: Text(
            "month and year",
            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
          ),
        ),
        /// スクロールできるコンポーネント
        Expanded(
          child: PageView.builder(
            controller: PageController(initialPage: 1200),
            itemBuilder: (context, index) {
              return const CalendarPage();
            },
          ),
        ),
      ],
    );
  }
}
```
**CalendarPageクラス**

```dart

class CalendarPage extends StatelessWidget {
  const CalendarPage({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Text("Calendar Page");
  }
}
```

#### `initialPage:1200`の意味

`PageController`の`initialPage`が`1200`となっていますが、これはPageViewが"ほぼ無限"に左にも右にもスワイプできる状態にするために設定しています。ここの数字はデフォルトでは0なので、何も設定しないと始点から後ろにスクロールできないカレンダーになってしまいます。
今回は1200に設定しているので、始点から後ろに1200回スワイプできることになります。このカレンダーは1枚で1ヶ月を表すので、この設定によって100年分遡れることになります。（100年以上遡りたい場合はここの数字をよしなに調整してください）




この段階でこのように `Calendar Page`という文字がスワイプで動くものが出来上がると思います。
![13150024006018 2](https://user-images.githubusercontent.com/43510799/101240257-6b6a8400-3731-11eb-8e9a-5b353745b996.gif)
ここからはこの `Calendar Page`にあたる部分（スクローラブルな部分）を実装していきます。

### カレンダーのセル、曜日のラベルを作る

**CalendarPageクラス**

```dart
class CalendarPage extends StatelessWidget {
  const CalendarPage({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        DaysOfTheWeek(),
        DatesRow(),
        DatesRow(),
        DatesRow(),
        DatesRow(),
        DatesRow(),
        DatesRow(),
      ],
    );
  }
}
```
**DaysOfTheWeekクラス**

```dart

const List<String> _DaysOfTheWeek = [
  'Sun',
  'Mon',
  'Tue',
  'Wed',
  'Thu',
  'Fry',
  'Sat'
];

/// 曜日のラベルを横並びに表示する。
class DaysOfTheWeek extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Row(
      children: _DaysOfTheWeek.map((day) {
        return Expanded(
          child: Padding(
            padding: EdgeInsets.only(bottom: 8),
            child: Text(
              day,
              textAlign: TextAlign.center,
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
        );
      }).toList(),
    );
  }
}
```
**DatesRow・DateCellクラス**

```dart


/// 1列で1週間を表すため、[DateCell]を7つ並べる。
class DatesRow extends StatelessWidget {
  const DatesRow({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Row(
        children: [
          DateCell(),
          DateCell(),
          DateCell(),
          DateCell(),
          DateCell(),
          DateCell(),
          DateCell(),
        ],
      ),
    );
  }
}

/// 日付を表示するセル
class DateCell extends StatelessWidget {
  const DateCell({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: DecoratedBox(
        /// カレンダーのセルを可視化するボーダー
        decoration: BoxDecoration(
          border: Border(
            top: BorderSide(color: Theme.of(context).dividerColor, width: 1),
            right: BorderSide(color: Theme.of(context).dividerColor, width: 1),
          ),
        ),
        child: Column(
          children: [
            ///日付が入る
            Text("day"),
          ],
        ),
      ),
    );
  }
}

```

ここまで書くと下の画像のように大体のレイアウトは完成します！
<img src= https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488239/f97299ab-8f21-6df9-265e-3188e981c6ce.png width= 330>
ここからはカレンダーとして日付を表示する機能を実装していきます。

# レイアウトにカレンダーの機能を埋め込む

ここらへんは色々な工夫ができると思いますが、今回はintに応じて適当なDateを返すextensionを作ってカレンダーの機能を作っていきます。


このextensionに関しては一旦全てここに貼り付けて、それぞれ使う箇所でその内容や意味について解説していこうと思います。

```dart
extension DateExtension on int {

  Duration get daysDuration {
    return Duration(days: (this == 7) ? 0 : -this);
  }

  String get monthName {
    final monthNameList = [
      "January",
      "February",
      "March",
      "April",
      "May",
      "June",
      "July",
      "August",
      "September",
      "October",
      "November",
      "December"
    ];
    return monthNameList[this - 1];
  }

  DateTime get visibleDateTime {
    final monthDif = this - 1200;
    final visibleYear = _visibleYear(monthDif);
    final visibleMonth = _visibleMonth(monthDif);
    return DateTime(visibleYear, visibleMonth);
  }

  int _visibleYear(int monthDif) {
    final currentMonth = DateTime.now().month;
    final currentYear = DateTime.now().year;
    final visibleMonth = currentMonth + monthDif;

    /// visibleMonthの表している月が
    /// 今年、もしくは来年以降の場合
    if (visibleMonth > 0) {
      return currentYear + (visibleMonth ~/ 12);

    /// visibleMonthが去年以前の場合
    } else {
      return currentYear + ((visibleMonth ~/ 12) - 1);
    }
  }

  int _visibleMonth(int monthDif) {
    final initialMonth = DateTime.now().month;
    final currentMonth = initialMonth + monthDif;

    /// visibleMonthの表している月が
    /// 今年、もしくは来年以降の場合
    if (currentMonth > 0) {
      return currentMonth % 12;

    /// visibleMonthが去年以前の場合
    } else {
      return 12 - (-currentMonth % 12);
    }
  }
}

```
次にこのextensionを使って先ほどの大枠に機能を敷き詰めていきます。

### DateExtensionを使って日付の情報を表示する

**CalendarPageViewクラス**

```dart
class CalendarPageView extends StatelessWidget {
  const CalendarPageView({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: EdgeInsets.symmetric(vertical: 12, horizontal: 12),
          child: Text(
            "month and year",
            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
          ),
        ),
        Expanded(
          child: PageView.builder(
            controller: PageController(initialPage: 1200),
            itemBuilder: (context, index) {
              return CalendarPage(index.visibleDateTime);
            },
          ),
        ),
      ],
    );
  }
}
```

**CalendarPageクラス**

```dart
class CalendarPage extends StatelessWidget {
  const CalendarPage(
    this.visiblePageDate, {
    Key key,
  }) : super(key: key);

  final DateTime visiblePageDate;

  List<DateTime> _getCurrentDates(DateTime dateTime) {
    final List<DateTime> result = [];
    final firstDay = _getFirstDate(dateTime);
    result.add(firstDay);
    for (int i = 0; i + 1 < 42; i++) {
      result.add(firstDay.add(Duration(days: i + 1)));
    }
    return result;
  }

  DateTime _getFirstDate(DateTime dateTime) {
    final firstDayOfTheMonth = DateTime(dateTime.year, dateTime.month, 1);
    return firstDayOfTheMonth.add(firstDayOfTheMonth.weekday.daysDuration);
  }
  @override
  Widget build(BuildContext context) {
    final currentDates = _getCurrentDates(visiblePageDate);
    return Column(
      children: [
        DaysOfTheWeek(),
        DaysRow(
          dates: currentDates.getRange(0, 7).toList(),
        ),
        DaysRow(
          dates: currentDates.getRange(7, 14).toList(),
        ),
        DaysRow(
          dates: currentDates.getRange(14, 21).toList(),
        ),
        DaysRow(
          dates: currentDates.getRange(21, 28).toList(),
        ),
        DaysRow(
          dates: currentDates.getRange(28, 35).toList(),
        ),
        DaysRow(
          dates: currentDates.getRange(35, 42).toList(),
        ),
      ],
    );
  }
}
```

**DatesRow・DateCellクラス**

```dart
class DatesRow extends StatelessWidget {
  const DatesRow({
    @required this.dates,
    Key key,
  }) : super(key: key);

  final List<DateTime> dates;

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Row(
        children: dates.map((date) {
          return _DateCell(date);
        }).toList(),
      ),
    );
  }
}

class _DateCell extends StatelessWidget {
  _DateCell(this.date);

  final DateTime date;

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: DecoratedBox(
          decoration: BoxDecoration(
            border: Border(
              top: BorderSide(color: Theme.of(context).dividerColor, width: 1),
              right:
                  BorderSide(color: Theme.of(context).dividerColor, width: 1),
            ),
          ),
          child: Column(
            children: [
              Text(date.day.toString()),
            ],
          ),
        ),
    );
  }
}
```

#### `visibleDateTime`は何をしているのか

CalendarPageViewクラス内でCalendarPageクラスを返す際の引数に`index.visibleDatetime`というものを渡しています。これは先ほど貼った`DateExtension`に含まれる関数で、そのページで表示すべき年月を返します。

```dart
  DateTime get visibleDateTime {
    final monthDif = this - 1200;
    final visibleYear = _visibleYear(monthDif);
    final visibleMonth = _visibleMonth(monthDif);
    return DateTime(visibleYear, visibleMonth);
  }

  int _visibleYear(int monthDif) {
    final currentMonth = DateTime.now().month;
    final currentYear = DateTime.now().year;
    final visibleMonth = currentMonth + monthDif;

    /// visibleMonthの表している月が
    /// 今年、もしくは来年以降の場合
    if (visibleMonth > 0) {
      return currentYear + (visibleMonth ~/ 12);

    /// visibleMonthが去年以前の場合
    } else {
      return currentYear + ((visibleMonth ~/ 12) - 1);
    }
  }

  int _visibleMonth(int monthDif) {
    final initialMonth = DateTime.now().month;
    final currentMonth = initialMonth + monthDif;

    /// visibleMonthの表している月が
    /// 今年、もしくは来年以降の場合
    if (currentMonth > 0) {
      return currentMonth % 12;

    /// visibleMonthが去年以前の場合
    } else {
      return 12 - (-currentMonth % 12);
    }
  }
}
```
`visibleDateTime`で`monthDif`というものが定義されていますが、これは`PageView`の`index`から初期値の`1200`を引いて出た差分で、最初に表示された月から何ヶ月離れているのかということを示しています。

#### `_getCurrentDate`は何をしているのか

`CalendarPage`では、`CalendarPageView`から受け取った`visiblePageDate`と`_getCurrentDates`を使って、そのページで表示すべき42個の日付を計算しています。

具体的に説明すれば、`_getFirstDate`でそのページの最初に表示すべき日付(`firstDate`)を取得してそれを一度リストに格納し、その後また「`firstDate`に`i+1`日を足してリストに格納する」を41回繰り返し、日付が42個入ったリストを作っています。

```dart
  List<DateTime> _getCurrentDates(DateTime dateTime) {
    final List<DateTime> result = [];
    final firstDay = _getFirstDate(dateTime);
    result.add(firstDay);
    for (int i = 0; i + 1 < 42; i++) {
      result.add(firstDay.add(Duration(days: i + 1)));
    }
    return result;
  }

  DateTime _getFirstDate(DateTime dateTime) {
    final firstDayOfTheMonth = DateTime(dateTime.year, dateTime.month, 1);
    return firstDayOfTheMonth.add(firstDayOfTheMonth.weekday.daysDuration);
  }

```
カレンダーの表示の軸は曜日で固定されているため、そのページの最初の日が必ずしもそのページの月の最初の日になるというわけではありません。`_getFirstDate`で使われてる`DateExtension`の`daysDuration`は適切なそのページの最初の日を取得するための調整の役を担っています。

```dart
  Duration get daysDuration {
    return Duration(days: (this == 7) ? 0 : -this);
  }
```

`weekday`は月曜を`1`,日曜を`7`としているので、この`daysDuration`では「`weekday`が`7`だったら調整をせず（`0`を返す）、それ以外であればその分引く」といったことをやっています。

ここまでくると完成が見えてきます。あとは`month and year`と書かれているラベルを修正する必要があります。
<img src= https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488239/f3e123c3-2ca6-b39a-a2c7-838fa52a5f6a.png width= 330>

### 年月を表示するラベルを作る
`PageView`の`index`の更新をラベルにも渡す必要があります。今回はシンプルに`CalendarPageView`クラスを`StatefulWidget`にして、`PageView`の`onPageChanged`内で`setState`を叩き`_currentIndex`を更新することによって実装します。

**CalendarPageViewクラス**

```dart
class CalendarPageView extends StatefulWidget {
  const CalendarPageView({Key key}) : super(key: key);

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

class _CalendarPageViewState extends State<CalendarPageView> {
  int _currentIndex = 1200;
  @override
  Widget build(BuildContext context) {
    final visibleMonth = _currentIndex.visibleDateTime.month.monthName;
    final visibleYear = _currentIndex.visibleDateTime.year.toString();
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: EdgeInsets.symmetric(vertical: 12, horizontal: 12),
          child: Text(
            visibleMonth + " " + visibleYear,
            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
          ),
        ),
        Expanded(
          child: PageView.builder(
            controller: PageController(initialPage: 1200),
            itemBuilder: (context, index) {
              return CalendarPage(index.visibleDateTime);
            },
            onPageChanged: (index) {
              setState(() {
                _currentIndex = index;
              });
            },
          ),
        ),
      ],
    );
  }
}
```

#### `.monthName`の解説

ラベルで年月を表示するのに使われている`.monthName`ですが、これは`DateExtension`のメソッドです。

```dart
  String get monthName {
    final monthNameList = [
      "January",
      "February",
      "March",
      "April",
      "May",
      "June",
      "July",
      "August",
      "September",
      "October",
      "November",
      "December"
    ];
    return monthNameList[this - 1];
  }
```

月の数字に対応する名前を`monthNameList`のindexで引っ張ってきています。

 
ここまでできれば完成です！🎉
![tmp_v4738558438970830761 2](https://user-images.githubusercontent.com/43510799/101240300-c7cda380-3731-11eb-8be6-f62e86c73c26.gif)


# 最後に
時間の都合上記事の内容からは省きましたが、実装を進めればこんな感じでイベントをカレンダー上に表示させることも可能です！
![cell_calendar](https://user-images.githubusercontent.com/43510799/92649244-ae30e180-f325-11ea-92a8-3e21d76aacd8.gif)
このカレンダー自体はオープンソースとして公開してあるので、もし詳細が気になる方がいればこちらのソースコードを覗いてみてください。
[![santa112358/cell_calendar - GitHub](https://gh-card.dev/repos/santa112358/cell_calendar.svg)](https://github.com/santa112358/cell_calendar)