【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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, { useState } from 'react';

export default () => {
  const [addText, setAddText] = useState<string>('');

  const handleAdd = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setAddText('');
  };

  return (
    <form onSubmit={handleAdd}>
      <input
        type='text'
        value={addText}
        onChange={(e) => setAddText(e.target.value)}
      />
      <button title='add' type='submit'>
        +
      </button>
    </form>
  );
};

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  ...
  const [layers, setLayers] = useState<[string, number, number][]>([]);
  const [selectedLayer, setSelectedLayer] = useState(-1);

  const handleAdd = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setAddText('');
    if (addText !== '')
    setLayers((prev: [string, number, number][]) => {
      const newList = prev;
      const newDepth = prev[selectedLayer] ? prev[selectedLayer][2] + 1 : 0;
      newList.splice(selectedLayer + 1, 0,
        [addText, selectedLayer, newDepth]);
      return newList;
    });
  };
  ...

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
 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
  ...
  const nextSameDepthLayerIndex = (current: number) =>
    layers.flatMap((e, i) =>
      i > current && e[2] <= layers[current]?.[2] ? i : []
    )[0] ?? layers.length;

  const Layer = ({ index, item }: { index: number, item: [string, number, number] }) => {
    const nextSameDepthLayer = nextSameDepthLayerIndex(selectedLayer);
    const layerStyles = {
      layer: {
        paddingLeft: 16*item[2]
      },
      selectedLayer: {
        paddingLeft: 16*item[2],
        backgroundColor: '#b8c1ec'
      },
      underSelectedLayer: {
        paddingLeft: 16*item[2],
        backgroundColor: '#d4d8f0'
      }
    };

    return (
      <div
        onClick={() => setSelectedLayer(selectedLayer === index ? -1 : index)}
        style={
          selectedLayer === index
            ? layerStyles.selectedLayer
            : selectedLayer !== -1 &&
              index > selectedLayer &&
              index < nextSameDepthLayer
            ? layerStyles.underSelectedLayer
            : layerStyles.layer
        }
      >
        <span className='text'>{item[0]}</span>
      </div>
    );
  };

  ...

  return (
    <>
      <div id='layersContainer'>
        {layers.map((e, i) => (
          <Layer key={i} index={i} item={e} />
        ))}
      </div>
      <form onSubmit={handleAdd}>
        <input
          type='text'
          value={addText}
          onChange={(e) => setAddText(e.target.value)}
        />
        <button title='add' type='submit'>
          +
        </button>
      </form>
    </>
  );
  ...

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

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

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


レイヤーの削除

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

 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
  ...
  const [rerender, setRerender] = useState<boolean>(false);

  ...

  const handleRemove = () => {
    setRerender(!rerender);
    const removeAmount = nextSameDepthLayerIndex(selectedLayer) - selectedLayer;
    setLayers((prev: [string, number, number][]) => {
      const newList = prev;
      newList.splice(selectedLayer, removeAmount);
      return newList;
    });
  };

  ...

  return (
    <>
      ...
      <button title="remove" onClick={handleRemove}>
        -
      </button>
    </>
  );
  ...

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

2 行目で別 state を用意して 7 行目で強制的に再描画させています。


レイヤーの畳み込み

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

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

 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
  ...
  const [folded, setFolded] = useState<boolean[]>([]);

  ...

  const invisibleLayers = () => {
    let res: number[] = [];
    for (const [i, b] of folded.entries()) {
      if (b) {
        res = res.concat(
          [...Array(nextSameDepthLayerIndex(i)).keys()].slice(i + 1)
        );
      }
    }
    return res;
  };

  ...

  const Layer ...
    ...
    return (
      <div
        ...
        className={
          'layer' + (~invisibleLayers().indexOf(index) ? ' invisible' : '')
        }
      >
        <span
          onClick={() =>
            setFolded((prev: boolean[]) => {
              const newList = prev;
              newList[index] = !prev[index];
              return newList;
            })
          }
          className="fold-button"
        >
          {folded[index] ? '>' : 'v'}
        </span>
        <span className="text">{item[0]}</span>
      </div>
    );
  };
  ...

  const handleAdd = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setAddText('');
    if (addText !== '') {
      setLayers((prev: [string, number, number][]) => {
        const newList = prev;
        const newDepth = prev[selectedLayer] ? prev[selectedLayer][2] + 1 : 0;
        newList.splice(selectedLayer + 1, 0, [
          addText,
          selectedLayer,
          newDepth
        ]);
        return newList;
      });
      setFolded((prev: boolean[]) => [...prev, false]);
    }
  };
  ...

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

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

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

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


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


参考になれば幸いです。

ではでは~👋