本記事は 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-content に start
を指定してあげることで、左上に子要素を寄せることができます。
<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;
無事「仕様書」に書いてあったような配置を再現することはできましたが、レスポンシブではありません。レスポンシブにするためにはウィンドウサイズが変わるごとに numColumns
と numRows
、 items
を再計算してあげる必要があります。
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日改稿