【React】動的な木構造レイヤーをレンダリングしてみる

お絵かきアプリとかでよくある、階層構造をもったレイヤーを実装してみました。

Figma レイヤー

Figma だとこんな感じに表示されますが、こういうのを React で実装してみます。

表示だけではなく、レイヤーの選択や削除もできるようにします。

Edit react-simple-tree

レイヤーのデータ構造

木構造を表現するため、自分のデータ、配列内での親のインデックス、自分の階層の深さを要素に持つ配列とします。

例えば、

a - b - c
- d - e
- f
- g
h - 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]]

となります。

階層の深さは親へとたどっていけば計算できますが、レンダリングの際によく使うので要素として入れています。


レイヤーの追加

まず要素を追加するフォームを設置します。

1
import React, { useState } from 'react';
2
3
export default () => {
4
const [addText, setAddText] = useState<string>('');
5
6
const handleAdd = (e: React.FormEvent<HTMLFormElement>) => {
7
e.preventDefault();
8
setAddText('');
9
};
10
11
return (
12
<form onSubmit={handleAdd}>
13
<input
14
type='text'
15
value={addText}
16
onChange={(e) => setAddText(e.target.value)}
17
/>
18
<button title='add' type='submit'>
19
+
20
</button>
21
</form>
22
);
23
};

次にレイヤーにデータを追加します。

レイヤーが選択されていればその子として、選択されていなければ最上階層として追加します。

1
...
2
const [layers, setLayers] = useState<[string, number, number][]>([]);
3
const [selectedLayer, setSelectedLayer] = useState(-1);
4
5
const handleAdd = (e: React.FormEvent<HTMLFormElement>) => {
6
e.preventDefault();
7
setAddText('');
8
if (addText !== '')
9
setLayers((prev: [string, number, number][]) => {
10
const newList = prev;
11
const newDepth = prev[selectedLayer] ? prev[selectedLayer][2] + 1 : 0;
12
newList.splice(selectedLayer + 1, 0,
13
[addText, selectedLayer, newDepth]);
14
return newList;
15
});
16
};
17
...

layersで説明した通りの構造になっています。

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
...
2
const nextSameDepthLayerIndex = (current: number) =>
3
layers.flatMap((e, i) =>
4
i > current && e[2] <= layers[current]?.[2] ? i : []
5
)[0] ?? layers.length;
6
7
const Layer = ({ index, item }: { index: number, item: [string, number, number] }) => {
8
const nextSameDepthLayer = nextSameDepthLayerIndex(selectedLayer);
9
const layerStyles = {
10
layer: {
11
paddingLeft: 16*item[2]
12
},
13
selectedLayer: {
14
paddingLeft: 16*item[2],
15
backgroundColor: '#b8c1ec'
16
},
17
underSelectedLayer: {
18
paddingLeft: 16*item[2],
19
backgroundColor: '#d4d8f0'
20
}
21
};
22
23
return (
24
<div
25
onClick={() => setSelectedLayer(selectedLayer === index ? -1 : index)}
26
style={
27
selectedLayer === index
28
? layerStyles.selectedLayer
29
: selectedLayer !== -1 &&
30
index > selectedLayer &&
31
index < nextSameDepthLayer
32
? layerStyles.underSelectedLayer
33
: layerStyles.layer
34
}
35
>
36
<span className='text'>{item[0]}</span>
37
</div>
38
);
39
};
40
41
...
42
43
return (
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
<input
52
type='text'
53
value={addText}
54
onChange={(e) => setAddText(e.target.value)}
55
/>
56
<button title='add' type='submit'>
57
+
58
</button>
59
</form>
60
</>
61
);
62
...

nextSameDepthLayerIndex は、選択中のレイヤー以降で選択中のレイヤーと同じかそれより階層が浅いものであるもののうち最初のもののインデックスを計算しています。

そのようなものが存在しなければ undefined となります。

これらの情報は選択中のレイヤーやその子レイヤーを示すためのスタイリングや、レイヤーの選択に使われています。


レイヤーの削除

レイヤー削除ボタンを設置し、選択中のレイヤーとその子レイヤーを削除します。

1
...
2
const handleRemove = () => {
3
const removeAmount = nextSameDepthLayerIndex(selectedLayer) - selectedLayer;
4
setLayers((prev: [string, number, number][]) => {
5
const newList = [...prev];
6
newList.splice(selectedLayer, removeAmount);
7
return newList;
8
});
9
};
10
11
...
12
13
return (
14
<>
15
...
16
<button title="remove" onClick={handleRemove}>
17
-
18
</button>
19
</>
20
);
21
...

React では配列 state の要素の変更は state の変更だと認識されないので、

5 行目で新たな配列を用意することで再描画しています。


レイヤーの畳み込み

どのレイヤーが畳まれているかの情報を保持しておきますが、データ本体とは関係せず描画にしか使わないし、

畳んだり開いたりするたびに layers を更新するのは大変なので layers とは別 state で管理します。

1
...
2
const [folded, setFolded] = useState<boolean[]>([]);
3
4
...
5
6
const invisibleLayers = () => {
7
let res: number[] = [];
8
for (const [i, b] of folded.entries()) {
9
if (b) {
10
res = res.concat(
11
[...Array(nextSameDepthLayerIndex(i)).keys()].slice(i + 1)
12
);
13
}
14
}
15
return res;
16
};
17
18
...
19
20
const Layer ...
21
...
22
return (
23
<div
24
...
25
className={
26
'layer' + (~invisibleLayers().indexOf(index) ? ' invisible' : '')
27
}
28
>
29
<span
30
onClick={() =>
31
setFolded((prev: boolean[]) => {
32
const newList = prev;
33
newList[index] = !prev[index];
34
return newList;
35
})
36
}
37
className="fold-button"
38
>
39
{folded[index] ? '>' : 'v'}
40
</span>
41
<span className="text">{item[0]}</span>
42
</div>
43
);
44
};
45
...
46
47
const handleAdd = (e: React.FormEvent<HTMLFormElement>) => {
48
e.preventDefault();
49
setAddText('');
50
if (addText !== '') {
51
setLayers((prev: [string, number, number][]) => {
52
const newList = prev;
53
const newDepth = prev[selectedLayer] ? prev[selectedLayer][2] + 1 : 0;
54
newList.splice(selectedLayer + 1, 0, [
55
addText,
56
selectedLayer,
57
newDepth
58
]);
59
return newList;
60
});
61
setFolded((prev: boolean[]) => [...prev, false]);
62
}
63
};
64
...

InvisibleLayers で畳まれて表示されないレイヤーのインデックスのリストを返すようにします。

選択中のレイヤー本体は非表示にならないので注意です。

描画しようとしているレイヤーのインデックスがこの中に含まれていれば、

クラス名を変更し、display: none; などによって非表示にします。


最終的にはこんな感じの挙動になります。


参考になれば幸いです。

ではでは~👋

  関連記事【React 初心者】React でタイピング練習アプリ!
【React 初心者】React でタイピング練習アプリ!