【Unity, UIElements】GraphViewと戯れる
クライアントエンジニアの穴うなぎです。
今回のブログでは、UIElementsの勉強を兼ねてGraphViewを触った結果得られた知見を紹介します。
GraphViewはEditor上にノードエディタを作成できる機能です。ShaderGraphやVFXGraphはこのAPIを使って実装されています。
本記事では基本的なGraphViewの使い方については触れませんのでご了承ください。
GraphViewを初めて触る方はこちらの記事や動画を参考にされることをおすすめします。
GraphView完全理解した(2019年末版) - Qiita
UNITY DIALOGUE GRAPH TUTORIAL - Setup - YouTube
使用したUnityのバージョンは2019.3.6f1です。
今回作成したノードエディタの紹介
マインドマップ的なノードエディタを作成してみました。
このノードエディタは以下の機能を持ちます。
- Outputが1つあるEntryPointノードを起点に、子ノードをつなげてグラフを作成できる
- 各ノードはキーワードを入力するためのTextFieldを持つ
- 各ノードは表示色を変更できる
- ノードの編集UIは折りたたみが可能で、折りたたむと1行になる
- グラフの保存・読み込みができる
GraphViewの知見
Nodeに置くVisualElementは適切なContainerへ
NodeはhogeContainer
という命名のVisualElementプロパティを持っています。
それぞれのhogeContainer
はEditor上の位置に対応しています。
hogeContainer
の階層構造は以下のとおりです。
1 2 3 4 5 6 7 8 |
contentContainer |-mainContainer |-titleContainer | |-titleButtonContainer |-topContainer | |-inputContainer | |-outputContainer |-extensionContainer |
Editor上ではhogeContainer
の位置関係は以下のようになっています。
一部のhogeContainer
は内部的に子要素の型を判定して行われる処理があるため、適切なContainerに要素を配置しなかった場合は予期しない挙動をすることがあります。(後述)
基本的にはPortをinputContainer, outputContainer
に配置し、パラメータ設定の要素をextensionContainer
に配置するといいでしょう。
デフォルトのCollapseボタンは地味に不便
Nodeにはデフォルトでノードを開閉するボタンが備わっています。
このボタンは接続を持つPortがある場合にボタンを無効にする処理が備わっています。
今回はNodeをコンパクトにしたかったので、接続を持つPortがあっても閉じることのできるノード開閉ボタンに置き換えました。
Nodeの開閉はexpanded
プロパティのsetterから実行できます。
1 2 3 4 5 6 7 8 9 10 11 12 |
node.titleButtonContainer.Clear(); // デフォルトのCollapseボタンを削除 var collapseButton = new Button(() => node.expanded = !node.expanded) {text = "v"}; // 適当に整形 collapseButton.style.fontSize = 10; collapseButton.style.marginTop = 8; collapseButton.style.marginBottom = 8; collapseButton.style.marginLeft = 5; collapseButton.style.marginRight = 5; collapseButton.style.fontSize = 10; node.titleButtonContainer.Add(collapseButton); |
PortをinputContainer、outputContainer以外に置かない
inputContainer
、outputContainer
以外にPortを置くと、Editor上でNodeを削除した際に接続されたEdgeを消す処理が動かない、などの予期しない挙動をします。
そのため、PortはinputContainer
、outputContainer
に置くのが無難です。
今回はtopContainer
にtitleContainer
をInsert()
してコンパクトにしてみました。
1 |
node.topContainer.Insert(1, node.titleContainer); |
また、今回作成したグラフではPortの型が1つしかなくLabelが不要なので削除しました。
Portに表示されるLabelの値はportName
プロパティからアクセスできます。
VisualElementのstyleを動的に変えられる
styleプロパティに直接アクセスすることでVisualElementの色をスクリプトから変更できます。
今回作成したグラフではColorFieldのCallbackでノードの色を変えています。
1 2 3 4 5 6 7 |
var colorField = new ColorField; colorField.RegisterValueChangedCallback(evt => { node.titleContainer.style.backgroundColor = evt.newValue; if (inputPort != null) inputPort.portColor = evt.newValue; outputPort.portColor = evt.newValue; }); |
TextFieldが日本語入力を受け付けるようにするためには設定が必要
TextFieldはデフォルトではIMEオフの入力のみを受け付けますが、以下のようにCallbackを登録するとIMEによる入力が可能になります。
1 2 3 |
var textField = new TextField(); textField.RegisterCallback<FocusInEvent>(evt => { Input.imeCompositionMode = IMECompositionMode.On; }); textField.RegisterCallback<FocusOutEvent>(evt => { Input.imeCompositionMode = IMECompositionMode.Auto; }); |
グラフの保存と読込の実装
グラフをScriptableObjectに保存・読み込みする機能を実装しました。
サンプルコードを以下に示します。
NodeにGUIDを持たせてやるとNodeとEdgeの管理が容易になります。
GraphSaveUtility.SaveGraph()
ではGraphViewから取得したNodeとEdgeをScriptableObjectに保存します。
GraphSaveUtility.LoadGraph()
ではScriptableObjectのデータを基に、GraphViewの初期化、Nodeの生成、Edgeの生成を行います。
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 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
public class MindMapNode : Node { public string Guid; // 略 } public class MindMapNodeFactory { public static MindMapNode Create() { // 略 } } [Serializable] public class EdgeData { public string BaseNodeGuid; public string TargetNodeGuid; } [Serializable] public class NodeData { public string Guid; // 略 } public class GraphData : ScriptableObject { public List<EdgeData> Edges = new List<EdgeData>(); public List<NodeData> Nodes = new List<NodeData>(); } public class GraphSaveUtility { public static void SaveGraph(string fileName, GraphView graphView) { var edges = GetEdges(graphView); if (!edges.Any()) return; var graphData = ScriptableObject.CreateInstance<GraphDataContainer>(); foreach (var edge in edges) { var outputNode = edge.output.node as MindMapNode; var inputNode = edge.input.node as MindMapNode; graphData.Edges.Add(new EdgeData() { BaseNodeGuid = outputNode.Guid, TargetNodeGuid= inputNode.Guid }); } var nodes = GetNodes(graphView); foreach (var node in nodes) { graphData.Nodes.Add(new NodeData() { Guid = node.Guid, Keyword = node.Keyword, Color = node.Color, IsEntryPoint = node.IsEntryPoint, Position = node.GetPosition().position, Expanded = node.expanded, }); } if (!AssetDatabase.IsValidFolder("Assets/Resources")) { AssetDatabase.CreateFolder("Assets", "Resources"); } AssetDatabase.CreateAsset(graphData, $"Assets/Resources/{fileName}.asset"); AssetDatabase.SaveAssets(); } public static void LoadGraph(string fileName, GraphView graphView) { var graphData = Resources.Load<GraphDataContainer>(fileName); if (graphView == null) { EditorUtility.DisplayDialog("FIle Not Found", "Target graph file does not exists!", "OK"); return; } ClearGraph(graphView); CreateNodes(graphView, graphData); CreateEdges(graphView, graphData); ApplyExpandedState(graphView, graphData); // NodeがExpanded状態でないとPortを発見できないのでEdge生成後に折りたたむ } private static void ClearGraph(GraphView graphView) { graphView.nodes.ToList().ForEach(graphView.RemoveElement); graphView.edges.ToList().ForEach(graphView.RemoveElement); } private static void CreateNodes(GraphView graphView, GraphDataContainer graphData) { foreach (var nodeData in graphData.Nodes) { var tempNode = MindMapNodeFactory.Create(nodeData.Keyword, nodeData.Position, nodeData.Color, nodeData.IsEntryPoint); tempNode.Guid = nodeData.Guid; graphView.AddElement(tempNode); } } private static void CreateEdges(GraphView graphView, GraphDataContainer graphData) { var nodes = GetNodes(graphView); foreach (var baseNode in nodes) { var edges = graphData.Edges.Where(x => x.BaseNodeGuid == baseNode.Guid).ToList(); foreach (var edgeData in edges) { var targetNode = nodes.First(x => x.Guid == edgeData.TargetNodeGuid); var inputPort = targetNode.inputContainer.Q<Port>(); var outputPort = baseNode.outputContainer.Q<Port>(); var edge = ConnectPorts(outputPort, inputPort); graphView.Add(edge); } } } private static Edge ConnectPorts(Port output, Port input) { var tempEdge = new Edge { output = output, input = input }; tempEdge.input.Connect(tempEdge); tempEdge.output.Connect(tempEdge); return tempEdge; } private static void ApplyExpandedState(graphView, graphData) { // 略 } private static List<Edge> GetEdges(GraphView graphView) => graphView.edges.ToList(); private static List<MindMapNode> GetNodes(GraphView graphView) => graphView.nodes.ToList().Cast<MindMapNode>().ToList(); } |
おわりに
今回GraphViewを触って「かなり遊べるな!」という印象を受けました。
ノードに配置する要素の自由度が高く、Edgeの拘束も自由に設定できるので多様なグラフの表現が可能に思いました。
みなさんもぜひGraphViewを触ってみてはいかがでしょうか。
参考文献
UNITY DIALOGUE GRAPH TUTORIAL - Setup - YouTube
GraphView完全理解した(2019年末版) - Qiita
Unity - Manual: UIElements Developer Guide
XAMLとか知らねぇよ!!C#だけでUIElement式エディタ拡張をする入門~style編~ - Qiita