[Unity] エディタ拡張用のアセット検索＆選択ウィンドウを作った

# はじめに

エディタ拡張用のアセット検索＆選択ウィンドウというのを結構前に作ったのですが、割と汎用的に使えるように作っていたのでソースコードまるごと公開してしまおう、という感じの記事です。
前の記事とは関係ありそうで微妙に関係ありません。

## 作った動機

なんかコンポーネントに登録するアセットを**特定のアセット**から選びたいみたいなシチュエーションって多いと思うのですが、Unity標準のアセット選択ウィンドウがあまりにも使い勝手が悪いので自分で作りました。
Unity標準の選択ウィンドウ、「特定のフォルダにある画像ファイルを一覧する」みたいな事すらできないので、もうちょっとどうにかなってほしいなあという気持ちがあります。

# ソースコード

早速ですがソースコードです。
まず、こんな感じの抽象クラスが用意されています。

```SelectWindowBase.cs
using UnityEngine;
using UnityEditor;
using System;
using System.Collections.Generic;

public abstract class SelectWindowBase : EditorWindow
{
    protected Action<string> callback;

    /// <summary>
    /// リスト要素に使用するアイコン画像のパスのリスト
    /// </summary>
    protected virtual string[] iconPaths { get { return null; } }

    /// <summary>
    /// リスト要素に対応するラベルのリスト
    /// </summary>
    protected virtual string[] values { get { return null; } }

    private Dictionary<string, Texture2D> textureMap = new Dictionary<string, Texture2D>();

    private string searchValue;
    private Vector2 scrollPosition = new Vector2(0, 0);

    private GUIStyle labelStyle;
    private GUIStyle clearButtonStyle;
    private GUIStyle focusedLabelStyle;

    private bool focusFlag;
    private bool searchValueChanged;

    // リスト要素のボタンの幅
    protected int itemWidth = 64;
    // リスト要素のボタンの高さ
    protected int itemHeight = 64;
    // リスト要素のラベルの高さ
    protected int labelHeight = 16;
    // 水平方向の要素ごとの間隔
    protected int horizontalSpacing = 0;
    // 垂直方向方向の要素ごとの間隔
    protected int verticalSpacing = 0;

    protected void SetSearchValue(string value)
    {
        searchValue = value;
        searchValueChanged = true;
    }

    public void FocusOnSearchField()
    {
        focusFlag = true;
    }

    private void OnGUI()
    {
        if (Event.current.keyCode == KeyCode.Escape)
        {
            Close();
            Event.current.Use();
        }

        using (new EditorGUILayout.HorizontalScope())
        {
            GUI.SetNextControlName("SearchField");
            var input = EditorGUILayout.TextField("Search", searchValue);
            if (input != searchValue)
            {
                SetSearchValue(input);
            }

            if (clearButtonStyle == null)
            {
                clearButtonStyle = new GUIStyle(GUI.skin.button) { stretchWidth = false };
            }

            GUI.SetNextControlName("ClearButton");
            if (GUILayout.Button("×", clearButtonStyle))
            {
                searchValue = "";
                GUI.FocusControl("ClearButton");
            }
        }

        GUILayout.Space(5);

        if (labelStyle == null)
        {
            labelStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, stretchWidth = true };
        }

        scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);

        // リスト要素の左上のposition
        var itemPositionX = 0;
        var itemPositionY = 0;

        var viewportStart = scrollPosition.y;
        var viewportEnd = scrollPosition.y + position.height;

        for (var i = 0; i < values.Length; i++)
        {
            var value = values[i];

            if (itemPositionX + itemWidth >= position.width)
            {
                itemPositionX = 0;
                itemPositionY += itemHeight + labelHeight + verticalSpacing;
                GUILayout.Space(itemHeight + labelHeight + verticalSpacing);
            }

            // リスト要素が描画範囲内にある場合
            if (itemPositionY + itemHeight + labelHeight >= viewportStart && itemPositionY <= viewportEnd)
            {
                var iconPath = iconPaths[i];
                if (!textureMap.ContainsKey(iconPath))
                {
                    textureMap[iconPath] = AssetDatabase.LoadAssetAtPath<Texture2D>(iconPath);
                }

                var texture = textureMap[iconPath];
                if (GUI.Button(new Rect(itemPositionX, itemPositionY, itemWidth, itemHeight), texture))
                {
                    CloseWithCallback(value);
                }
            }

            // 検索と一致した場合、ラベルの背景に色付けをしてスクロール位置を飛ばす
            if (searchValue == value)
            {
                if (searchValueChanged)
                {
                    scrollPosition.y = itemPositionY;
                    searchValueChanged = false;
                }

                if (focusedLabelStyle == null)
                {
                    focusedLabelStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, stretchWidth = true };
                    focusedLabelStyle.normal.textColor = Color.black;
                    focusedLabelStyle.normal.background = Texture2D.whiteTexture;
                    focusedLabelStyle.fontStyle = FontStyle.Bold;
                }

                Color beforeBackColor = GUI.backgroundColor;

                GUI.backgroundColor = Color.yellow;

                EditorGUI.LabelField(new Rect(itemPositionX, itemPositionY + itemHeight, itemWidth, labelHeight), value, focusedLabelStyle);

                GUI.backgroundColor = beforeBackColor;

                if (Event.current.keyCode == KeyCode.Return)
                {
                    CloseWithCallback(value);
                    Event.current.Use();
                }
            }
            else
            {
                EditorGUI.LabelField(new Rect(itemPositionX, itemPositionY + itemHeight, itemWidth, labelHeight), value, labelStyle);
            }

            itemPositionX += itemWidth + horizontalSpacing;
        }

        GUILayout.Space(itemHeight + labelHeight + verticalSpacing);

        EditorGUILayout.EndScrollView();

        if (GUILayout.Button("Cancel"))
        {
            Close();
        }

        if (focusFlag)
        {
            EditorGUI.FocusTextInControl("SearchField");
            focusFlag = false;
        }
    }

    private void CloseWithCallback(string value)
    {
        if (callback != null) callback(value);

        Close();
    }
}
```

