Skip to content

calender ui with infinity scroll

2022-10-23


calender ui の続き。
上のメモではカレンダーの見た目を作ったが、今回はそのカレンダーをラップしてスクロールできるようにする。
スクロールは scroll event を用いて実装を行い、ある一定の高さまでスクロールしたら 1 年分カレンダーを追加する形にしている。 高さ判定は IntersectionObserver API も考えたが、要素 1 つ 1 つを監視するのは労力が必要なので一旦スクロール位置で判定するようにしてみた。もしかしたら変更するかも。

PR

main push でもよかったが、一応ブランチを切って実装した。

実装

前回定義した <DatePicker /> をラップする形で <Calender /> というコンポーネントを定義した。
props は <DatePicker /> と同じ。また、<DatePicker /> 側の props は少し拡張してある。

<DatePicker /> コンポーネント

<DatePicker /> の部分は vdate を受け取るようにした。そもそもの vdate の目的がカレンダーの表示部分の保持なので、これは親コンポーネントで管理する必要があるため、<Calender /> 側で保持して、<DatePicker /> に渡すようにした。
また、加えて id という props を受け取るようにした。これは初期ロード時に選択状態の <DatePicker /> を特定するために使う。これも親コンポーネントでスクロールイベントを管理する以上、ここでは渡されるだけになっている。
他の部分は変更はない。

