# Creating a custom Editor Window using a Multi Column Header

Hey there I'm trying to use the MultiColumnHeader to create a tool to view materials in the scene, inspired by the light explorer. The problems I'm facing are two fold. First I can't figure out how to place the content of the MultiColumnHeader in a scrollable view and secondly I can't figure out how to give the rows a different background color that alternates like in the light explorer. This is my OnGUI code:

 void OnGUI()
{
windowVisibleRect.width = position.width;
windowVisibleRect.height = position.height;

float xScroll = scrollPos.x;

// draw the column's contents
for (int i = 0; i < columns.Length; i++)
{
// calculate column content rect
contentRect.x -= xScroll;
contentRect.y = contentRect.yMax;
contentRect.yMax = windowVisibleRect.yMax;
Rect rowRect = new Rect(contentRect.x, contentRect.y, windowVisibleRect.width,20);
for (int j = 0; j < analyzerList.Count; j++)
{

rowRect.y += 25;
EditorGUI.ObjectField(MatcellRect, GUIContent.none, analyzerList[j].mat, typeof(Material), false);

analyzerList[j].ShowGameObject = EditorGUI.Foldout(GameObjectsRect,analyzerList[j].ShowGameObject, "Show " + analyzerList[j].gameObjects.Count + " GameObjects");
if (analyzerList[j].ShowGameObject)
{
foreach (GameObject go in analyzerList[j].gameObjects)
{
GameObjectsRect.y += GameObjectsRect.height;
EditorGUI.ObjectField(GameObjectsRect, GUIContent.none, go, typeof(Material), false);

rowRect.y += GameObjectsRect.height;
}
}

}
}

}


If a Editor GUI guru could explain to me how to do it like I'm a child that would be great :). I don't do custom GUI stuff and most of this is beyond me.

As per requested I'm uploading two example images. One from unity and one I randomly found from the net. Note that I do not need the "stacking"(I dont know how to call them) headers from the second example. I do how ever require a vertical and horizontal scrollbar like in the second example.

And finally just for reference this is what my code does so far

• Do you have to use MultiColumnHeader or do you accept any other answers aswell ? Feb 4 '21 at 13:04
• I would accept a answer with the same functionality as multi column header Feb 4 '21 at 14:02
• Could you find an image example(approximate/similar, not unity only) of what you are trying to achieve, that would help greatly. I worked a lot with GUI and Editor scripting stuff, so I don't mind writing a thing or two and giving the result code, helps me learn stuff like that too. I suppose it should be done in an old fashioned EditorGUI style rather than new .uss .uxml and VisualElement approach? The difference is first is CustomEditor/EditorWindow that uses OnGUI, the other one is newer that uses similar approach to development like WEB development with html and css. Feb 5 '21 at 13:06
• @CandidMoon_Max_ , Hey there I updated the question with some reference images. As for your question. Yes it has to be done in the old EditorGUI style so that it can run and support older version of unity. I first set of to do it with the new VisualElement but found it rather lacking at this point. Feb 5 '21 at 14:09
• Please, let me know if you have any questions regarding my answer. I have commented on most things I think could bring out questions, but I might have missed some. It's easier to read with these comments in an appropriate IDE. Feb 8 '21 at 0:34

Code:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;

#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.IMGUI.Controls;

public class MCHEditorWindow : EditorWindow
{
/// <summary>
/// This is a test object, just to showcase how this could be used.
/// </summary>
private class Enemy
{
public string Name { get; set; }
public float Health { get; set; }
public Color SkinColor { get; set; }

public Enemy(string name, float health, Color skinColor)
{
this.Name = name;
this.Health = health;
this.SkinColor = skinColor;
}
}

public static MCHEditorWindow Open()
{
title: "MCH Editor Window",
focus: true
);

commentsNotebookEditorWindow.minSize = new Vector2(x: 450.0f, y: 100.0f);

}

// Create a few test subjects.
private Enemy[] _testObjects = new Enemy[]
{
new Enemy("Orc", 25.0f, Color.green),
new Enemy("Fairy", 10.0f, Color.cyan),
new Enemy("Mech Golem", 57.0f, Color.grey),
new Enemy("Orc", 25.0f, Color.green),
new Enemy("Fairy", 10.0f, Color.cyan),
new Enemy("Mech Golem", 57.0f, Color.grey),
new Enemy("Orc", 25.0f, Color.green),
new Enemy("Fairy", 10.0f, Color.cyan),
new Enemy("Mech Golem", 57.0f, Color.grey),
new Enemy("Orc", 25.0f, Color.green),
new Enemy("Fairy", 10.0f, Color.cyan),
new Enemy("Mech Golem", 57.0f, Color.grey),
};

private void Initialize()
{
// We can move these columns into some ScriptableObject or some other data saving object/file to save their properties there, otherwise because of some events these settings will be recreated and state of the window won't be saved as expected.
{
{
allowToggleVisibility = false, // At least one column must be there.
autoResize = true,
minWidth = 250.0f,
canSort = true,
sortingArrowAlignment = TextAlignment.Right,
headerContent = new GUIContent("Name", "A name of an enemy."),
},
{
allowToggleVisibility = true,
autoResize = true,
minWidth = 300.0f,
canSort = false,
sortingArrowAlignment = TextAlignment.Right,
headerContent = new GUIContent("Health", "A health of an enemy."),
},
{
allowToggleVisibility = true,
autoResize = true,
minWidth = 125.0f,
maxWidth = 175.0f,
canSort = false,
sortingArrowAlignment = TextAlignment.Right,
headerContent = new GUIContent("Skin Color", "A color of an enemy skin."),
},
};

// When we chagne visibility of the column we resize columns to fit in the window.

// Initial resizing of the content.
}

private readonly Color _lighterColor = Color.white * 0.3f;
private readonly Color _darkerColor = Color.white * 0.1f;

private Vector2 _scrollPosition;

private void OnGUI()
{
// After compilation and some other events data of the window is lost if it's not saved in some kind of container. Usually those containers are ScriptableObject(s).
{
this.Initialize();
}

// Basically we just draw something. Empty space. Which is FlexibleSpace here on top of the window.
// We need this for - GUILayoutUtility.GetLastRect() because it needs at least 1 thing to be drawn before it.
GUILayout.FlexibleSpace();

// Get automatically aligned rect for our multi column header component.
Rect windowRect = GUILayoutUtility.GetLastRect();

// Here we are basically assigning the size of window to our newly positioned windowRect.
windowRect.width = this.position.width;
windowRect.height = this.position.height;

float columnHeight = EditorGUIUtility.singleLineHeight;

// This is a rect for our multi column table.
Rect columnRectPrototype = new Rect(source: windowRect)
{
height = columnHeight, // This is basically a height of each column including header.
};

// Just enormously large view if you want it to span for the whole window. This is how it works [shrugs in confusion].
Rect positionalRectAreaOfScrollView = GUILayoutUtility.GetRect(0, float.MaxValue, 0, float.MaxValue);

// Create a viewRect since it should be separate from rect to avoid circular dependency.
Rect viewRect = new Rect(source: windowRect)
{
xMax = this._columns.Sum((column) => column.width) // Scroll max on X is basically a sum of width of columns.
};

this._scrollPosition = GUI.BeginScrollView(
position: positionalRectAreaOfScrollView,
scrollPosition: this._scrollPosition,
viewRect: viewRect,
alwaysShowHorizontal: false,
alwaysShowVertical: false
);

// Draw header for columns here.

// For each element that we have in object that we are modifying.
//? I don't have an appropriate object here to modify, but this is just an example. In real world case I would probably use ScriptableObject here.
for (int a = 0; a < this._testObjects.Length; a++)
{
//! We draw each type of field here separately because each column could require a different type of field as seen here.
// This can be improved if we want to have a more robust system. Like for example, we could have logic of drawing each field moved to object itself.
// Then here we would be able to just iterate through array of these objects and call a draw methods for these fields and use this window for many types of objects.
// But example with such a system would be too complicated for gamedev.stackexchange, so I have decided to not overengineer and just use hard coded indices for columns - columnIndex.

Rect rowRect = new Rect(source: columnRectPrototype);

rowRect.y += columnHeight * (a + 1);

// Draw a texture before drawing each of the fields for the whole row.
if (a % 2 == 0)
EditorGUI.DrawRect(rect: rowRect, color: this._darkerColor);
else
EditorGUI.DrawRect(rect: rowRect, color: this._lighterColor);

// Name field.
int columnIndex = 0;

{

// This here basically is a row height, you can make it any value you like. Or you could calculate the max field height here that your object has and store it somewhere then use it here instead of EditorGUIUtility.singleLineHeight.
// We move position of field on y by this height to get correct position.
columnRect.y = rowRect.y;

GUIStyle nameFieldGUIStyle = new GUIStyle(GUI.skin.label)
{
padding = new RectOffset(left: 10, right: 10, top: 2, bottom: 2)
};

EditorGUI.LabelField(
label: new GUIContent(this._testObjects[a].Name),
style: nameFieldGUIStyle
);
}

// Health slider field.
columnIndex = 1;

{

columnRect.y = rowRect.y;

this._testObjects[a].Health = EditorGUI.Slider(
value: this._testObjects[a].Health,
leftValue: 0.0f,
rightValue: 100.0f
);
}

// Skin color field.
columnIndex = 2;

{

columnRect.y = rowRect.y;

this._testObjects[a].SkinColor = EditorGUI.ColorField(
label: GUIContent.none,
value: this._testObjects[a].SkinColor,
showEyedropper: true,
showAlpha: false,
hdr: false
);
}
}

GUI.EndScrollView(handleScrollWheel: true);
}

