ReactNativeでGoogleカレンダーのようなUIを実現したい

※ この記事は [React Native Advent Calendar 2023](https://qiita.com/advent-calendar/2023/react-native) の 14日目の記事です。

## はじめに

なかぽんと申します 🙋‍♂️

エンジニアデビューしたての頃は Java や C#でお堅い業務をしていましたが、趣味で React Native をいじってたら気づけば本業になっていた系エンジニアです。

ReactNative で Googleカレンダー のような実装を行うために参考サイトや記事を探してもしっくりこなくて困ったことがあります。

僕自身も個人で開発しているアプリで同様のケースがあったので、同じ悩みを持つ方のためにまとめていきます。今後同じような悩みを持つ人の手助けとなれば幸いです。

本記事では初心者でも最低限の実装でカレンダーを作成できるように想定していますが、
もし「できんやないかーーーい」となった際は校舎裏にでも呼び出してください(((((；ﾟДﾟ))))))))
※気軽にコメントやSNSで連絡してください笑

## 完成例

GitHub: [ソースコードはこちら](https://github.com/nakapon9517/like-google-calendars)

<img src="https://raw.githubusercontent.com/nakapon9517/like-google-calendars/main/assets/example.gif" />

## 前提

- パッケージ管理は yarn を利用
- 実装者依存を最小限とするため[react-native-calendars](https://github.com/wix/react-native-calendars)という Wix 製ライブラリを利用します。
  - ライブラリを使わずに実装を行いたい場合は以下ポイントに気をつければいけるかも・・？
    - [FlashList](https://shopify.github.io/flash-list/docs/) (※リスト表示のパフォーマンスに気をつける)
    - [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/) (※アニメーションをなるべく JS スレッドから切り離す)
    - ステータス変更によるレンダリングコストを最小限に（データは配列ではなく Map で持つなど）
    - リスト内部は最小限の内容で実装（カレンダーヘッダの YYYY/MM などレンダリング頻度が高いコンポーネントはリストに含めずに分離）
- 今回はネイティブコードが不要なので [Expo Go](https://apps.apple.com/jp/app/expo-go/id982107779) で動作確認を行っています。

  ※アプリインストール後`yarn start`で表示される QR コードを読み込むと確認が可能です。

## 準備

1. ReactNative(Expo managed workflow)の環境構築

ReactNative+Expo での構築がもしまだの方は[公式サイト](https://docs.expo.dev/tutorial/create-your-first-app/)を参考に行なってみてください 🙋‍♂️(不明点あれば質問受け付けてますのでコメントまたは SNS などでご連絡ください)

2. 必要ライブラリをインストール

```
yarn add react-native-calendars
yarn add dayjs
```

※dayjs は他の日付ライブラリでも代替可能です

## 実装

それでは早速実装に入ります！

### ざっくり目次

A: 表示したい予定を作成しよう
B: 表示したい予定を変換してカレンダーに渡そう
C: 実際にカレンダーで表示しよう

#### A: 表示したい予定を作成しよう

##### A-1: 予定の型を作成

API やストレージから取得する実際の予定となる型を定義します

```typescript
export type Schedule = {
  id: string;
  /** 予定名 */
  text: string;
  /** 予定カラー */
  color: string;
  /** 予定開始日 */
  fromAt: Date;
  /** 予定終了日 */
  toAt: Date;
};
```

##### A-2: 予定を状態管理にセット

APIやストレージから取得した値をReduxやJotai、Recoilなどにセット、
またはuseStateにユーザー入力値を保存するなりいつものReactとして予定を作成してください。

```typescript
export const useCalendarEvents = () => {
  const [events, setEvents] = useState<Schedule[]>([
    { id: 'id-1', text: '予定A', color: COLORS[0], fromAt: new Date(2023, 11, 1), toAt: new Date(2023, 11, 1) },
    { id: 'id-2', text: '予定B', color: COLORS[1], fromAt: new Date(2023, 11, 3), toAt: new Date(2023, 11, 5) },
    { id: 'id-3', text: '予定C', color: COLORS[2], fromAt: new Date(2023, 11, 4), toAt: new Date(2023, 11, 8) },
    { id: 'id-4', text: '予定D', color: COLORS[3], fromAt: new Date(2023, 11, 21), toAt: new Date(2023, 11, 24) },
    { id: 'id-5', text: '予定E', color: COLORS[4], fromAt: new Date(2023, 11, 23), toAt: new Date(2023, 11, 23) },
    { id: 'id-6', text: '予定F', color: COLORS[5], fromAt: new Date(2023, 11, 23), toAt: new Date(2023, 11, 24) },
    { id: 'id-7', text: '予定G', color: COLORS[6], fromAt: new Date(2023, 11, 24), toAt: new Date(2023, 11, 24) },
    { id: 'id-8', text: '予定H', color: COLORS[7], fromAt: new Date(2023, 11, 29), toAt: new Date(2023, 11, 29) },
    { id: 'id-9', text: '予定I', color: COLORS[8], fromAt: new Date(2023, 11, 30), toAt: new Date(2023, 11, 31) },
    { id: 'id-10', text: '予定J', color: COLORS[9], fromAt: new Date(2023, 11, 31), toAt: new Date(2023, 11, 31) },
  ]);

};
```

#### B: 表示したい予定を変換してカレンダーに渡そう

##### B-1: A-2でセットした予定を以下のようにカレンダー表示用に変換

```typescript
const eventItems = useMemo(() => {
    // ポイント:結果はMapにセットしていく
    const result = new Map<string, CalendarItem[]>();
    events.map((event, i) => {
      const dayKey = dateFormat(event.fromAt);
      const diff = dayjs(event.toAt).diff(event.fromAt, 'day') + 1;
      if (diff == 1) {
        // A:予定が1日以内の場合
        const currentData = result.get(dayKey);
        // A-1:既に既存データがある場合は下に表示させるため表示順を取得
        const maxIndex = currentData?.reduce((p, c) => Math.max(p, c.index), 0);
        result.set(dayKey, [
          ...(currentData ?? []),
          {
            id: event.id,
            index: maxIndex != undefined ? maxIndex + 1 : 0, // A-2:既にある予定の下に表示
            color: event.color,
            text: event.text,
            type: 'all',
          },
        ]);
      } else {
        // B:予定が1日以上の場合
        let index: null | number = null;
        // B-1:予定が複数日に跨る場合は該当分処理する
        Array(diff)
          .fill(null)
          .map((_, i) => {
            const date = dateFormat(dayjs(new Date(dayKey)).add(i, 'day').toDate()); // 例: 予定が 12/1 ~ 12/4 の場合、12/1, 12/2, 12/3, 12/4となる
            const currentData = result.get(date);
            if (index == null) index = currentData?.length ?? 0; // 既存の予定と被らないよう該当日付の予定数を取得しインデックスに指定
            result.set(date, [
              ...(currentData ?? []),
              {
                id: event.id,
                index,
                color: event.color,
                text: event.text,
                type: i == 0 ? 'start' : i == diff - 1 ? 'end' : 'between', // 表示タイプの指定 (start:予定開始日 / between:予定中間日 / end:予定終了日 / all:全日)
              },
            ]);
          });
      }
    });
    return result;
  }, []);
```

##### B-2:カスタムフックとして定義

A,Bをまとめて以下のようにカスタムフックを作成

```typescript
import { useMemo, useState } from 'react';
import { Schedule } from '@/types/Schedule';
import { CalendarItem } from '@/types/CalendarItem';
import dayjs from 'dayjs';
import { dateFormat } from '@/utils';

const COLORS = [
  '#A3D10C',
  '#8FBC8B',
  '#20B2AA',
  '#FFE944',
  '#FFD700',
  '#DEB887',
  '#FFA07A',
  '#FB7756',
  '#FF4D4D',
  '#FF99E6',
  '#CDA5F3',
  '#B0C4DE',
  '#87CEEB',
  '#9999FF',
  '#6B7DB3',
  '#778899',
  '#6F6D78',
];

export const useCalendarEvents = () => {
  const [events, setEvents] = useState<Schedule[]>([
    { id: 'id-1', text: '予定A', color: COLORS[0], fromAt: new Date(2023, 11, 1), toAt: new Date(2023, 11, 1) },
    { id: 'id-2', text: '予定B', color: COLORS[1], fromAt: new Date(2023, 11, 3), toAt: new Date(2023, 11, 5) },
    { id: 'id-3', text: '予定C', color: COLORS[2], fromAt: new Date(2023, 11, 4), toAt: new Date(2023, 11, 8) },
    { id: 'id-4', text: '予定D', color: COLORS[3], fromAt: new Date(2023, 11, 21), toAt: new Date(2023, 11, 24) },
    { id: 'id-5', text: '予定E', color: COLORS[4], fromAt: new Date(2023, 11, 23), toAt: new Date(2023, 11, 23) },
    { id: 'id-6', text: '予定F', color: COLORS[5], fromAt: new Date(2023, 11, 23), toAt: new Date(2023, 11, 24) },
    { id: 'id-7', text: '予定G', color: COLORS[6], fromAt: new Date(2023, 11, 24), toAt: new Date(2023, 11, 24) },
    { id: 'id-8', text: '予定H', color: COLORS[7], fromAt: new Date(2023, 11, 29), toAt: new Date(2023, 11, 29) },
    { id: 'id-9', text: '予定I', color: COLORS[8], fromAt: new Date(2023, 11, 30), toAt: new Date(2023, 11, 31) },
    { id: 'id-10', text: '予定J', color: COLORS[9], fromAt: new Date(2023, 11, 31), toAt: new Date(2023, 11, 31) },
  ]);

  const eventItems = useMemo(() => {
    // ポイント1: 結果はMapにセットしていく
    const result = new Map<string, CalendarItem[]>();
    events.map((event, i) => {
      const dayKey = dateFormat(event.fromAt);
      const diff = dayjs(event.toAt).diff(event.fromAt, 'day') + 1;
      if (diff == 1) {
        // A:予定が1日以内の場合
        const currentData = result.get(dayKey);
        // A-1:既に既存データがある場合は下に表示させるため表示順を取得
        const maxIndex = currentData?.reduce((p, c) => Math.max(p, c.index), 0);
        result.set(dayKey, [
          ...(currentData ?? []),
          {
            id: event.id,
            index: maxIndex != undefined ? maxIndex + 1 : 0, // A-2:既にある予定の下に表示
            color: event.color,
            text: event.text,
            type: 'all',
          },
        ]);
      } else {
        // B:予定が1日以上の場合
        let index: null | number = null;
        // B-1:予定が複数日に跨る場合は該当分処理する
        Array(diff)
          .fill(null)
          .map((_, i) => {
            const date = dateFormat(dayjs(new Date(dayKey)).add(i, 'day').toDate()); // 例: 予定が 12/1 ~ 12/4 の場合、12/1, 12/2, 12/3, 12/4となる
            const currentData = result.get(date);
            if (index == null) index = currentData?.length ?? 0; // 既存の予定と被らないよう該当日付の予定数を取得しインデックスに指定
            result.set(date, [
              ...(currentData ?? []),
              {
                id: event.id,
                index,
                color: event.color,
                text: event.text,
                type: i == 0 ? 'start' : i == diff - 1 ? 'end' : 'between', // 表示タイプの指定 (start:予定開始日 / between:予定中間日 / end:予定終了日 / all:全日)
              },
            ]);
          });
      }
    });
    return result;
  }, []);

  return { eventItems };
};

```

#### C: 実際に呼び出してカレンダーで表示しよう

以下の2コンポーネントで実際に表示します
- GoogleCalendar (カレンダーの大枠）
- GoogleCalendarDayItem (カレンダー内の日付コンポーネント)

GoogleCalendar

```typescript
import { useMemo } from 'react';
import { StyleSheet, View, useColorScheme } from 'react-native';
import { CalendarList, LocaleConfig } from 'react-native-calendars';
import { GoogleCalendarDayItem } from './GoogleCalendarDay';
import { Theme as CalendarTheme } from 'react-native-calendars/src/types';
import { useCalendarEvents } from '@/hooks/useCalendarEvents';

// カレンダーの表示言語設定
// 多言語対応を行う場合は日本語以外にも切り替えられるよう実装が必要
LocaleConfig.locales.jp = {
  monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
  monthNamesShort: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
  dayNames: ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'],
  dayNamesShort: ['日', '月', '火', '水', '木', '金', '土'],
};
LocaleConfig.defaultLocale = 'jp';

const PAST_RANGE = 24;
const FUTURE_RANGE = 24;

export const GoogleCalendar = () => {
  const { eventItems } = useCalendarEvents();
  const theme = useColorScheme();
  const cellMinHeight = 80;

  // カレンダーのテーマを定義
  const calendarTheme: CalendarTheme = useMemo(
    () => ({
      monthTextColor: '#000',
      textMonthFontWeight: 'bold',
      calendarBackground: 'transparent',
      arrowColor: '#0000ff',
    }),
    [theme],
  );

  return (
    <View style={styles.container}>
      <CalendarList
        key={theme} // ダークモードの切り替えなどで再レンダリングが必要な場合に指定
        pastScrollRange={PAST_RANGE} // カレンダーの月表示範囲、現在の月から24ヶ月前まで
        futureScrollRange={FUTURE_RANGE} // カレンダーの月表示範囲、現在の月から24ヶ月後まで
        firstDay={1} // 1週間の始まり (0: 日曜, 1: 始まり)
        showSixWeeks={true} // 6週間表示（hideExtraDays = falseの場合のみ）
        hideExtraDays={false} // 当月以外の日付を隠す
        monthFormat="yyyy年 M月"
        dayComponent={(d) => <GoogleCalendarDayItem {...d} eventItems={eventItems} cellMinHeight={cellMinHeight} />}
        markingType="custom" // 日付内表示をカスタムするので custom を指定
        theme={calendarTheme}
        horizontal={true}
        hideArrows={false}
        pagingEnabled={true} // 横スワイプでのページネーションを可能にする
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
});
```


GoogleCalendarDayItem

```typescript
import React, { useCallback, useMemo } from 'react';
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { CalendarItem } from '@/types/CalendarItem';
import { DateData } from 'react-native-calendars';
import { DayProps } from 'react-native-calendars/src/calendar/day';

const width = Dimensions.get('window').width;

/** 日付内の予定バーの高さ */
export const CELL_HEIGHT = 16;

const MAX_EVENTS = 5; // 1日に表示する最大予定数
const CELL_ITEM_PADDING = 2; // 予定間の余白
const CELL_RADIUS = 3; // 予定バーの

type Props = DayProps & {
  date?: DateData | undefined;
  eventItems: Map<string, CalendarItem[]>;
  cellMinHeight: number;
};

// TODO: 1日あたり5件など多くの予定がある場合は「more」などを表示させる必要あり
export const GoogleCalendarDayItem = (props: Props) => {
  const { date, eventItems: dayItems, children, state, cellMinHeight } = props;

  // 該当日付の予定を表示順(インデックス)に並び替える
  const events = useMemo(
    () => (dayItems.get((date as DateData).dateString) ?? []).sort((a, b) => b.index - a.index),
    [date, dayItems],
  );

  // 日付をクリック
  const onDayPress = useCallback(() => {
    console.info('on press day', date?.dateString);
  }, []);

  // 予定をクリック
  const onEventPress = useCallback((item: CalendarItem) => {
    console.info('on press event', item.text);
  }, []);

  // 予定表示
  const renderEvent = useCallback((v: CalendarItem, i: number) => {
    const borderLeft = v.type == 'start' || v.type == 'all' ? CELL_RADIUS : 0; // 表示タイプが予定開始日または全日の場合は、左枠線を曲げる
    const borderRight = v.type == 'end' || v.type == 'all' ? CELL_RADIUS : 0; // 表示タイプが予定終了日または全日の場合は、右枠線を曲げる
    return (
      <TouchableOpacity
        key={`${v.id} - ${i}`}
        style={[
          styles.event,
          {
            backgroundColor: v.color,
            top: v.index * (CELL_HEIGHT + CELL_ITEM_PADDING), // 並び順の位置で表示させる
            borderTopLeftRadius: borderLeft,
            borderBottomLeftRadius: borderLeft,
            borderTopRightRadius: borderRight,
            borderBottomRightRadius: borderRight,
          },
        ]}
        onPress={() => onEventPress(v)}
      >
        {v.type == 'start' || v.type == 'all' ? (
          <View style={styles.eventRow}>
            <Text style={styles.eventText} numberOfLines={1}>
              {v.text}
            </Text>
          </View>
        ) : (
          <></>
        )}
      </TouchableOpacity>
    );
  }, []);

  return (
    <TouchableOpacity
      style={[
        styles.cell,
        {
          minHeight: cellMinHeight,
          maxWidth: MAX_EVENTS * CELL_HEIGHT + CELL_ITEM_PADDING,
          opacity: state == 'disabled' ? 0.4 : 1, // 表示月以外の日付は薄く表示
        },
      ]}
      onPress={() => onDayPress()}
    >
      {/* chilerenに予定日が含まれているので表示 */}
      <Text style={[styles.dayText, state == 'today' && styles.todayText]}>{children}</Text>
      {/* 以下で該当日の予定を表示 */}
      <View>{events.map((event, i) => renderEvent(event, i))}</View>
    </TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  cell: {
    width: '100%',
  },
  dayText: {
    textAlign: 'center',
    marginBottom: CELL_ITEM_PADDING,
  },
  todayText: {
    color: 'blue',
    fontWeight: 'bold',
  },
  event: {
    width: '99%',
    height: CELL_HEIGHT,
    borderRadius: CELL_RADIUS,
    position: 'absolute',
    left: 0,
    zIndex: 2,
    justifyContent: 'center',
  },
  eventRow: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  eventText: {
    color: 'white',
    fontSize: 10,
    fontWeight: '500',
    paddingLeft: 2,

    shadowColor: 'black',
    shadowOffset: { width: 0, height: 0 },
    shadowRadius: 2,
    shadowOpacity: 0.2,
  },
});
```

## 最後に

上記実装は実際に Knot というアプリで導入しています！
「ReactNative 相談部屋」というグループでは ReactNative や Expo の質問や不明点にお答えしているので興味ある方はぜひご参加ください 👍

【iOS】
https://apps.apple.com/jp/app/id1606856291

【Android】
https://play.google.com/store/apps/details?id=com.app.gatheragain

ぜひ皆さんが僕と同じようにReactNative + Expo による極上の開発体験沼にハマってしまうことを心からお祈りしております😎

最後まで読んでいただきありがとうございました！