🗓️

マンスリーカレンダーはどうやって描画されているのか #TimeTreeアドカレ

2023/12/15に公開

これは株式会社TimeTree Advent Calendar 2023の15日目の記事です。

https://qiita.com/advent-calendar/2023/timetree

こんにちは。フロントエンドエンジニアの @fujikky です。

マンスリーカレンダーとは、月ごとの予定を確認するための画面です。一般的な壁掛けカレンダーがこの形式なので馴染みのある方も多いと思います。実際、TimeTreeユーザーの多くの方がマンスリーカレンダーをメインビューとしています。


マンスリーカレンダーのイメージ

今日が何日なのかを確認したり、終日や非終日の違いが分かったり、複数日にまたがった予定がいつまで続くのが一目で分かるビューとなっており、ユーザーはユーザーは簡単に予定を把握できます。また、特徴として最初の週・最後の週は先月の日付や来月の日付が表示されることがあります。

この記事ではマンスリーカレンダーを実装する上での難しさや手順を紹介しています。ソースコードは React + TypeScript をベースに解説しますが、iOSやAndroidアプリでもだいたい似たような実装になっています。

マンスリーカレンダーの難しさ

マンスリーカレンダーは、予定を一目でユーザーに見せるためにいくつかの視覚的な工夫が含まれています。それが実装の難易度に繋がっているのですが、その例を紹介します。

  • 毎月の週数が異なる
    月や開始曜日によっては4週〜6週に変化します。そのためレイアウトを固定することができません。もちろん6週固定表示にしたり、縦スクロールにすることもできますが、TimeTreeでは動的なレイアウトを採用しています。


    月曜始まりの場合の週数の変化

  • 日付の並び順
    各日付ごとに予定の並び順を定義する必要があります。基本的には祝日、終日、時間順に並んでいれば良さそうですが、終日の予定、同じ時間の予定が複数ある場合はどうするかを考える必要があります。また、アプリでプレミアム機能を使っている場合は、「予定の優先度」を設定できるので、それも考慮に入れます。


    複数日の予定→祝日→終日→時刻→予定の作成日時の順で並べる

  • 日付をまたぐ予定
    予定が日をまたぐ場合、複数の日に表示されます。TimeTreeでは、その予定が続いていることを表すために帯が伸びるような表現をしています。そうすると本来その日に描画される予定が押し出される形になります。まるでブロックのように積み上げていくイメージです。さらにイレギュラーなパターンでは、その日にある予定の途中に日またぎの予定が差し込まれたり、予定の間に隙間が空いてしまうこともあります。

描画ロジックのステップ

実際にマンスリーカレンダーを作り、予定を描画する仕組みを解説していきます。

1. マスを作る

予定を描画するためのマスを作ります。この時必要な変数は以下になります。

  • カレンダーの開始曜日
    月曜始まりか日曜始まりかでカレンダーの開始日・終了日が変わってきます。ちなみにTimeTreeでは週の開始曜日は設定で変更可能で、かつ日本ではデフォルト月曜日、アメリカではデフォルト日曜日など国や言語毎にデフォルト値を変えています。
  • カレンダーの年月
    内部的には各月1日の日付を使っていて、日付ライブラリを使って、該当月の開始日・終了日を計算します。
    • 例えば Luxon を使っている 場合だと以下のようなコードになります。
      const date = DateTime.fromISO("2023-12-01");
      const start = date.startOf("week"); // 月初の週の初めを取得
      const end = date.endOf("month").endOf("week"); // 月末の週末を取得
      // 実際には週の開始曜日を考慮する必要がある
      
  • 画面の高さ
    予定が何件描画できるのかを判定するために必要になります。画面の高さ➗週数➗予定の高さが表示可能な予定の件数になります。

    Web版では表示可能な予定の個数まで計算した上でCSS Gridのマスを作っている

2. 日付を埋める

開始日から終了日が分かったので、その間の日数分ループし、日付を埋めていきます。

日付のテキストカラーを土曜日は青、日曜日は赤になるように色を変えます。ちなみに土曜日を青とする文化はアジア圏のみなのでTimeTreeでは設定可能にした上で、アジア圏だけデフォルトオンにしています。また、APIやキャッシュDBから祝日情報を取得して、一致した日付部分を赤色に変えます。該当月でない日付(先月・翌月)は薄くするなどの処理をします。

また、「今日」を目立たせたり、「選択中」の日付の背景を変更するなどいくつかの状態をもとに表示状態をコントロールします。

日付部分のコンポーネントはこのような型になります。