private void Awake()
{
this.Initialize();
}
}
#endif


Result:

• Hey there thank you for the great answer. Your code and comments helped me clear up some ideas in my head. If I can only ask two more noob questions hehe. How would I put the entire thing in a container that has padding from the sides of the window and second question is about having a foldout control in each row. How can I expand the whole row to fit the contents of the foldout. Am awarding you the bounty already. Feb 8 '21 at 14:36
• @UriPopov Hey, thanks. I have found a bug in this code as well, size of header is not correctly scaled, which cuts of a part of it. I will post an update with bug fix and I will try to implement these features. Not sure how long it's going to take, but I will try asap. For foldouts it depends on the use case, for example, if you have a serializable object then somehow it's SerializedObject/SerializedProperty or editor should be accessed to get calculated height of the expanded field, then we add this height to our column height. I will experiment with auto Layout - if it works with Header. Feb 8 '21 at 15:38
• Hey. The current use case is that a bunch of game objects are stored in a list. I'm displaying each material in the scene and then a foldout with all gameobjects using that material. Each gameObject is represented with a ObjectField so clicking it will highlight it in the scene. Feb 8 '21 at 16:07
• @UriPopov github.com/PixelLifetime/unity-game-development-stack-exchange/… - I have managed to "fix"(hack) the scaling problem, it had either a bug or I am doing something that MCH doesn't expected me to do. Anyway this is working now, if you need this version right now, just follow the link. Onto other 2. Feb 8 '21 at 18:06
• @ Candid Moon Max Wow, you are truly awesome. I will check everything out as soon as I can. Feb 8 '21 at 18:39

Why is this a separate answer?

First - it goes out of the scope of the question bringing more features.

Second - it has a slightly different approach to rendering objects, the object rendered is inherited from UnityEngine.Object which enables serialization for it for SerializedObject, but it also brings some things that others might not like to have in their architecture.

Possible issues:

• You won't be able to assign GameObject[s] out of the box, since unity doesn't serialize scene objects in ScriptableObject. You will have to either serialize a different type of object or implement your own solution, which can be complicated.
• DrawRect is making rows less visible, but I have described in the comments what you can do to achieve the desired result.

Code:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.IMGUI.Controls;

public class MCHEditorWindow : EditorWindow
{
/// <summary>
/// This is a test object, just to showcase how this could be used.
/// </summary>
[System.Serializable]
private class Enemy : ScriptableObject
{
[SerializeField] private string _name;
public string Name => this._name;

[SerializeField] private float _health;
public float Health => this._health;

[SerializeField] private Color _skinColor;
public Color SkinColor => this._skinColor;

[SerializeField] private List<GameObject> _gameObjects;
public List<GameObject> GameObjects => this._gameObjects;

public Enemy(string name, float health, Color skinColor)
{
this._name = name;
this._health = health;
this._skinColor = skinColor;
}

public void Initialize(string name, float health, Color skinColor)
{
this._name = name;
this._health = health;
this._skinColor = skinColor;
}

private void Awake()
{
this._name = "Default Name";
this._health = 34.2f;
this._skinColor = Color.white;
}
}

public static MCHEditorWindow Open()
{
title: "MCH Editor Window",
focus: true
);

commentsNotebookEditorWindow.minSize = new Vector2(x: 450.0f, y: 100.0f);

}

// Create a few test subjects.
private Enemy[] _testObjects = new Enemy[3];
//{
//  new Enemy("Orc", 25.0f, Color.green),
//  new Enemy("Fairy", 10.0f, Color.cyan),
//  new Enemy("Mech Golem", 57.0f, Color.grey),
//};

private bool _firstOnGUIIterationAfterInitialize;

