Skip to content

Instantly share code, notes, and snippets.

@akbyrd
Last active July 29, 2017 21:41
Show Gist options
  • Save akbyrd/c4b9baaf21b7f230a4c9e44750c4b657 to your computer and use it in GitHub Desktop.
Save akbyrd/c4b9baaf21b7f230a4c9e44750c4b657 to your computer and use it in GitHub Desktop.
An in-progress collection drawer for Unity. Enum specialization is prototype stage, feel free to ignore.
namespace PixelDash.Tools.Editor.Presentation
{
using System;
using PixelDash.Tools.Presentation;
using UnityEditor;
using UnityEngine;
using UnityEngine.Assertions;
using UObject = UnityEngine.Object;
using Flags = PixelDash.Tools.Presentation.PDCollectionFlags;
/* TODO
* - Draw other fields in property drawer
* - Vertically center foldout arrow
* - Profile difference between Unity, custom and Rotorz
* - Decide what to do when list items themselves throw exceptions
*
* - Attempt to encapsulate list struct in a sane way
* - target generic base class?
* - Non generic dummy base class?
* - #if out the wrapper in release?
* - inherit from list?
*
* - Drag elements onto header (UnityEditor.DragAndDrop? Use primary field, disable when resizing is disabled)
*
* - Ensure entire collection respects indentation
* - Handle expanded gracefully (new elements always collapsed? preserve expanded state during reorder)
* - Ensure right clicking works as expected everywhere (especially AudioClipSettings parent)
*
* - Support insert above/below (add button on each element?)
* - Support duplicate command/button
* - Eat right click on element? (how's this work with property overrides?)
* - BUG: Check bolding when adding elements, modifiying child properties
* - Ensure nested collections work
* - Is it possible to draw textures on top of the text?
*
* - Decide how keyboard naviation should work (move up/down, remove, duplicate, insert above/below)
* - Support multi-selection with buttons
* - Support use from Editors (layout), PropertyDrawers (non-layout), procedural calls(?)
* - Ensure lists inside scroll views are handled well
* - Auto-scroll when dragging at end of Inspector?
* - Determine how Element name is chosen.
*
* - Flags
* - DisplayKeyAsParent
* - DisablePrefabModification
* - DisablePrefabInstanceModification
* - Option to ignore indentation
* - lock items
* (establish usage code before tackling this)
* generalize Flags.SyncWithEnum and State.enumNames to be an array of lock values
* option to allow other items after the locked ones
* separate locking from other flags so other items can use the flags like normal
*/
/// <summary>
/// Draws serialized collections of items in a nicer container than the
/// Unity default. Collections are reorderable by default.
/// </summary>
/// <remarks>
/// Fully supports undo/redo, revert to prefab, item collapse/expand,
/// different sized items, synchronizing items with an enum container, and
/// a series of customizations through <see cref="Flags"/>. Collections are
/// marked with <see cref="PDCollectionAttribute"/>
/// </remarks>
public static class PDCollection
{
public readonly static GUIContent RemoveButtonContent;
public readonly static GUIContent AddButtonContent;
public readonly static float HeaderHeight;
public readonly static float ButtonWidth;
public readonly static float DragHandleWidth;
public readonly static GUIStyle DragHandle;
public readonly static GUIStyle HeaderBackground;
public readonly static GUIStyle ContainerBackground;
public readonly static GUIStyle ItemBackground;
public readonly static GUIStyle DropBackground;
public readonly static GUIStyle ButtonOnLight;
public readonly static GUIStyle ButtonOnDark;
public readonly static GUIStyle Separator;
static PDCollection ()
{
HeaderHeight = 22;
ButtonWidth = 26;
DragHandleWidth = 26;
AddButtonContent = EditorGUIUtility.IconContent("Toolbar Plus");
RemoveButtonContent = EditorGUIUtility.IconContent("Toolbar Minus");
//"Toolbar Plus More" if needed
//Resources
Color lightGray;
Color mediumGray;
Color darkGray;
Color darkestGray;
Color blueGrayTrans;
Texture2D headerBackground;
Texture2D containerBackground;
if ( EditorGUIUtility.isProSkin )
{
lightGray = new Color32(104, 104, 104, 255);
mediumGray = new Color32( 79, 79, 79, 255);
darkGray = new Color32( 47, 47, 47, 255);
darkestGray = new Color32( 10, 10, 10, 255);
blueGrayTrans = new Color32( 70, 70, 73, 200);
headerBackground = TextureFromString("PDT_HeaderBackground" , "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAECAYAAABGM/VAAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADtJREFUeNpi/P//P4OKisp/Bii4c+cOIwtIQE9Pj+HLly9gQRCfBcQACbx69QqmmAEseO/ePQZkABBgAD04FXsmmijSAAAAAElFTkSuQmCC");
containerBackground = TextureFromString("PDT_ContainerBackground", "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAECAYAAABGM/VAAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAD1JREFUeNpi/P//P4OKisp/Bii4c+cOIwtIwMXFheHFixcMEhISYAVMINm3b9+CBUA0CDCiazc0NGQECDAAdH0YelA27kgAAAAASUVORK5CYII=");
}
else
{
lightGray = new Color32(178, 178, 178, 255);
mediumGray = new Color32(121, 121, 121, 255);
darkGray = new Color32(140, 140, 140, 255);
darkestGray = new Color32( 38, 38, 38, 255);
blueGrayTrans = new Color32(200, 200, 206, 200);
headerBackground = TextureFromString("PDT_HeaderBackground" ,"iVBORw0KGgoAAAANSUhEUgAAAAUAAAAECAYAAABGM/VAAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAEFJREFUeNpi/P//P0NxcfF/BgRgZP78+fN/VVVVhpCQEAZjY2OGs2fPNrCApBwdHRkePHgAVwoWnDVrFgMyAAgwAAt4E1dCq1obAAAAAElFTkSuQmCC");
containerBackground = TextureFromString("PDT_ContainerBackground", "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAECAYAAABGM/VAAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADtJREFUeNpi/P//P0NxcfF/Bijo7e1lZCgqKvr/6dOn/5cvXwbTID4TSPb9+/cM8vLyYBoEGLFpBwgwAHGiI8KoD3BZAAAAAElFTkSuQmCC");
}
//Textures
var pixel = new Color[1];
pixel[0] = lightGray;
var lightGrayPixel = TextureFromPixels("PDT_LightGrayPixel", pixel, 1, 1);
pixel[0] = mediumGray;
var mediumGrayPixel = TextureFromPixels("PDT_MediumGrayPixel", pixel, 1, 1);
pixel[0] = darkGray;
var darkGrayPixel = TextureFromPixels("PDT_DarkGrayPixel", pixel, 1, 1);
Texture2D itemBackground;
{
Color shadow = darkestGray;
shadow.a = 90f / 255f;
var pixels = new[] {
shadow, shadow, shadow,
darkestGray, darkestGray, darkestGray,
darkestGray, blueGrayTrans, darkestGray,
darkestGray, darkestGray, darkestGray,
shadow, shadow, shadow,
};
itemBackground = TextureFromPixels("PDT_ItemBackground", pixels, 3, 5);
}
Texture2D dropBackground;
{
var pixels = new[] {
lightGray, lightGray, lightGray,
mediumGray, darkGray, mediumGray,
lightGray, lightGray, lightGray
};
dropBackground = TextureFromPixels("PDT_DropBackground", pixels, 3, 3);
}
//Styles
//Header
HeaderBackground = new GUIStyle();
HeaderBackground.normal.background = headerBackground;
HeaderBackground.border = new RectOffset(2, 2, 2, 1);
HeaderBackground.margin = new RectOffset(0, 0, 0, 10);
HeaderBackground.padding = new RectOffset(2, 2, 2, 1);
HeaderBackground.name = "PDT_HeaderBackground";
ButtonOnDark = new GUIStyle();
ButtonOnDark.active.background = mediumGrayPixel;
ButtonOnDark.alignment = TextAnchor.MiddleCenter;
ButtonOnDark.margin = new RectOffset(2, 0, 0, 0);
ButtonOnDark.name = "PDT_Button_OnDark";
//Container
ContainerBackground = new GUIStyle();
ContainerBackground.normal.background = containerBackground;
ContainerBackground.border = new RectOffset(2, 2, 2, 2);
ContainerBackground.margin = new RectOffset(5, 5, -1, 10);
ContainerBackground.padding = ContainerBackground.border;
ContainerBackground.name = "PDT_ContainerBackground";
//Items
Separator = new GUIStyle();
Separator.normal.background = lightGrayPixel;
Separator.fixedHeight = 1;
Separator.stretchWidth = true;
Separator.name = "PDT_Separator";
ButtonOnLight = new GUIStyle(ButtonOnDark);
ButtonOnLight.active.background = darkGrayPixel;
ButtonOnLight.name = "PDT_Button_OnLight";
DragHandle = new GUIStyle("RL DragHandle");
DragHandle.fixedWidth = 10;
DragHandle.fixedHeight = DragHandle.normal.background.height;
DragHandle.border = new RectOffset();
DragHandle.margin = new RectOffset(0, 2, 0, 0);
DragHandle.name = "PDT_DragHandle";
ItemBackground = new GUIStyle();
ItemBackground.normal.background = itemBackground;
ItemBackground.border = new RectOffset(1, 1, 2, 2);
ItemBackground.margin = new RectOffset(0, 0, 1, 1);
ItemBackground.padding = new RectOffset(0, 0, 2, 2);
ItemBackground.overflow = new RectOffset(
ContainerBackground.padding.left,
ContainerBackground.padding.right,
1 + ItemBackground.margin.top,
1 + ItemBackground.margin.bottom
);
ItemBackground.name = "PDT_ItemBackground";
DropBackground = new GUIStyle();
DropBackground.normal.background = dropBackground;
DropBackground.border = new RectOffset(1, 1, 1, 1);
DropBackground.overflow = new RectOffset(
ContainerBackground.padding.left - 1,
ContainerBackground.padding.right - 1,
ItemBackground.margin.top,
ItemBackground.margin.bottom
);
DropBackground.name = "PDT_DropBackground";
}
private static Texture2D TextureFromString ( string name, string base64String )
{
byte[] imageData = Convert.FromBase64String(base64String);
int offset = 3 + 15;
int width = (imageData[offset] << 8) | imageData[offset + 1];
offset = 3 + 15 + 2 + 2;
int height = (imageData[offset] << 8) | imageData[offset + 1];
var texture = new Texture2D(width, height, TextureFormat.ARGB32, false, true);
texture.LoadImage(imageData);
texture.filterMode = FilterMode.Point;
texture.wrapMode = TextureWrapMode.Repeat;
texture.hideFlags = HideFlags.HideAndDontSave;
texture.name = name;
return texture;
}
private static Texture2D TextureFromPixels ( string name, Color[] pixels, int width, int height )
{
//TODO: This doesn't work in DLLs
Assert.IsTrue(width*height == pixels.Length, "TextureFromPixels: Provided pixel count and dimensions do not match");
if ( width*height != pixels.Length )
{
//TODO: Allow this assembly to depend on logging?
//TODO: Recombine logging and runtime assemblies?
//TODO: Does this assembly rely on the runtime assembly?
Debug.LogErrorFormat("Pixel Dash Tools Internal Error - TextureFromPixels: Provided pixel count and dimensions do not match." +
"\nTexture: '{0}', Dimensions: {1}x{2}, Pixel Count: {3}.",
name, width, height, pixels.Length
);
}
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false, false);
texture.SetPixels(0, 0, width, height, pixels);
texture.Apply();
texture.filterMode = FilterMode.Point;
texture.wrapMode = TextureWrapMode.Repeat;
texture.hideFlags = HideFlags.HideAndDontSave;
texture.name = name;
return texture;
}
public class State
{
//TODO: Move some of this to a cache?
public Rect position;
public string error;
public int controlID;
public float errorHeight;
public float headerHeight;
public float collectionHeight;
public Flags flags;
public string keyFieldName;
public string[] fixedKeyValues;
public Rect draggedItemBaseRect;
public int draggedItemIndex = -1;
public float dragInitialMouseY;
public int dropIndex = -1;
public int keyboardControlAtDragStart;
public bool IsDragging { get { return draggedItemIndex != -1; } }
public bool HasError { get { return !string.IsNullOrEmpty(error); } }
}
public static void BeginDrag ( State s, int itemIndex, Rect itemRect, float initialMouseY )
{
/* TODO: Control IDs are order based so changing the draw order
* causes focus to jump to an adjacent item. Is there a way to
* avoid this? Or is there a way to only clear focus if it's
* somewhere in this list?
*
* If we stop doing this, try using
* EditorGUIUtility.editingTextField = false
* to remove cursor from text boxes.
*/
s.keyboardControlAtDragStart = GUIUtility.keyboardControl;
GUIUtility.keyboardControl = s.controlID;
GUIUtility.hotControl = s.controlID;
//EditorGUI.FocusTextInControl(null)?
s.draggedItemBaseRect = itemRect;
s.draggedItemIndex = itemIndex;
s.dragInitialMouseY = initialMouseY;
s.dropIndex = itemIndex;
}
public static void EndDrag ( State s )
{
if ( s.dropIndex == s.draggedItemIndex )
GUIUtility.keyboardControl = s.keyboardControlAtDragStart;
GUIUtility.hotControl = 0;
s.draggedItemBaseRect = new Rect();
s.draggedItemIndex = -1;
s.dragInitialMouseY = 0;
s.dropIndex = -1;
}
private struct ErrorModule
{
public static float GetHeight ( State s, SerializedProperty arrayProp, GUIContent label )
{
if ( !s.HasError )
{
if ( arrayProp == null )
{
s.error = typeof(PDCollection).Name + " could not find the target collection. Ensure the field exists and is serializable.";
}
else if ( !arrayProp.isArray )
{
s.error = typeof(PDCollection).Name + " cannot be used with non-array fields.";
}
}
if ( s.HasError )
{
s.errorHeight = EditorStyles.helpBox.CalcHeight(PDContent.Temp(s.error), EditorGUIUtility.currentViewWidth);
s.errorHeight = Mathf.Max(s.errorHeight, 32);
}
else
{
s.errorHeight = 0;
}
return s.errorHeight;
}
public static void OnGUI ( State s, SerializedProperty arrayProp, GUIContent label )
{
if ( !s.HasError ) { return; }
Rect line = s.position;
line.height = s.errorHeight;
s.position.y += line.height;
EditorGUI.HelpBox(line, s.error, MessageType.Error);
}
}
public static class HeaderModule
{
public static float GetHeight ( State s, SerializedProperty arrayProp, GUIContent label )
{
s.headerHeight = HeaderHeight;
return s.headerHeight;
}
public static void OnGUI ( State s, SerializedProperty arrayProp, GUIContent label )
{
//NOTE: Size field blows up label (it's an internally reused GUIContent).
var foldoutContent = PDContent.Temp(label);
Rect usable;
//Background
{
Rect background = s.position;
background.height = s.headerHeight;
s.position.y += background.height;
usable = HeaderBackground.padding.Remove(background);
if ( Event.current.type == EventType.Repaint )
HeaderBackground.Draw(background, false, false, false, false);
}
if ( !s.HasError )
{
if ( arrayProp.isExpanded )
{
//Add button
bool disableResize = (s.flags & Flags.DisableResize) != 0;
if ( !disableResize )
{
Rect addButton = usable;
addButton.xMin = addButton.xMax - ButtonWidth;
usable.xMax -= addButton.width;
if ( GUI.Button(addButton, AddButtonContent, ButtonOnDark) )
++arrayProp.arraySize;
}
usable.xMax -= ButtonOnDark.margin.left;
//Size
bool hideSize = (s.flags & Flags.HideSize) != 0;
if ( !hideSize )
{
Rect sizeField = usable;
sizeField.xMin = sizeField.xMax - EditorGUIUtility.fieldWidth - ButtonWidth;
usable.xMax = sizeField.xMin;
Vector2 center = sizeField.center;
sizeField.height = 16;
sizeField.center = center;
using ( new PDEditorGUI.IndentScope(0) )
using ( new PDEditorGUI.LabelWidthScope(ButtonWidth) )
using ( new PDGUI.DisabledScope(disableResize) )
{
arrayProp.arraySize = EditorGUI.DelayedIntField(sizeField, "Size", arrayProp.arraySize);
}
}
}
}
if ( arrayProp != null )
{
//Foldout
using ( new EditorGUI.PropertyScope(usable, foldoutContent, arrayProp) )
{
//Label
Rect foldout = usable;
usable.size = Vector2.zero;
arrayProp.isExpanded = PDEditorGUI.Foldout(foldout, arrayProp.isExpanded, foldoutContent);
}
}
else
{
//GUI.Label(usable, foldoutContent);
EditorGUI.PrefixLabel(usable, new GUIContent(foldoutContent.ToString()));
}
}
}
public static class ArrayModule
{
public static float GetHeight ( State s, SerializedProperty arrayProp, GUIContent label )
{
s.collectionHeight = 0;
float errorHeight = ErrorModule.GetHeight(s, arrayProp, label);
if ( arrayProp == null || arrayProp.isExpanded )
{
s.collectionHeight += ContainerBackground.margin.top;
s.collectionHeight += ContainerBackground.padding.top;
s.collectionHeight += errorHeight;
if ( !s.HasError )
{
float marginSize = Mathf.Max(ItemBackground.margin.top, ItemBackground.margin.bottom);
float previousMargin = 0;
for ( int i = 0; i < arrayProp.arraySize; ++i )
{
var item = arrayProp.GetArrayElementAtIndex(i);
s.collectionHeight += previousMargin;
s.collectionHeight += ItemBackground.padding.top;
s.collectionHeight += EditorGUI.GetPropertyHeight(item);
s.collectionHeight += ItemBackground.padding.bottom;
previousMargin = marginSize;
}
}
s.collectionHeight += ContainerBackground.padding.bottom;
}
//TODO: Is there a cleaner way to do this? No magic values!
if ( !s.HasError && arrayProp.arraySize == 0 )
s.collectionHeight += 2;
s.collectionHeight += ContainerBackground.margin.bottom;
return s.collectionHeight;
}
public static void OnGUI ( State s, SerializedProperty arrayProp, GUIContent label )
{
if ( arrayProp == null || arrayProp.isExpanded )
{
Event e = Event.current;
EventType eType = e.GetTypeForControl(s.controlID);
Rect usable = s.position;
usable.height = s.collectionHeight;
s.position.y += usable.height;
//Container background
Rect containerRect = usable;
containerRect.yMin += ContainerBackground.margin.top;
containerRect.yMax -= ContainerBackground.margin.bottom;
usable = ContainerBackground.padding.Remove(containerRect);
if ( eType == EventType.Repaint )
ContainerBackground.Draw(containerRect, false, false, false, false);
//Errors
if ( s.HasError )
{
s.position = usable;
ErrorModule.OnGUI(s, arrayProp, label);
return;
}
//Handle non-item events
//NOTE: We eat all input while dragging
if ( s.IsDragging )
{
switch ( eType )
{
//Allowed events
case EventType.Layout:
case EventType.Repaint:
case EventType.ScrollWheel:
break;
case EventType.MouseDrag:
{
//NOTE: The drag needs to be handled by each item
e.Use();
break;
}
case EventType.MouseUp:
{
if ( e.button == 0 )
{
//Swap
arrayProp.MoveArrayElement(s.draggedItemIndex, s.dropIndex);
arrayProp.serializedObject.ApplyModifiedProperties();
arrayProp.serializedObject.Update();
GUI.changed = true;
EndDrag(s);
}
e.Use();
return;
}
case EventType.KeyDown:
{
if ( e.keyCode == KeyCode.Escape )
EndDrag(s);
e.Use();
return;
}
default:
{
e.Use();
return;
}
}
}
//TODO: Needs to handle structs with an enum 'primary key'
//TODO: Don't allow modification of the enum key!!
//TODO: Validate the primary key is actually an enum (hard when there are no items!)
//TODO: How does this work with multi selection? (Process each item individually?)
//TODO: Sorting is going to be tricky on prefab instances.
//TODO: Optimize - do only once
//TODO: Object pinging doesn't work. Unity bug?
//Enum specialization
bool synWithEnum = (s.flags & Flags.SyncWithEnum) != 0;
if ( synWithEnum )
{
/* TODO: enumNames will log an error if the array type
* is not an enum. There appears to be no way to check
* this in advance or even handle the error.
*
* Probably need to do something ugly like use
* reflection here or in the attribute.
*
* Alternatively, could insert one element, get it,
* check the type, then update the property without
* saving the change.
*/
/* NOTE: Unity sorts the enums values by underlying
* value, not by the order they appear in source code.
*/
//TODO: arrayProp.displayName will be wrong when using a wrapper struct
//Remove invalid enum values
for ( int i = 0; i < arrayProp.arraySize; ++i )
{
var item = arrayProp.GetArrayElementAtIndex(i);
var enumProp = item.FindPropertyRelative(s.keyFieldName);
if ( enumProp.enumValueIndex == -1 )
{
UObject target = arrayProp.serializedObject.targetObject;
Debug.LogFormat(target,
"Removing invalid enum value '{0}'." +
"\nObject: '{1}', Field: '{2}'",
enumProp.intValue, target.name, arrayProp.displayName
);
arrayProp.DeleteArrayElementAtIndex(i);
GUI.changed = true;
}
}
//Insert and sort valid enum values
for ( int i = 0; i < s.fixedKeyValues.Length; ++i )
{
int matchingItemIndex = -1;
for ( int j = i; j < arrayProp.arraySize; ++j )
{
var item = arrayProp.GetArrayElementAtIndex(i);
var enumProp = item.FindPropertyRelative(s.keyFieldName);
if ( enumProp.enumValueIndex == i )
{
matchingItemIndex = j;
break;
}
}
if ( matchingItemIndex == -1 )
{
UObject target = arrayProp.serializedObject.targetObject;
Debug.LogFormat(target,
"Inserting missing enum value '{0}'." +
"\nObject: '{1}', Field: '{2}'",
s.fixedKeyValues[i], target.name, arrayProp.displayName
);
//Insert
arrayProp.InsertArrayElementAtIndex(i);
var newItem = arrayProp.GetArrayElementAtIndex(i);
var enumProp = newItem.FindPropertyRelative(s.keyFieldName);
enumProp.enumValueIndex = i;
GUI.changed = true;
}
else if ( matchingItemIndex != i )
{
//Sort
arrayProp.MoveArrayElement(matchingItemIndex, i);
GUI.changed = true;
}
}
}
//TODO: Do we need to apply and update when making array changes? We only do it in one place at the moment.
//TODO: Ensure changes are always saved if closing the editor
//Items
float margin = Mathf.Max(ItemBackground.margin.top, ItemBackground.margin.bottom);
Rect currentYRect = usable;
currentYRect.height = 0;
currentYRect.y -= margin;
currentYRect.xMin += ItemBackground.margin.left;
currentYRect.xMin += ItemBackground.padding.left;
currentYRect.xMax -= ItemBackground.padding.right;
currentYRect.xMax -= ItemBackground.margin.right;
ItemPosition itemP = new ItemPosition(-1, currentYRect);
Rect draggedItemFinalRect = s.draggedItemBaseRect;
//draggedItemFinalRect.position += new Vector2(-5, 0);
float yOffset = e.mousePosition.y - s.dragInitialMouseY;
float yMin = usable.yMin;
float yMax = usable.yMax - draggedItemFinalRect.height;
draggedItemFinalRect.y = Mathf.Clamp(draggedItemFinalRect.y + yOffset, yMin, yMax);
//NOTE: Cached because it can change in the middle of the loop
int dropIndex = s.dropIndex;
Rect dropRect = new Rect();
for ( int i = 0; i < arrayProp.arraySize; ++i )
{
currentYRect.y = itemP.rect.yMax + margin;
itemP.actualIndex = i;
itemP.apparentIndex += 1;
//Draw dragged item last (so it draws on top)
if ( s.IsDragging && i >= s.draggedItemIndex )
{
if ( i < arrayProp.arraySize - 1 )
{
itemP.actualIndex = i + 1;
}
else
{
itemP.actualIndex = s.draggedItemIndex;
itemP.apparentIndex = dropIndex;
}
}
//Skip drop location
if ( itemP.apparentIndex == dropIndex )
{
//TODO: There's probably a more intuitive way to handle this. Maybe combine this check with the one above?
if ( itemP.actualIndex != s.draggedItemIndex || itemP.apparentIndex == arrayProp.arraySize-1 )
{
dropRect = currentYRect;
dropRect.height = s.draggedItemBaseRect.height;
}
if ( itemP.actualIndex != s.draggedItemIndex )
{
itemP.apparentIndex += 1;
currentYRect.y += dropRect.height + margin;
}
}
//Draw!
var item = arrayProp.GetArrayElementAtIndex(itemP.actualIndex);
itemP.rect = currentYRect;
itemP.rect.height += ItemBackground.padding.top;
itemP.rect.height += EditorGUI.GetPropertyHeight(item);
itemP.rect.height += ItemBackground.padding.bottom;
if ( itemP.actualIndex == s.draggedItemIndex )
{
DrawDraggedItem(s, e, eType, arrayProp, itemP, item, draggedItemFinalRect, dropRect);
}
else
{
DrawItem(s, e, eType, arrayProp, itemP, item, draggedItemFinalRect);
}
}
}
}
private static void DrawDraggedItem ( State s, Event e, EventType eType, SerializedProperty arrayProp,
ItemPosition itemP, SerializedProperty item,
Rect draggedItemFinalRect, Rect dropRect )
{
//Always keep the drag cursur up while dragging
Resolution res = Screen.currentResolution;
Rect dragCursorRect = GUIUtility.ScreenToGUIRect(new Rect(0, 0, res.width, res.height));
EditorGUIUtility.AddCursorRect(dragCursorRect, MouseCursor.Pan);
if ( eType == EventType.Repaint )
{
//Drop location background
DropBackground.Draw(dropRect, dropRect.Contains(e.mousePosition), false, false, false);
//Dragged row background
ItemBackground.Draw(draggedItemFinalRect, true, true, false, false);
}
itemP.rect = draggedItemFinalRect;
DrawItem(s, e, eType, arrayProp, itemP, item, draggedItemFinalRect);
}
private static void DrawItem ( State s, Event e, EventType eType, SerializedProperty arrayProp,
ItemPosition itemP, SerializedProperty item,
Rect draggedItemFinalRect )
{
bool isDraggedItem = itemP.actualIndex == s.draggedItemIndex;
bool disableReordering = (s.flags & Flags.DisableReordering) != 0;
Rect usable = itemP.rect;
usable.xMin += ItemBackground.padding.left;
usable.xMax -= ItemBackground.padding.right;
//Drag handle
Rect dragHandleRect = new Rect();
if ( !disableReordering )
{
dragHandleRect = usable;
dragHandleRect.width = DragHandleWidth;
usable.xMin += dragHandleRect.width;
EditorGUIUtility.AddCursorRect(dragHandleRect, MouseCursor.Pan);
}
else
{
usable.xMin += DragHandle.margin.right;
}
//Event handling
if ( eType == EventType.MouseDown )
{
Assert.IsTrue(!s.IsDragging);
if ( e.button == 0 && dragHandleRect.Contains(e.mousePosition) )
{
BeginDrag(s, itemP.actualIndex, itemP.rect, e.mousePosition.y);
e.Use();
}
}
else if ( eType == EventType.MouseDrag )
{
//Update drop index
if ( s.IsDragging && !isDraggedItem )
{
float centerY = itemP.rect.center.y;
if ( (draggedItemFinalRect.yMin < centerY && itemP.apparentIndex < s.dropIndex)
|| (draggedItemFinalRect.yMax > centerY && itemP.apparentIndex > s.dropIndex) )
{
s.dropIndex = itemP.apparentIndex;
}
}
}
else if ( eType == EventType.Repaint )
{
bool isLastItem = itemP.apparentIndex == arrayProp.arraySize - 1;
if ( !isDraggedItem && !isLastItem )
{
//Separator
Rect separatorRect = itemP.rect;
separatorRect.yMin = separatorRect.yMax;
separatorRect.height = 1;
Separator.Draw(separatorRect, separatorRect.Contains(e.mousePosition), false, false, false);
}
if ( !disableReordering )
{
//Drag handle texture
var dragTextureSize = new Vector2(DragHandle.fixedWidth, DragHandle.fixedHeight);
Rect dragTextureRect = new Rect(dragHandleRect.center - .5f*dragTextureSize, dragTextureSize);
bool hovered = dragHandleRect.Contains(e.mousePosition) || isDraggedItem;
DragHandle.Draw(dragTextureRect, hovered, isDraggedItem, false, false);
}
}
//TODO: These are drawing on top of the dragged item background. Why?
//Remove button
bool disableResize = (s.flags & Flags.DisableResize) != 0;
if ( !disableResize )
{
Rect removeButtonRect = usable;
removeButtonRect.xMin = removeButtonRect.xMax - ButtonWidth;
usable.xMax -= removeButtonRect.width;
//HACK: Temporary hack for the below TODO
if ( eType == EventType.MouseDown )
if ( e.button != 0 && removeButtonRect.Contains(e.mousePosition) )
e.Use();
//TODO: This accepts any button :(
if ( GUI.Button(removeButtonRect, RemoveButtonContent, ButtonOnLight) )
{
arrayProp.DeleteArrayElementAtIndex(itemP.actualIndex);
GUI.changed = true;
return;
}
}
usable.xMax -= ButtonOnLight.margin.left;
//Element
{
Rect elementRect = usable;
elementRect.yMin += ItemBackground.padding.top;
elementRect.yMax -= ItemBackground.padding.bottom;
//NOTE: Undo the built-in offset for the parent foldout
if ( item.hasVisibleChildren && EditorGUIUtility.hierarchyMode )
//TODO: This is not robust enough
//NOTE: Other property drawers may draw everything on a single line (e.g. the built-in Vector3 drawer).
if ( item.propertyType == SerializedPropertyType.Generic )
elementRect.xMin += PDEditorGUI.FoldoutHierarchyModeCorrection;
EditorGUI.PropertyField(elementRect, item, true);
}
}
}
//TODO: Remove?
private struct ItemPosition
{
public int actualIndex;
public int apparentIndex;
public Rect rect;
public ItemPosition ( int actualIndex, Rect rect = new Rect() )
: this(actualIndex, actualIndex, rect) { }
public ItemPosition ( int actualIndex, int apparentIndex, Rect rect = new Rect() )
{
this.actualIndex = actualIndex;
this.apparentIndex = apparentIndex;
this.rect = rect;
}
}
}
}
namespace PixelDash.Tools.Presentation
{
using System;
using UnityEngine;
using Flags = PDCollectionFlags;
/// <summary>
/// Flags used to modify the appearance of a PDCollection.
/// </summary>
[Flags]
public enum PDCollectionFlags
{
#pragma warning disable 1591
None = 1 << 0,
HideSize = 1 << 1,
DisableResize = 1 << 2,
DisableReordering = 1 << 3,
SyncWithEnum = (1 << 4) + HideSize + DisableResize + DisableReordering
#pragma warning restore 1591
}
//TODO: Can this be applied to the base class?
/// <summary>
/// Marks a collection to be drawn using PDCollection.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public class PDCollectionAttribute : PropertyAttribute
{
//TODO: DOn't expose these
/// <summary>
/// The name of the collection field the PDCollection will target. If
/// null, the PDCollection will use the first top-level collection
/// field it is able to find.
/// </summary>
public string collectionName;
/// <summary>
/// The name of a field that will be considered the 'primary key'. The
/// primary key is used to allow drag and drop onto list elements or to
/// synchronize the collection with the values of a particular enum. If
/// null, the PDCollection will use the first top-level field it is
/// able to find.
/// </summary>
public string keyFieldName;
public string[] fixedKeyValues;
/// <summary>
/// Flags that will be applied to the PDCollection.
/// </summary>
public Flags flags;
/// <inheritdoc />
public PDCollectionAttribute ( Flags flags = Flags.None, string collectionName = null, string keyFieldName = null/*, object[] fixedKeyValues = null*/ )
{
this.collectionName = collectionName;
this.keyFieldName = keyFieldName;
this.flags = flags;
//this.fixedKeyValues = fixedKeyValues;
}
}
[AttributeUsage(AttributeTargets.Field)]
public class PDCollectionItemsAttribute : PropertyAttribute { }
[AttributeUsage(AttributeTargets.Field)]
public class PDCollectionKeyFieldAttribute : ReadOnlyAttribute { }
}
namespace PixelDash.Tools.Editor.Presentation
{
using System;
using System.Reflection;
using PixelDash.Tools.Presentation;
using UnityEditor;
using UnityEngine;
using UnityEngine.Assertions;
using HeaderModule = PDCollection.HeaderModule;
using ArrayModule = PDCollection.ArrayModule;
using State = PDCollection.State;
using Flags = Tools.Presentation.PDCollectionFlags;
/// <summary>
/// Draws a collection decorated with <see cref="PDCollectionAttribute"/>
/// using <see cref="PDCollection"/>.
/// </summary>
[CustomPropertyDrawer(typeof(PDCollectionAttribute))]
public sealed class PDCollectionDrawer : PropertyDrawer
{
//TODO: If objects before the collection change, this miiiiight not work?
private int controlID = GUIUtility.GetControlID("PDCollection".GetHashCode(), FocusType.Passive);
private bool triedToFindCollection;
private bool triedToFindKeyField;
/// <summary>
/// The usual.
/// </summary>
public override float GetPropertyHeight ( SerializedProperty property, GUIContent label )
{
var attribute = (PDCollectionAttribute) base.attribute;
State state = (State) GUIUtility.GetStateObject(typeof(State), controlID);
state.controlID = controlID;
state.flags = attribute.flags;
//TODO: Probably won't work on nested PDCollections
SerializedProperty arrayProp = null;
if ( attribute.collectionName == null && !triedToFindCollection )
{
triedToFindCollection = true;
attribute.collectionName = GetChildPropertyWithAttribute<PDCollectionItemsAttribute>(property);
if ( attribute.collectionName == null )
{
state.error = string.Format("{0} was specified but no {1} was found on a child field.",
typeof(PDCollectionAttribute).Name,
typeof(PDCollectionItemsAttribute).Name
);
}
else
{
arrayProp = property.FindPropertyRelative(attribute.collectionName);
if ( arrayProp == null )
{
state.error = string.Format("{0} was specified but the field '{1}' appears not to be serialized.",
typeof(PDCollectionItemsAttribute).Name,
attribute.collectionName
);
attribute.collectionName = null;
}
else if ( !arrayProp.isArray )
{
state.error = string.Format("{0} was specified but the field '{1}' is not a collection.",
typeof(PDCollectionItemsAttribute).Name,
attribute.collectionName
);
attribute.collectionName = null;
}
}
}
arrayProp = property.FindPropertyRelative(attribute.collectionName);
if ( attribute.keyFieldName == null && !triedToFindKeyField )
{
triedToFindKeyField = true;
if ( arrayProp != null )
{
attribute.keyFieldName = GetChildPropertyWithAttribute<PDCollectionKeyFieldAttribute>(arrayProp);
bool syncWithEnum = (state.flags & Flags.SyncWithEnum) != 0;
if ( syncWithEnum )
{
if ( attribute.keyFieldName == null )
{
state.error = string.Format("{0} was specified but no {1} was found on a child field.",
Flags.SyncWithEnum,
typeof(PDCollectionKeyFieldAttribute).Name
);
}
else
{
bool resetProp = false;
if ( arrayProp.arraySize == 0 )
{
resetProp = true;
//TODO: Does this act differently than Inserting?
++arrayProp.arraySize;
}
var item = arrayProp.GetArrayElementAtIndex(0);
var keyFieldProp = item.FindPropertyRelative(attribute.keyFieldName);
if ( keyFieldProp == null )
{
state.error = string.Format("{0} was specified but the key field '{1}' appears not to be serialized.",
Flags.SyncWithEnum,
attribute.keyFieldName
);
attribute.keyFieldName = null;
}
else if ( keyFieldProp.propertyType != SerializedPropertyType.Enum )
{
state.error = string.Format("{0} was specified but the key field '{1}' is not an enum.",
Flags.SyncWithEnum,
attribute.keyFieldName
);
attribute.keyFieldName = null;
}
else
{
attribute.fixedKeyValues = keyFieldProp.enumNames;
}
if ( resetProp )
arrayProp.serializedObject.Update();
}
}
}
}
state.keyFieldName = attribute.keyFieldName;
state.fixedKeyValues = attribute.fixedKeyValues;
if ( arrayProp != null )
property.isExpanded = arrayProp.isExpanded;
float height = 0;
height += HeaderModule.GetHeight(state, arrayProp, label);
height += ArrayModule.GetHeight(state, arrayProp, label);
return height;
}
//TODO: Test with namespaces
//TODO: This may find types in the wrong assembly
private static string GetChildPropertyWithAttribute<T> ( SerializedProperty rootProp ) where T : Attribute
{
Type targetType = null;
Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
for ( int i = 0; i < assemblies.Length; ++i )
{
//TODO: this.fieldInfo?!
targetType = assemblies[i].GetType(rootProp.type);
if ( targetType != null )
break;
}
if ( targetType == null ) { return null; }
FieldInfo[] fields = targetType.GetFields(BindingFlags.Instance|BindingFlags.Public|BindingFlags.NonPublic);
for ( int i = 0; i < fields.Length; ++i )
{
T[] attributes = (T[]) fields[i].GetCustomAttributes(typeof(T), false);
Assert.IsTrue(attributes.Length <= 1);
if ( attributes.Length > 0 )
return fields[i].Name;
}
return null;
}
/// <summary>
/// The usual.
/// </summary>
public override void OnGUI ( Rect position, SerializedProperty property, GUIContent label )
{
var attribute = (PDCollectionAttribute) base.attribute;
State state = (State) GUIUtility.GetStateObject(typeof(State), controlID);
if ( state.controlID != controlID )
{
Debug.LogError(typeof(PDCollectionDrawer) + " - could not find the correct state object. GetPropertyHeight probably wasn't called.");
return;
}
state.position = position;
var arrayProp = property.FindPropertyRelative(attribute.collectionName);
HeaderModule.OnGUI(state, arrayProp, label);
ArrayModule.OnGUI(state, arrayProp, label);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment