大量のUnityEditor.MenuItem被害者の会


# 導入
#### ある朝のこと
Button, Toggle, Slider, Dropdown, Window, …。
徹夜して UI パーツの Prefab を沢山用意したぞ！100個以上はあるかも！これでゲームが作れる！

そうだ！Project タブからドラッグするのも大変だから、Hierarchy で右クリックした時に出るこのメニューに追加して出しやすくしよう！
![右クリックした時に出るメニュー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/893246/fb798f8f-a9a5-e734-ef6a-2cee7b473a95.png)


```cs:これは悪い夢だ.cs
public class MyMenuItems
{
    [MenuItem("GameObject/MyUI/Button/Basic/Normal", false, 0)]
    static void Execute001() => InstantiatePrefabFromPath("Assets/Prefabs/Button/Basic/Normal");
    [MenuItem("GameObject/MyUI/Button/Basic/Positive", false, 0)]
    static void Execute002() => InstantiatePrefabFromPath("Assets/Prefabs/Button/Basic/Positive");
    [MenuItem("GameObject/MyUI/Button/Basic/Negative", false, 0)]
    static void Execute003() => InstantiatePrefabFromPath("Assets/Prefabs/Button/Basic/Negative");
    /*
    （数が多すぎて省略されました）
    */
    [MenuItem("GameObject/MyUI/Window/Special/RedFrame", false, 0)]
    static void Execute255() => InstantiatePrefabFromPath("Assets/Prefabs/Window/Special/RedFrame");
    [MenuItem("GameObject/MyUI/Window/Special/GreenFrame", false, 0)]
    static void Execute256() => InstantiatePrefabFromPath("Assets/Prefabs/Window/Special/GreenFrame");

    /// <summary>引数のPrefabパスを元にGameObjectを生成</summary>
    static void InstantiatePrefabFromPath(string assetPath)
    {
        var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
        var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
        instance.transform.SetParent(Selection.activeTransform, false);
        Selection.activeObject = instance;
    }
}
```

はあはあ…いつの間にか夜になっちゃった…
このコード今後のメンテもめんどくさいな…
そうだ！ゲーム作るのやめよう。
ところで記事の続きをお読みください。

# Editor 拡張の紹介
指定したディレクトリ配下を対象にした Prefab 一覧を階層構造で表示し、選択した Prefab を配置する Editor 拡張を作成してみました。
以下の gif が実際の動作例です。
画面左側の Project タブの構造が、画面中央に出てくるメニューに反映されています。Prefab を作ったら任意のディレクトリ配下に入れるだけなので楽です。
![動作例.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/893246/fa7a04f1-5e0d-91b4-2e79-31c7f85e53cc.gif)
プロジェクト内に様々なファイルが多くある場合、Project タブ内で毎回絞り込んでドラッグするのは地味に手間ですから、少し便利になりました。
因みに先ほどの茶番では UI を例に話していましたが特に UI に限らず使えます。

# 実装ポイント
### メニューレイアウトについて
先ほどの見ていただいた Editor 拡張のメニューですが、インスペクタでよく見る Add Component を押した時に出る以下のようなメニューに見た目が近かったと思います。
![これのことです.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/893246/22827239-5f70-491b-9bcd-a35a188f611f.png)
実際の Add Component ボタン押下時のメニュー処理は `UnityEditor.AddComponent.AddComponentWindow` クラスで実装されていますが、こちらは internal になっておりなおかつ拡張性はありません。

`UnityEditor.Experimental.GraphView.SearchWindow` を利用することで似たような見た目のウィンドウを作成できます。namespace からもわかる通り、本来は ShaderGraph, VFXGraph などで利用されている `GraphView` の機能の一部で、 2019.4.14f1 時点では `Experimental` となっております。

利用するまでの手順としては `ISearchWindowProvider` を実装したクラス作成して `SearchWindow.Open` に渡すだけとなっており、結構簡単です。