private void Initialize()
{
this._firstOnGUIIterationAfterInitialize = true;

// We can move these columns into some ScriptableObject or some other data saving object/file to save their properties there, otherwise because of some events these settings will be recreated and state of the window won't be saved as expected.
{
{
allowToggleVisibility = false, // At least one column must be there.
autoResize = true,
minWidth = 100.0f,
maxWidth = 250.0f,
canSort = true,
sortingArrowAlignment = TextAlignment.Right,
headerContent = new GUIContent("Name", "A name of an enemy."),
},
{
allowToggleVisibility = true,
autoResize = true,
minWidth = 300.0f,
canSort = false,
sortingArrowAlignment = TextAlignment.Right,
headerContent = new GUIContent("Health", "A health of an enemy."),
},
{
allowToggleVisibility = true,
autoResize = true,
minWidth = 125.0f,
maxWidth = 200.0f,
canSort = false,
sortingArrowAlignment = TextAlignment.Right,
headerContent = new GUIContent("Skin Color", "A color of an enemy skin."),
},
{
allowToggleVisibility = false,
autoResize = true,
minWidth = 300.0f,
canSort = false,
sortingArrowAlignment = TextAlignment.Right,
headerContent = new GUIContent("Game Objects", "An array of game objects."),
},
};

// When we chagne visibility of the column we resize columns to fit in the window.
{
};

// Initial resizing of the content.

this._testObjects[0] = ScriptableObject.CreateInstance<Enemy>();
this._testObjects[0].Initialize("Orc", 25.0f, Color.green);

this._testObjects[1] = ScriptableObject.CreateInstance<Enemy>();
this._testObjects[1].Initialize("Fairy", 10.0f, Color.cyan);

this._testObjects[2] = ScriptableObject.CreateInstance<Enemy>();
//this._testObjects[2].Initialize("Mech Golem", 57.0f, Color.grey);
}

private readonly Color _lighterColor = new Color(r: 1.0f, g: 1.0f, b: 1.0f, a: 0.15f);
private readonly Color _darkerColor = new Color(r: 0.0f, g: 0.0f, b: 0.0f, a: 0.15f);

private Vector2 _scrollPosition;

