Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

レスポンシブなクリスマスツリーの作り方

本記事は Sansan Advent Calendar 2022 15日目の記事です。

こんにちは。技術本部Digitization部データ化グループでエンジニアをしている池田力です。

背景

複雑なレイアウトのUIを作る場合、CSSだけで実装するのには限界があります。そうして限界を迎えた時に一番に考えるのはJavaScriptを使ってCSSを制御するということでしょう。 JavaScriptでCSSを制御するのはとても便利である一方で、バグを埋め込む温床になりやすいため慎重に実装する必要があります。今までJavaScriptで制御しなければならないほど複雑なUIを作ったことがなかったのですが、素のCSSだけでは太刀打ちできない実装に出くわして試行錯誤した結果、ある程度知見を得ることができたため、ブログとしてまとめてみようと思います。

なお、この記事では「複雑なレイアウト」の例としてクリスマスツリーを考えてみました。テーマとしてお遊びの要素が強いですが、本質を理解すれば様々なレイアウトに応用できます。

前提環境

フレームワーク・言語 : React 18/TypeScript 4.6 (Vite 3) Node.js 18

ブラウザ : Chrome 108

素のCSSだけでマス目のレイアウトを作る

最初にマス目を描画するための領域を準備します。

const App: React.FC = () => {
  return (
    <div
      style={{
        padding: "20px",
        height: "100vh",
        boxSizing: "border-box",
      }}
    >
      <div
        style={{
          height: "100%",
          width: "100%",
          boxSizing: "border-box",
          backgroundColor: "#1B4C8A",
          padding: "20px",
        }}
      />
    </div>
  );
};

export default App;

(余白を見やすくするためにbodyの背景色をグレーにしています。)

flexなアイテムを追加してみる

次に、divの中に60px x 60pxの四角形を子要素として描画します。flexを使ってgapを指定すれば等間隔に並べることができます。

const App: React.FC = () => {
  const items = [
    { name: "hoge", key: 1 },
    { name: "fuga", key: 2 },
    { name: "moge", key: 3 },
    { name: "nuga", key: 4 },
  ];

  return (
    <div
      style={{
        padding: "20px",
        height: "100vh",
        boxSizing: "border-box",
      }}
    >
      <div
        style={{
          height: "100%",
          width: "100%",
          boxSizing: "border-box",
          backgroundColor: "#1B4C8A",
          padding: "20px",
          display: "flex",
          gap: "20px",
        }}
      >
        {items.map((item) => (
          <div
            style={{
              width: "60px",
              height: "60px",
              backgroundColor: "#fff",
              display: "flex", // テキストの中央揃え用のflex
              alignItems: "center",
              justifyContent: "center",
            }}
            key={item.key}
          >
            {item.name}
          </div>
        ))}
      </div>
    </div>
  );
};

export default App;

子要素を増やすと横並びに正しく配置されます。

ところが、幅からはみ出すところまで子要素が増えると、子要素のサイズが変わってしまいます。

ここで使えるのが、flex-wrap です。 flex-wrap: wrapと指定してあげることで、横幅を超えた分は次の行に繰り越すことができます。

さらに、align-contentstart を指定してあげることで、左上に子要素を寄せることができます。

    <div
      style={{
        display: "flex",
        ...
      }}
    >
      <div
        style={{
          ...
          alignContent: "start",
          ...
        }}
      >

JavaScriptを用いて自由に子要素を配置する

ここまでで紹介してきたような単純なマス目のレイアウトはCSSのみで実装することができました。ところが、「仕様書」と称して以下のようなレイアウトを作ってほしいと依頼が来たらどうしましょう?規則的な配置をしているものの、CSSだけで配置するのは難しそうです。

様々なCSSプロパティを調査した結果、grid-templateでマス目の横幅、縦幅を固定し、grid-column, grid-row でx座標, y座標を指定してあげればよいということがわかりました。 ひとまず、スプレッドシートの配置を再現するコードを実装してみましょう。