type DayTitleProps = {
  readonly isCurrentMonth?: boolean;
  readonly isHoliday?: boolean;
  readonly isToday?: boolean;
  readonly isSunday?: boolean;
  readonly isSaturday?: boolean;
};


日付が埋まった状態

3. APIやキャッシュDBから予定データを取得する

月曜日開始で2023年11月の場合、2023年10月30日〜2023年12月3日の間の予定を取得します。

TimeTreeの予定データのうち、繰り返予定はサーバー上では1件分のデータしか保持されておらず、APIのレスポンスでも1件分しか返ってきません。代わりに予定データの中に予定の繰り返しを表現するRRULEという情報が含まれているので、それをクライアントのRRULEライブラリを使って展開し、カレンダーにマッピングする用のデータとします。ちなみにRRULEはRFC5545で定義されている仕様で、iCalendarなどの予定形式を構成する一部です。

https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html

4. 予定データを日付ごとにマッピングする

カレンダーの左上を0としたインデックスごとに配列を用意し、該当する日付に予定データを入れていきます。

このとき予定が週がまたいでいる場合は週の終わりと翌週の開始で分割します。また、それぞれの予定に何日間分あるのか(span)を入れておきます。

例えば以下のような予定データがあるときは、次のようなマッピングテーブルが生成されます。

APIから取得した予定データ
[
  {
    title: "Event",
    color: "#47b2f7",
    startAt: "2023-11-03",
    endAt: "2023-11-06",
  },
]
生成されるマッピングテーブル
{
  4: [{ title: "Event", color: "#47b2f7", span: 3 }],
  7: [{ title: "Event", color: "#47b2f7", span: 1 }]
}


描画例

5. 各日付の予定アイテムをソートする

マッピングテーブル上の各予定の配列を 複数日祝日終日時刻予定の作成日時 となるようにソートします。

6. 日をまたぐ予定に対して適切な位置にプレースホルダーを挿入する

マッピングテーブルを左上から順番にループし、日をまたいだ予定がある場合は、その翌日に「その位置に予定が描画されるプレースホルダー(isOccupied: true)」を挿入し、元々あった予定を押し出します。

またこのとき、プレースホルダーを挿入する位置までに予定が埋まっていない場合は「その位置に予定が描画されないプレースホルダー(isOccupied: false)」として挿入します。 isOccupied は表示しきれなかった残りの予定をカウントするのに使っています。

例えば次のような予定の配置の場合は、以下のような位置にプレイスホルダーが挿入されます。

プレースホルダーが挿入されたマッピングテーブル
{
    8: [
        { "type": "event", "title": "Event11", "color": "#f35f8c", "span": 2 },
        { "type": "event", "title": "Event12", "color": "#fdc02d", "span": 3 },
        { "type": "event", "title": "Event13", "color": "#3dc2c8", "span": 1 },
        { "type": "event", "title": "Event14", "color": "#b38bdc", "span": 1 },
    ],
    9: [
        { "type": "placeholder", "isOccupied": true },
        { "type": "placeholder", "isOccupied": true },
        { "type": "event", "title": "Event15", "color": "#2ecc87", "span": 1 },
    ],
    10: [
        { "type": "placeholder", "isOccupied": false },
        { "type": "placeholder", "isOccupied": true },
    ]
}

7. 日付を座標に変換

ここからが実際の描画の処理になるのでプラットフォームによって処理が分かれます。Web版TimeTreeの最近作られたマンスリーカレンダーでは CSS Grid を使っているので、マッピングテーブルを順番にグリッドの座標に変換し、そのデータを style へ反映させます。

<div style={{ gridColumn: `${x}`, gridRow: `${y} / span ${span}` }}>
  {day}
</div>

このときプレースホルダーは除外します。また、描画できる予定の上限に達した場合もそれ以上は座標データを作らないようにし、代わりに隠れている予定がいくつあるのかの数字をつけます(アプリだと三角マークが表示されています)


表示領域に収まらない場合は数値バッジを表示

まとめ

いかがでしたでしょうか。マンスリーカレンダーを作る際のポイントを紹介しました。
今回は描画パフォーマンスの改善までは踏み込んでいませんが、基本ループ処理が多いので改善の余地はたくさんあると思います。

TimeTreeの採用情報

TimeTreeのミッションに向かって一緒に挑戦してくれる仲間を探しています。TimeTreeで働くことに興味がある方はぜひ、Company Deck(会社紹介資料)や採用ページをご覧ください!

Company Deck(会社紹介資料)
TimeTree採用ページ

TimeTree Tech Blog

Discussion