```cs:ISearchWindowProviderの実装
public sealed class InstantiatePrefabWindow : ScriptableObject, ISearchWindowProvider
{
    /// <summary>データ構造を作成する</summary>
    public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context) => new List<SearchTreeEntry>();

    /// <summary>選択された時の処理</summary>
    public bool OnSelectEntry(SearchTreeEntry entry, SearchWindowContext context) => true;
}
```
```cs:SearchWindow.Openの実行手順
var provider = CreateInstance<InstantiatePrefabWindow>();
SearchWindow.Open(new SearchWindowContext(), provider);
```

### メニューのデータ構造作成について
以下のようなファイルパスのリストをソースとした場合に

```cs
var list = new List<string>()
{
    "Button/Basic/Normal.prefab",
    "Button/Basic/Positive.prefab",
    "Button/Basic/Negative.prefab",
    "Button/Special/Special01.prefab",
    "Toggle/Toggle01.prefab",
}
```

最終的には以下のような構造を求められるので、少しだけ変換する手間が必要です。

```cs
var list = new List<SearchTreeEntry>()
{
    new SearchTreeGroupEntry(new GUIContent("Select Prefab")), // title
    new SearchTreeGroupEntry(new GUIContent("Button"       )) {level = 1},
    new SearchTreeGroupEntry(new GUIContent("Basic"        )) {level = 2},
    new SearchTreeEntry     (new GUIContent("Normal"       )) {level = 3},
    new SearchTreeEntry     (new GUIContent("Positive"     )) {level = 3},
    new SearchTreeEntry     (new GUIContent("Negative"     )) {level = 3},
    new SearchTreeGroupEntry(new GUIContent("Special"      )) {level = 2},
    new SearchTreeEntry     (new GUIContent("Special01"    )) {level = 3},
    new SearchTreeGroupEntry(new GUIContent("Toggle"       )) {level = 1},
    new SearchTreeEntry     (new GUIContent("Toggle01"     )) {level = 2},
};
```

パスのリストについて `Split('/')` して階層を確認しつつ前後の要素と比較して level も含めて適切に `GroupEntry` を挿入する必要がありますね。

<details>
<summary>該当箇所の実装はこのような感じになりました</summary>
<div>

```cs
IEnumerable<SearchTreeEntry> DataToEntries(IEnumerable<Data> dataList)
{
    yield return new SearchTreeGroupEntry(new GUIContent("Select Prefab"));
    var data2 = default(Data); // 1 = current, 2 = prev
    foreach (var data1 in dataList)
    {
        // DirectoryNames は、アセットパスを Path.GetDirectoryName したのち Split('/') した配列です
        var directoryNames1 = data1?.DirectoryNames;
        var directoryNames2 = data2?.DirectoryNames;
        var level = 1;
        var max = Max
        (
            directoryNames1?.Length ?? 0,
            directoryNames2?.Length ?? 0
        );
        for (var i = 0; i < max; i++)
        {
            var name1 = directoryNames1?.ElementAtOrDefault(i);
            var name2 = directoryNames2?.ElementAtOrDefault(i);
            if (string.IsNullOrEmpty(name1))
            {
                break;
            }
            if (string.IsNullOrEmpty(name2))
            {
                yield return new SearchTreeGroupEntry(new GUIContent(name1)) {level = level};
            }
            else if (name1 != name2)
            {
                yield return new SearchTreeGroupEntry(new GUIContent(name1)) {level = level};
                i = max;
            }
            level++;
        }
        yield return new SearchTreeEntry(new GUIContent(data1.FileName, Constants.IconPrefab)) {level = level, userData = data1.AssetPath};
        data2 = data1;
    }
}
```
</div></details>


# 完成品
- Unity 2019.4.14f1 で動作確認しました
- すでに触れていますが `Experimental` な機能を利用しています