こんな感じで継承して使います。
画像のパスのリストと、対応する値（string）のリストをoverrideしてやる感じです。
要素のサイズなんかもよしなに調節してやります。

```SampleSelectWindow.cs
using UnityEngine;
using System;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEditor;

public class TestSelectWindow : SelectWindowBase
{
    private static string[] valuesCache;
    protected override string[] values
    {
        get
        {
            return valuesCache;
        }
    }

    private static string[] iconPathsCache;
    protected override string[] iconPaths
    {
        get
        {
            return iconPathsCache;
        }
    }

    private static bool initialized;

    [MenuItem("Sample/Test")]
    public static TestSelectWindow Open()
    {
        return Open(null, null);
    }

    public static SampleSelectWindow Open(string defaultValue = null, Action<string> callback = null)
    {
        if (!initialized)
        {
            // Assets/Test 以下の画像を一覧表示する
            valuesCache = System.IO.Directory.GetFiles("Assets/Test/")
                                             .Where(path => !path.EndsWith(".meta"))
                                             .Select(path => System.IO.Path.GetFileNameWithoutExtension(path)).ToArray();
            iconPathsCache = valuesCache.Select(value =>
            {
                return string.Format("Assets/Test/{0}.png", value);
            }).ToArray();
        }

        var editor = GetWindowWithRect<SampleSelectWindow>(new Rect(0, 0, 660, 600), true);
        editor.maxSize = new Vector2(1600, 1200);
        editor.minSize = new Vector2(400, 300);
        editor.itemWidth = 120;

        editor.titleContent = new GUIContent("SelectWindow");
        editor.callback = callback;
        editor.SetSearchValue(defaultValue);

        editor.ShowAuxWindow();
        editor.FocusOnSearchField();

        return editor;
    }
}
```

# 動作イメージ

こんな感じで使えます。（UnityEditorからフォーカス外すとキャプチャできないので、Dockに入れて表示しています）
ウィンドウサイズに応じてリスト要素をグリッド状に描画してくれます。

![12月-21-2018 21-56-14.gif](https://qiita-image-store.s3.amazonaws.com/0/97014/82ee1599-6941-09dd-08a7-db26df819dbf.gif)

クリックするとコールバックを実行してWindowが閉じます。`Debug.Log(value);`をコールバックに入れておくとこんな感じです。

![12月-21-2018 22-03-06.gif](https://qiita-image-store.s3.amazonaws.com/0/97014/7dffd437-aebf-6641-796a-8e0576abf44f.gif)

上の検索バーに文字列を入れるとこんな感じになります。
一致した要素に自動的にフォーカスします。その状態でEnterを押すとその要素が選択されコールバックが実行されます。

![12月-21-2018 22-09-28.gif](https://qiita-image-store.s3.amazonaws.com/0/97014/9cf8d5e9-18d4-6eaf-6d52-bd0d8f0dea2e.gif)
（なんかエラー出てるけどなんでだろう）

# 実装について

一応描画範囲内の画像だけ読み込むという感じにしていますが、それ以外は割と雑な作りです。
諸々効率化できそうな所は残っていますが、エディタ拡張で厳密なパフォーマンスを求めても仕方ないし、現状で十分サクサク動いているので特に対応はしていません。

# おわりに

この時代にIMGUIでゴリゴリ実装するのもなかなか辛かったし、早くUIElementsが来て欲しいなあ、というかそもそも標準の選択ウィンドウがもっとマシになってほしいな、という気持ちです。
まあせっかく作ったし、割と色々と使えるんじゃないかなと思うので、ぜひ使ってやって下さい。