const App: React.FC = () => {
  const trunkThickness = 3;
  const numColumns = 11;
  const numRows = 8;

  const items = new Array(numColumns * numRows)
    .fill(undefined)
    .map((_val, index) => {
      const columnNumber = (index % numColumns) + 1,
        rowNumber = Math.floor(index / numColumns) + 1;
      const horizontalCenterNumber = Math.floor((numColumns - 1) / 2) + 1;

      const isDisplay = (() => {
        if (rowNumber >= numColumns / 2 + 1)
          // 幹の部分の判定ロジック
          return (
            Math.abs(columnNumber - horizontalCenterNumber) <=
            Math.min(Math.ceil(numColumns / 2 - trunkThickness), 1)
          );

        // 葉っぱの部分の判定ロジック
        return Math.abs(columnNumber - horizontalCenterNumber) < rowNumber;
      })();

      return {
        columnNumber,
        rowNumber,
        isDisplay,
        key: index + 1,
      };
    });

  return (
    <div
      style={{
        padding: "20px",
        height: "100vh",
        boxSizing: "border-box",
      }}
    >
      <div
        style={{
          height: "100%",
          width: "100%",
          boxSizing: "border-box",
          backgroundColor: "#fff",
          padding: "20px",
          display: "grid",
          gridTemplate: `repeat(${numRows}, 60px) / repeat(${numColumns}, 60px)`,
          gridGap: "20px",
          alignContent: "start",
        }}
      >
        {items.map((item) =>
          item.isDisplay ? (
            <div
              style={{
                width: "60px",
                height: "60px",
                backgroundColor: "#1b8a2c",
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                gridColumn: item.columnNumber,
                gridRow: item.rowNumber,
              }}
              key={item.key}
            />
          ) : undefined
        )}
      </div>
    </div>
  );
};

export default App;

無事「仕様書」に書いてあったような配置を再現することはできましたが、レスポンシブではありません。レスポンシブにするためにはウィンドウサイズが変わるごとに numColumnsnumRowsitems を再計算してあげる必要があります。

import { useEffect, useRef, useState } from "react";

const App: React.FC = () => {
  const wrapperRef = useRef<HTMLDivElement>(null);

  const gridItemSize = 60, gridGap = 20, trunkThickness = 3;
  const [numColumns, setNumColumns] = useState(1);
  const [numRows, setNumRows] = useState(1);
  const [items, setItems] = useState<
    {
      text: string;
      columnNumber: number;
      rowNumber: number;
      isDisplay: boolean;
      key: number;
    }[]
  >([]);

  const onResize = () => {
    const newNumColumns = Math.floor(
        Math.max(((wrapperRef.current?.clientWidth ?? 0) - gridGap) / (gridItemSize + gridGap), 5)
      ),
      newNumRows = Math.floor(
        Math.max(((wrapperRef.current?.clientHeight ?? 0) - gridGap) / (gridItemSize + gridGap), 5)
      );
    const newItems = new Array(newNumColumns * newNumRows)
    .fill(undefined)
    .map((_val, index) => {
      const columnNumber = (index % newNumColumns) + 1,
        rowNumber = Math.floor(index / newNumColumns) + 1;
      const horizontalCenterNumber = Math.floor((newNumColumns - 1) / 2) + 1;

      const isDisplay = (() => {
        if (rowNumber >= newNumColumns / 2 + 1)
          // 幹の部分の判定ロジック
          return (
            Math.abs(columnNumber - horizontalCenterNumber) <=
            Math.min(Math.ceil(newNumColumns / 2 - trunkThickness), 1)
          );

        // 葉っぱの部分の判定ロジック
        return Math.abs(columnNumber - horizontalCenterNumber) < rowNumber;
      })();

      return {
        columnNumber,
        rowNumber,
        isDisplay,
        key: index + 1,
      };
    });

    setNumColumns(newNumColumns);
    setNumRows(newNumRows);
    setItems(newItems);
  };
  useEffect(() => {
    onResize();
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  return (
    <div
      style={{
        ...
      }}
    >
      <div
        style={{
          ...
          gridTemplate: `repeat(${numRows}, ${gridItemSize}px) / repeat(${numColumns}, ${gridItemSize}px)`,
          gridGap,
          alignContent: "start",
        }}
        ref={wrapperRef}
      >
      ...

onResizeで再計算することで、11 x 8以外のクリスマスツリーも描画することができていることが確認できました!!

レスポンシブなクリスマスツリーのコード全体はGitHubに置きました。興味がある方はぜひご覧になってください。

https://github.com/Tsutomu-Ikeda/2022-advent-responsive-christmas-tree

最後に

マス目の配置する実装は使う場面が多そうですが、検索してもちょうど自分がやりたい内容のものがなかなか見つからず、試行錯誤しながら最終的な実装まで辿り着きました。この記事が同じように困っている人の助けになれば幸いです。

なお、本記事の執筆にあたっては、データ戦略部の上田、Eight Engineering Unitの藤野にサポートをしてもらいました。ありがとうございました。

※12月20日改稿

© Sansan, Inc.