```cs:InstantiatePrefabWindow.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using static UnityEngine.Mathf;

/// <summary>
/// 指定したディレクトリ配下を対象にした Prefab 一覧を表示し、選択した Prefab を配置するメニューウィンドウ
/// </summary>
public sealed class InstantiatePrefabWindow : ScriptableObject, ISearchWindowProvider
{
    /// <summary>
    /// 指定したディレクトリパスとアセットパスから得られる情報群
    /// </summary>
    class Data
    {
        const string Separator = "/";
        public string AssetPath { get; }
        public string FileName { get; }
        public string[] DirectoryNames { get; }

        public Data(string assetPath, string basePath)
        {
            var fileName = Path.GetFileNameWithoutExtension(assetPath);
            var directory = Path.GetDirectoryName(assetPath.Remove(0, basePath.Length + Separator.Length));
            AssetPath = assetPath;
            FileName = fileName;
            DirectoryNames = directory.Split(new[] {Separator}, StringSplitOptions.None);
        }
    }

    class Constants
    {
        public static readonly Texture IconPrefab = EditorGUIUtility.IconContent("GameObject Icon").image;
    }

    string basePath;

    /// <summary>
    /// メニューを開く
    /// </summary>
    /// <param name="basePath">指定したディレクトリ配下を対象にメニューを作成する</param>
    public static void Open(string basePath = "Assets")
    {
        var hierarchyWindow = Resources.FindObjectsOfTypeAll<EditorWindow>().First(window => window.GetType().Name == "SceneHierarchyWindow");
        var provider = CreateInstance<InstantiatePrefabWindow>();
        provider.basePath = basePath;
        var position = new Vector2(hierarchyWindow.position.x + hierarchyWindow.position.width / 2, hierarchyWindow.position.y + 56);
        SearchWindow.Open(new SearchWindowContext(position, hierarchyWindow.position.width), provider);
    }

    /// <summary>
    /// データ構造の作成
    /// </summary>
    public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
    {
        var assetPaths = AssetDatabase.FindAssets("t:prefab", new[] {basePath}).Select(AssetDatabase.GUIDToAssetPath);
        var data = assetPaths.Select(assetPath => new Data(assetPath, basePath));
        var entries = DataToEntries(data);
        return entries.ToList();
    }

    /// <summary>
    /// 選択時の処理
    /// </summary>
    public bool OnSelectEntry(SearchTreeEntry entry, SearchWindowContext context)
    {
        DestroyImmediate(this);
        var assetPath = entry.userData as string;
        var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
        var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
        instance.transform.SetParent(Selection.activeTransform, false);
        Selection.activeObject = instance;
        return true;
    }

    /// <summary>
    /// パスリストからメニューのデータ構造を作成
    /// </summary>
    IEnumerable<SearchTreeEntry> DataToEntries(IEnumerable<Data> dataList)
    {
        yield return new SearchTreeGroupEntry(new GUIContent("Select Prefab"));
        var data2 = default(Data); // 1 = current, 2 = prev
        foreach (var data1 in dataList)
        {
            var directoryNames1 = data1?.DirectoryNames;
            var directoryNames2 = data2?.DirectoryNames;
            var level = 1;
            var max = Max
            (
                directoryNames1?.Length ?? 0,
                directoryNames2?.Length ?? 0
            );
            for (var i = 0; i < max; i++)
            {
                var name1 = directoryNames1?.ElementAtOrDefault(i);
                var name2 = directoryNames2?.ElementAtOrDefault(i);
                if (string.IsNullOrEmpty(name1))
                {
                    break;
                }
                if (string.IsNullOrEmpty(name2))
                {
                    yield return new SearchTreeGroupEntry(new GUIContent(name1)) {level = level};
                }
                else if (name1 != name2)
                {
                    yield return new SearchTreeGroupEntry(new GUIContent(name1)) {level = level};
                    i = max;
                }
                level++;
            }
            yield return new SearchTreeEntry(new GUIContent(data1.FileName, Constants.IconPrefab)) {level = level, userData = data1.AssetPath};
            data2 = data1;
        }
    }
}
```

使い方としては以下のような形で登録します。

```cs
public class MyMenuItems
{
    [MenuItem("GameObject/Create UI", false, 0)]
    static void Execute() => InstantiatePrefabWindow.Open("Assets/Prefabs");
}
```

# 終わりに
この記事は [KLab 2020 Advent Calendar](https://qiita.com/advent-calendar/2020/klab) の 12/3 の記事でした。