export const DatePicker: FC<Props> = ({
  id,
  date = dayjs(),
  vdate,
  onDateChange,
}) => {
  const dayOfWeek = (date.startOf("month").day() + 7) % 7;
  const daysList = Array.from(new Array(date.daysInMonth()), (_, i) => i + 1);

  return (
    <Container id={id}>
      <Typography align="center" component="h1" weight="bold">
        {date.format("YYYY年MM月")}
      </Typography>
      <DatePickerContainer>
        {weekList["ja"].map((week) => (
          <DayStyle key={week}>{week}</DayStyle>
        ))}

        {Array.from(new Array(dayOfWeek), (_, i) => (
          <DayStyle key={i} />
        ))}
        {daysList.map((day) => (
          <DayStyle key={day}>
            <Day
              key={day}
              value={dayjs(new Date(date.year(), date.month(), day))}
              selected={
                // string compare
                vdate.format("YYYY-MM-DD") ===
                dayjs(new Date(date.year(), date.month(), day)).format(
                  "YYYY-MM-DD"
                )
              }
              onClickDate={onDateChange}
            >
              {day}
            </Day>
          </DayStyle>
        ))}
      </DatePickerContainer>
    </Container>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

<Calender /> コンポーネント

<Calender /><DatePicker /> をラップするコンポーネントで、基本的にスクロール等の状態管理はトップレベルであるこの層で行うことを想定している。
<Calender /> でやることは以下がある。

  • <DatePicker /> のレンダリング
  • スクロールイベントの管理
  • 初期ロード時に選択中のカレンダーを特定してそれを表示する

1 つずつ見ていく。

<DatePicker /> のレンダリング

<DatePicker /> のレンダリングは、選択中の日付から ±1 年分を読み込むようにして、合計 24 ヶ月分をレンダリングする。
カレンダーの外枠は ingred-ui の <ScrollArea /> コンポーネントを用いて実装し、このコンポーネントのスクロール位置を判定したいので ref を渡すようにしている。

export const Calender: FC<Props> = ({ date = dayjs(), onDateChange }) => {
  const ref = useRef<HTMLDivElement>(null);

  const nextYearMonthList = Array.from(new Array(12)).map((_, i) =>
    d.clone().add(i, "month")
  );
  const prevYearMonthList = Array.from(new Array(12)).map((_, i) =>
    d.clone().subtract(12 - i, "month")
  );

  const [monthList, setMonthList] = useState<Dayjs[]>([
    ...prevYearMonthList,
    ...nextYearMonthList,
  ]);

  // other code

  return (
    <Container>
      <ScrollArea ref={ref} minHeight={HEIGHT} maxHeight={HEIGHT} id="calender">
        <>
          {monthList.map((m) => (
            <DatePicker
              key={m.format("YYYY-MM")}
              id={m.format("YYYY-MM")}
              date={m}
              vdate={vdate}
              onDateChange={onDateChange}
            />
          ))}
        </>
      </ScrollArea>
    </Container>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

スクロールイベントの管理

スクロールイベントの管理は

  • 1 番上まで到達した際に古いカレンダーを 1 年分読み込む
  • 1 番下まで到達した際に新しいカレンダーを 1 年分読み込む

の 2 種類が存在する。
そのため、handler を 2 つ定義し、それを useEffect で更新のたびに呼び出す手法をとった。

中で使っている MARGIN という定数は、スクロールイベントを発火させる際に 1 番下まで到達してから読み込むと若干遅いので少し余分を持たせてイベントを発火させるためのものとして定義した。

const MARGIN = 500;

export const Calender: FC<Props> = ({ date = dayjs(), onDateChange }) => {
  const ref = useRef<HTMLDivElement>(null);
  const [loaded, setLoaded] = useState<{
    prev: Dayjs;
    next: Dayjs;
  }>({
    prev: d.clone().subtract(12, "month"),
    next: d.add(1, "year"),
  });

  const handleScrollDown = () => {
    if (ref.current === null) {
      return;
    }

    const { scrollTop, clientHeight, scrollHeight } = ref.current;

    const next = loaded.next.add(1, "year");

    if (scrollTop + clientHeight + MARGIN >= scrollHeight) {
      const nextYearMonthList = Array.from(new Array(12)).map((_, i) =>
        loaded.next.clone().add(i, "month")
      );
      setLoaded({ next, prev: loaded.prev });
      setMonthList([...monthList, ...nextYearMonthList]);
    }
  };

  const handleScrollUp = () => {
    if (ref.current === null) {
      return;
    }

    const { scrollTop } = ref.current;

    const prev = loaded.prev.subtract(1, "year");
    if (scrollTop - MARGIN <= 0) {
      const prevYearMonthList = Array.from(new Array(12)).map((_, i) =>
        loaded.prev.clone().subtract(12 - i, "month")
      );
      setLoaded({ next: loaded.next, prev });
      setMonthList([...prevYearMonthList, ...monthList]);
    }
  };

  useEffect(() => {
    if (ref.current !== null) {
      ref.current.addEventListener("scroll", handleScrollUp);
      ref.current.addEventListener("scroll", handleScrollDown);
    }

    return () => {
      if (ref.current !== null) {
        ref.current.removeEventListener("scroll", handleScrollUp);
        ref.current.removeEventListener("scroll", handleScrollDown);
      }
    };
  }, [loaded]);

  // other code
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

初期ロード時に選択中のカレンダーを特定してそれを表示する

これは非常に単純明快で、<DatePicker /> に渡された id を見つけて、その id に対応する <DatePicker /> を表示するようにした。

export const Calender: FC<Props> = ({ date = dayjs(), onDateChange }) => {
  useEffect(() => {
    const target = document.getElementById(vdate.format("YYYY-MM"));
    if (target !== null) {
      target.scrollIntoView({ block: "center" });
    }
  }, []);

  // other code
};
1
2
3
4
5
6
7
8
9
10

全体像

<Calender /> の全体像としては以下のようになった。
少し長くなってしまったが、さほど難しいことはしていないはず。

import dayjs, { Dayjs } from "dayjs";
import { ScrollArea } from "ingred-ui";
import { FC, useEffect, useMemo, useRef, useState } from "react";
import { DatePicker } from "../DatePicker";
import { HEIGHT, MARGIN } from "./constants";
import { Container } from "./styled";

type Props = {
  date?: Dayjs;
  onDateChange?: (date: Dayjs) => void;
};

/**
 * Calender UI
 * Scrollable calendar UI.
 * Currently, one year from the currently selected date is displayed.
 * @todo Can render current month when server-side rendering.
 */
export const Calender: FC<Props> = ({ date = dayjs(), onDateChange }) => {
  // stupid hack...
  // This is not the original purpose of memoization, but to fix values.
  const d = useMemo(() => date.clone(), []);

  const nextYearMonthList = Array.from(new Array(12)).map((_, i) =>
    d.clone().add(i, "month")
  );
  const prevYearMonthList = Array.from(new Array(12)).map((_, i) =>
    d.clone().subtract(12 - i, "month")
  );

  const ref = useRef<HTMLDivElement>(null);
  const [loaded, setLoaded] = useState<{
    prev: Dayjs;
    next: Dayjs;
  }>({
    prev: d.clone().subtract(12, "month"),
    next: d.add(1, "year"),
  });
  const [monthList, setMonthList] = useState<Dayjs[]>([
    ...prevYearMonthList,
    ...nextYearMonthList,
  ]);

  const vdate = date.clone();

  const handleScrollDown = () => {
    if (ref.current === null) {
      return;
    }

    const { scrollTop, clientHeight, scrollHeight } = ref.current;

    const next = loaded.next.add(1, "year");

    if (scrollTop + clientHeight + MARGIN >= scrollHeight) {
      const nextYearMonthList = Array.from(new Array(12)).map((_, i) =>
        loaded.next.clone().add(i, "month")
      );
      setLoaded({ next, prev: loaded.prev });
      setMonthList([...monthList, ...nextYearMonthList]);
    }
  };

  const handleScrollUp = () => {
    if (ref.current === null) {
      return;
    }

    const { scrollTop } = ref.current;

    const prev = loaded.prev.subtract(1, "year");
    if (scrollTop - MARGIN <= 0) {
      const prevYearMonthList = Array.from(new Array(12)).map((_, i) =>
        loaded.prev.clone().subtract(12 - i, "month")
      );
      setLoaded({ next: loaded.next, prev });
      setMonthList([...prevYearMonthList, ...monthList]);
    }
  };

  // TODO: SSR support
  useEffect(() => {
    const target = document.getElementById(vdate.format("YYYY-MM"));
    if (target !== null) {
      target.scrollIntoView({ block: "center" });
    }
  }, []);

  useEffect(() => {
    if (ref.current !== null) {
      ref.current.addEventListener("scroll", handleScrollUp);
      ref.current.addEventListener("scroll", handleScrollDown);
    }

    return () => {
      if (ref.current !== null) {
        ref.current.removeEventListener("scroll", handleScrollUp);
        ref.current.removeEventListener("scroll", handleScrollDown);
      }
    };
  }, [loaded]);

  return (
    <Container>
      <ScrollArea ref={ref} minHeight={HEIGHT} maxHeight={HEIGHT} id="calender">
        <>
          {monthList.map((m) => (
            <DatePicker
              key={m.format("YYYY-MM")}
              id={m.format("YYYY-MM")}
              date={m}
              vdate={vdate}
              onDateChange={onDateChange}
            />
          ))}
        </>
      </ScrollArea>
    </Container>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120

課題

SSR ができない。いや、厳密に言うと問題なく行えるはずだが、初期ローディング時に選択中のカレンダーを特定するために、<DatePicker /> に id を渡して、それを CSR 時にもってきて選択中のカレンダーを表示している。
つまり、このカレンダーはデフォルトで ±1 年間を初期段階でレンダリングしておくようにしてるので、renderToString 関数が呼ばれた段階ではカレンダーは 1 年前の部分が表示されていることになる。 さほど問題がないので今回は目を瞑ったが、後々解決しないといけないものだという認識ではいる。

まとめ

無限スクロールは SEO 文脈や画面遷移やブラウザバック時にスクロール位置が保持されない等のあまり良くない側面が語られがちだが、今回のようなカレンダー UI 等は無限スクロールが適していると思う。
実際、このような UI は見たことがあるし、非常に便利だなと感じている。

また、このような UI を作る上での課題感や実装難易度の温度感は掴むことができたため、プロトタイピングの目的としては十分だと感じている。
パフォーマンス上の問題点等も今後洗い出していければなと思う。