【React】動的な木構造レイヤーをレンダリングしてみる
お絵かきアプリとかでよくある、階層構造をもったレイヤーを実装してみました。
Figma だとこんな感じに表示されますが、こういうのを React で実装してみます。
表示だけではなく、レイヤーの選択や削除もできるようにします。
レイヤーのデータ構造
木構造を表現するため、自分のデータ、配列内での親のインデックス、自分の階層の深さを要素に持つ配列とします。
例えば、
a - b - c- d - e- f- gh - i
という構造は
[['a', -1, 0], ['b', 0, 1], ['c', 1, 2],['d', 0, 1], ['e', 3, 2],['f', 3, 2],['g', 0, 1],['h', -1, 0], ['i', 7, 1]]
となります。
階層の深さは親へとたどっていけば計算できますが、レンダリングの際によく使うので要素として入れています。
レイヤーの追加
まず要素を追加するフォームを設置します。
1import React, { useState } from 'react';23export default () => {4const [addText, setAddText] = useState<string>('');56const handleAdd = (e: React.FormEvent<HTMLFormElement>) => {7e.preventDefault();8setAddText('');9};1011return (12<form onSubmit={handleAdd}>13<input14type='text'15value={addText}16onChange={(e) => setAddText(e.target.value)}17/>18<button title='add' type='submit'>19+20</button>21</form>22);23};
次にレイヤーにデータを追加します。
レイヤーが選択されていればその子として、選択されていなければ最上階層として追加します。
1...2const [layers, setLayers] = useState<[string, number, number][]>([]);3const [selectedLayer, setSelectedLayer] = useState(-1);45const handleAdd = (e: React.FormEvent<HTMLFormElement>) => {6e.preventDefault();7setAddText('');8if (addText !== '')9setLayers((prev: [string, number, number][]) => {10const newList = prev;11const newDepth = prev[selectedLayer] ? prev[selectedLayer][2] + 1 : 0;12newList.splice(selectedLayer + 1, 0,13[addText, selectedLayer, newDepth]);14return newList;15});16};17...
selectedLayer
は現在選択されているレイヤーの、layers
内でのインデックスです。
何も選択されていないときは -1
を格納しています。
setLayers
の中では、元の配列を参照し、新しく追加する要素の階層の深さを計算し、適切な位置に挿入しています。
挿入する位置は選択中のレイヤーの直後となっています。
例えば [['a', -1, 0], ['b', 0, 1], ['c', -1, 0]]
となっているところに 'd'
というデータを追加するとき、
何も選択されていなければ先頭に ['d', -1, 0]
として追加され、
1
番目の要素=['b', 0, 1]
が選択されていればその直後に ['d', 1, 2]
として追加され、[['a', -1, 0], ['b', 0, 1], ['d', 1, 2], ['c', -1, 0]]
となります。
レイヤーの描画・選択
layers
のそれぞれの要素をリストとして並べて描画します。
1...2const nextSameDepthLayerIndex = (current: number) =>3layers.flatMap((e, i) =>4i > current && e[2] <= layers[current]?.[2] ? i : []5)[0] ?? layers.length;67const Layer = ({ index, item }: { index: number, item: [string, number, number] }) => {8const nextSameDepthLayer = nextSameDepthLayerIndex(selectedLayer);9const layerStyles = {10layer: {11paddingLeft: 16*item[2]12},13selectedLayer: {14paddingLeft: 16*item[2],15backgroundColor: '#b8c1ec'16},17underSelectedLayer: {18paddingLeft: 16*item[2],19backgroundColor: '#d4d8f0'20}21};2223return (24<div25onClick={() => setSelectedLayer(selectedLayer === index ? -1 : index)}26style={27selectedLayer === index28? layerStyles.selectedLayer29: selectedLayer !== -1 &&30index > selectedLayer &&31index < nextSameDepthLayer32? layerStyles.underSelectedLayer33: layerStyles.layer34}35>36<span className='text'>{item[0]}</span>37</div>38);39};4041...4243return (44<>45<div id='layersContainer'>46{layers.map((e, i) => (47<Layer key={i} index={i} item={e} />48))}49</div>50<form onSubmit={handleAdd}>51<input52type='text'53value={addText}54onChange={(e) => setAddText(e.target.value)}55/>56<button title='add' type='submit'>57+58</button>59</form>60</>61);62...
nextSameDepthLayerIndex
は、選択中のレイヤー以降で選択中のレイヤーと同じかそれより階層が浅いものであるもののうち最初のもののインデックスを計算しています。
そのようなものが存在しなければ undefined
となります。
これらの情報は選択中のレイヤーやその子レイヤーを示すためのスタイリングや、レイヤーの選択に使われています。
レイヤーの削除
レイヤー削除ボタンを設置し、選択中のレイヤーとその子レイヤーを削除します。
1...2const handleRemove = () => {3const removeAmount = nextSameDepthLayerIndex(selectedLayer) - selectedLayer;4setLayers((prev: [string, number, number][]) => {5const newList = [...prev];6newList.splice(selectedLayer, removeAmount);7return newList;8});9};1011...1213return (14<>15...16<button title="remove" onClick={handleRemove}>17-18</button>19</>20);21...
React では配列 state の要素の変更は state の変更だと認識されないので、
5 行目で新たな配列を用意することで再描画しています。
レイヤーの畳み込み
どのレイヤーが畳まれているかの情報を保持しておきますが、データ本体とは関係せず描画にしか使わないし、
畳んだり開いたりするたびに layers
を更新するのは大変なので layers
とは別 state で管理します。
1...2const [folded, setFolded] = useState<boolean[]>([]);34...56const invisibleLayers = () => {7let res: number[] = [];8for (const [i, b] of folded.entries()) {9if (b) {10res = res.concat(11[...Array(nextSameDepthLayerIndex(i)).keys()].slice(i + 1)12);13}14}15return res;16};1718...1920const Layer ...21...22return (23<div24...25className={26'layer' + (~invisibleLayers().indexOf(index) ? ' invisible' : '')27}28>29<span30onClick={() =>31setFolded((prev: boolean[]) => {32const newList = prev;33newList[index] = !prev[index];34return newList;35})36}37className="fold-button"38>39{folded[index] ? '>' : 'v'}40</span>41<span className="text">{item[0]}</span>42</div>43);44};45...4647const handleAdd = (e: React.FormEvent<HTMLFormElement>) => {48e.preventDefault();49setAddText('');50if (addText !== '') {51setLayers((prev: [string, number, number][]) => {52const newList = prev;53const newDepth = prev[selectedLayer] ? prev[selectedLayer][2] + 1 : 0;54newList.splice(selectedLayer + 1, 0, [55addText,56selectedLayer,57newDepth58]);59return newList;60});61setFolded((prev: boolean[]) => [...prev, false]);62}63};64...
InvisibleLayers
で畳まれて表示されないレイヤーのインデックスのリストを返すようにします。
選択中のレイヤー本体は非表示にならないので注意です。
描画しようとしているレイヤーのインデックスがこの中に含まれていれば、
クラス名を変更し、display: none;
などによって非表示にします。
最終的にはこんな感じの挙動になります。
参考になれば幸いです。
ではでは~