private void OnGUI()
{
float columnHeight = EditorGUIUtility.singleLineHeight;

// Basically we just draw something. Empty space. Which is FlexibleSpace here on top of the window.
// We need this for - GUILayoutUtility.GetLastRect() because it needs at least 1 thing to be drawn before it.
GUILayout.FlexibleSpace();

// Get automatically aligned rect for our multi column header component.
Rect windowRect = GUILayoutUtility.GetLastRect();

// Here we are basically assigning the size of window to our newly positioned windowRect.
windowRect.width = this.position.width;
windowRect.height = this.position.height;

// After compilation and some other events data of the window is lost if it's not saved in some kind of container. Usually those containers are ScriptableObject(s).
{
this.Initialize();
}

GUIStyle groupGUIStyle = new GUIStyle(GUI.skin.box)
{
//padding = new RectOffset(left: 50, right: 50, top: 50, bottom: 50) // For some reason doesn't work with group.
//border = new RectOffset(30, 30, 30, 30), // None work.
};

//! Here we just basically move around group. It's not really padding, we are just setting position and reducing size.
Vector2 groupRectPaddingInWindow = new Vector2(20.0f, 20.0f);

Rect groupRect = new Rect(source: windowRect);

GUI.BeginGroup(position: groupRect, style: groupGUIStyle);
{   // Group Scope.

Rect positionalRectAreaOfScrollView = new Rect(source: groupRect);

// Create a viewRect since it should be separate from rect to avoid circular dependency.
Rect viewRect = new Rect(source: groupRect)
{
width = this._multiColumnHeaderState.widthOfAllVisibleColumns, // Scroll max on X is basically a sum of width of columns.
//? Do not remove this hegiht. It's compensating for the size of bottom scroll slider when it appears, that is why the right side scroll slider appears.
//height = groupRect.height - columnHeight, // Remove columnHeight - basically size of header.
};

this._scrollPosition = GUI.BeginScrollView(
position: positionalRectAreaOfScrollView,
scrollPosition: this._scrollPosition,
viewRect: viewRect,
alwaysShowHorizontal: false,
alwaysShowVertical: false
);
{   // Scroll View Scope.

//? After debugging for a few hours - this is the only hack I have found to actually work to aleviate that scaling bug.

// This is a rect for our multi column table.
Rect columnRectPrototype = new Rect(source: positionalRectAreaOfScrollView)
{
height = columnHeight, // This is basically a height of each column including header.
};

// Draw header for columns here.

float heightJump = columnHeight;

// For each element that we have in object that we are modifying.
//? I don't have an appropriate object here to modify, but this is just an example. In real world case I would probably use ScriptableObject here.
for (int a = 0; a < this._testObjects.Length; a++)
{
SerializedObject serializedObject = new SerializedObject(obj: this._testObjects[a]);

float calculatedRowHeight = 0.0f;

//! We draw each type of field here separately because each column could require a different type of field as seen here.
// This can be improved if we want to have a more robust system. Like for example, we could have logic of drawing each field moved to object itself.
// Then here we would be able to just iterate through array of these objects and call a draw methods for these fields and use this window for many types of objects.
// But example with such a system would be too complicated for gamedev.stackexchange, so I have decided to not overengineer and just use hard coded indices for columns - columnIndex.

Rect rowRect = new Rect(source: columnRectPrototype);

//rowRect.y += columnHeight * (a + 1);

// Name field.
int columnIndex = 0;

{

// This here basically is a row height, you can make it any value you like. Or you could calculate the max field height here that your object has and store it somewhere then use it here instead of EditorGUIUtility.singleLineHeight.
// We move position of field on y by this height to get correct position.
columnRect.y = rowRect.y + heightJump;
//columnRect.height += heightJump;

GUIStyle nameFieldGUIStyle = new GUIStyle(GUI.skin.label)
{
padding = new RectOffset(left: 10, right: 10, top: 2, bottom: 2)
};

EditorGUI.LabelField(
label: new GUIContent(this._testObjects[a].Name),
style: nameFieldGUIStyle
);
}

// Health slider field.
columnIndex = 1;

{

columnRect.y = rowRect.y + heightJump;
//columnRect.height += heightJump;

SerializedProperty serializedProperty = serializedObject.FindProperty(propertyPath: "_health");

calculatedRowHeight = Mathf.Max(calculatedRowHeight, EditorGUI.GetPropertyHeight(property: serializedProperty, includeChildren: true));

EditorGUI.Slider(
property: serializedProperty,
leftValue: 0.0f,
rightValue: 100.0f,
label: GUIContent.none
);
}

// Skin color field.
columnIndex = 2;

{

columnRect.y = rowRect.y + heightJump;
//columnRect.height += heightJump;

SerializedProperty serializedProperty = serializedObject.FindProperty(propertyPath: "_skinColor");

calculatedRowHeight = Mathf.Max(calculatedRowHeight, EditorGUI.GetPropertyHeight(property: serializedProperty, includeChildren: true));

EditorGUI.PropertyField(
property: serializedProperty,
label: GUIContent.none
);
}

// Skin color field.
columnIndex = 3;

{

columnRect.y = rowRect.y + heightJump;
//columnRect.height += heightJump;

SerializedProperty serializedProperty = serializedObject.FindProperty(propertyPath: "_gameObjects");

calculatedRowHeight = Mathf.Max(calculatedRowHeight, EditorGUI.GetPropertyHeight(property: serializedProperty, includeChildren: true));

EditorGUI.PropertyField(
property: serializedProperty,
includeChildren: true
);
}

// The problem of it drawing here is that it draws over fields instead of under them.
//? The soultion could be to cache height for each row after 1 OnGUI and use it instead, upading it each time it reaches the end.
Rect backgroundColorRect = new Rect(source: rowRect)
{
y = rowRect.y + heightJump,
height = calculatedRowHeight
};

// Draw a texture before drawing each of the fields for the whole row.
if (a % 2 == 0)
EditorGUI.DrawRect(rect: backgroundColorRect, color: this._darkerColor);
else
EditorGUI.DrawRect(rect: backgroundColorRect, color: this._lighterColor);

heightJump += calculatedRowHeight;
}
}
GUI.EndScrollView(handleScrollWheel: true);
}
GUI.EndGroup();

if (!this._firstOnGUIIterationAfterInitialize)
{
//! Uncomment this if you want to have appropriate ResizeToFit(). It brings jaggedness which I didn't like, so I have removed it knowing the implications.

//float difference = 50.0f;

////! Magic number difference is just a number to ensure that this width won't be exceeded by this auto scale bug. Lowering this number could cause it to reappear.
////? If you don't mind resizing to fit to sometimes overscale columns then just remove these next 2 lines of code.
}

this._firstOnGUIIterationAfterInitialize = false;
}

private void Awake()
{
this.Initialize();
}
}
#endif


Result:

• If anyone is interested on how to reference these objects in editor like Light Explorer does, it most likely either 1. finds all of the objects from the active scene on Awake or similar method and caches references to them or 2. parses scene asset with the help of internal tools to edit the object that is on the scene, like Inspector - Selection, for example. Feb 11 '21 at 14:33