Generic drawers

As we saw in the drawer types section, all drawers in Odin are always strongly typed by nature. If a drawer is supposed to draw the type 'Foo', then it will specify exactly the type 'Foo' as the generic argument that it passes to its base drawer type. This, however, isn't always good enough. Sometimes we need a drawer to draw many different types. For this, we use generic drawers.

What are generics? This is a very important question, and to understand this section, you must understand generics in C#. You can find a lot of tutorials on the internet that explain what generics are, and how they work. Microsoft's official documentation has a very good overview here.

In particular, it is important to understand what a type parameter is, and what type parameter constraints are.

A basic generic drawer

In the below example, the 'T' in the base drawer OdinValueDrawer<T> is Foo, and so the class FooDrawer will only ever draw the exact type 'Foo'. If we have a type 'Bar' which derives from Foo, the below drawer will never draw a value of type Bar.

[OdinDrawer]
public class FooDrawer : OdinValueDrawer<Foo>
{
}

This is all well and good when we know exactly what type we want to draw - but what if we want a drawer to drawer all sorts of different types? What if we want to draw all types that inherit from Foo? A savvy reader might have figured this one out already! The answer is, that instead of passing a specific type as the 'T' argument, we make our drawer type generic, and then we pass a generic type parameter as the type we want to draw.

[OdinDrawer]
public class FooDrawer<T> : OdinValueDrawer<T>
    where T : Foo
{
}

So what does that mean, exactly? When Odin scans this drawer and is deciding where it should apply, what it sees is essentially, "this drawer draws all values 'T' where T is Foo or is derived from Foo". Odin has a built-in generic parameter parser, and understands all generic constraints that C# allows you to use - so whenever you put constraints on a generic drawer, those constraints will define what it does and does not draw. If you do not put any constraints on T, it will apply on all possible variations of T. That is, it will apply on all types, everywhere.

Whenever Odin wants to draw a value that derives from Foo, it will see that the type fits the constraints of FooDrawer, and it will instantiate a strongly typed variant of FooDrawer for that type. For example, if Odin wants to draw a Bar value, it will create a FooDrawer<Bar> instance. If Odin wants to draw a Foo value, it will create a FooDrawer<Foo> instance.

Okay, so those are the basics of generic drawers. Why is this useful? Well, given that drawers are strongly typed, and defined using generic constraints, it means that there are certain guarantees that can be made at compile time. The C# compiler understands generics, and so it knows a lot about what this drawer draws, and what you can do with it. Let's examine a different example case to see why this is useful.

Suppose we've been using Odin for a bit, and we use a lot of Odin-serialized lists. Odin-serialized values can be null, and so every time we add a new component that has Odin-serialized lists, they will start as null, and we have to manually click them, and create a new list instance. We're really tired of constantly having to do this, so we're going to make a drawer that automatically does it for us - every time it sees a list that is null, it creates and sets a new instance of it.

So let's think about this - we want this drawer to apply on all lists. That's pretty simple - all lists implement the IList interface, so we'll just add a constraint for that, and we'll be drawing all lists, as well as everything else that can act like a list!

Don't worry if you don't understand the contents of DrawPropertyLayout in the drawer code beneath - we'll get into how Odin's properties and value entries work later.

[OdinDrawer]
[DrawerPriority(10, 0, 0)] // Give it a super priority, as we want to fix the values before anything is drawn
public class ListInitializer<T> : OdinValueDrawer<T>
    where T : IList
{
    protected override void DrawPropertyLayout(IPropertyValueEntry<T> entry, GUIContent label)
    {
        // Check all values for null, and if any are null, create an instance
        // Only do this in repaint; as a rule, only change reference type values in repaint
        if (Event.current.type == EventType.Repaint)
            for (int i = 0; i < entry.ValueCount; i++)
                if (entry.Values[i] == null)
                    entry.Values[i] = new T();

        // Call the next drawer in line, and let the lists be drawn normally
        this.CallNextDrawer(entry, label);
    }
}

Looks good, right? Wrong! If you try to declare that drawer, you will get a compiler error at "new T()". Why? Well, the compiler doesn't know whether it's actually possible for us to create a new instance of T. The C# compiler is paranoid! Whatever we ask it to do, it needs to know that it's a safe, legal thing to do, or it will simply refuse to compile the code.

There are a lot of types that implement the IList interface, and it's possible that not all of them have a convenient public constructor that takes no parameters, so we can make a new instance. After all, if there isn't such a constructor, we can't actually make a new instance without passing in some parameters. If there is a constructor, but it's private or protected, we can't access the constructor at all!

So, since the compiler doesn't know whether T has a public parameterless constructor or not, it doesn't let us create a new instance of T using new(). Luckily, if we look through the list of possible generic constraints, we can see that there's a 'new()' constraint that requires that the generic type parameter has a public, parameterless constructor. When we specify that constraint, the compiler can safely assume that there will be such a constructor, and it lets us call new()!

So our drawer declaration ends up looking like this:

[OdinDrawer]
[DrawerPriority(10, 0, 0)] // Give it a super priority, as we want to fix the values before anything is drawn
public class ListInitializer<T> : OdinValueDrawer<T>
    where T : IList, new()
{
    protected override void DrawPropertyLayout(IPropertyValueEntry<T> entry, GUIContent label)
    {
        // Check all values for null, and if any are null, create an instance
        // Only do this in repaint; as a rule, only change reference type values in repaint
        if (Event.current.type == EventType.Repaint)
            for (int i = 0; i < entry.ValueCount; i++)
                if (entry.Values[i] == null)
                    entry.Values[i] = new T();

        // Call the next drawer in line, and let the lists be drawn normally
        this.CallNextDrawer(entry, label);
    }
}

As we can see, it can be very convenient to have strongly typed and specified drawers. Since the compiler itself knows what the drawer is allowed to draw, we can safely use the types that we draw. If we want to, we can use members of the IList interface above to check the length of the list - whether it's a list or an array or something entirely third! - just because we know that whatever T might be, it definitely implements IList, and therefore we can safely access everything that the IList interface specifies when working with instances of T.

This, however, isn't always enough. Suppose we want to know, in a strongly typed fashion, what the element type of the list is? Suppose we also want to specify some generic parameter constraints for the element type of the list?

Generic parameter inference

Let's examine another example. Continuing in the spirit of the former example with the ListInitializer drawer, suppose we also want to add a certain number of elements, say five, to all the new list instances we're creating. To do this, we need to know what the element type is. For starters, there exists a strongly typed version of IList, IList<T>, that specifies the element type of the list. However, we can immediately see the problem if we try declaring a drawer that uses IList<T>.

public class ListInitializer<T> : OdinValueDrawer<T>
    where T : IList<T>, new()

What are we saying here? We're saying, "this drawer draws all values T, where T is a list that contains element of type T". So only lists that contains lists that contain lists than contain lists (...) will be drawn, since T as the element type of the list must itself obey the constraint of being a list with the element T. This is not a useful constraint! What we need is a second generic type parameter, to separate the idea of the type of the list from the idea of the type of the elements in the list.

public class ListInitializer<TList, TElement> : OdinValueDrawer<TList>
    where TList : IList<TElement>, new()

Odin actually understands this drawer declaration and its constraints. Now, given, for example, the type List<float> which implements IList<float>, Odin will create a ListInitializer<List<float>, float> instance to draw it. Odin does this by inferring that if the TList argument is List<float>, then given the constraint that TList must implement IList<TElement>, then logically, the TElement type parameter must in this case be float.

We can go on and add further constraints on TElement, should we wish to.

public class ListInitializer<TList, TElement> : OdinValueDrawer<TList>
    where TList : IList<TElement>, new()
    where TElement : new()

With that, we have all the constraints necessary to create the full drawer that creates new lists and adds five elements to them:

[OdinDrawer]
[DrawerPriority(10, 0, 0)]
public class ListInitializer<TList, TElement> : OdinValueDrawer<TList>
    where TList : IList<TElement>, new()
    where TElement : new()
{
    protected override void DrawPropertyLayout(IPropertyValueEntry<TList> entry, GUIContent label)
    {
        // Note how the compiler, knowing that TList implements IList<TElement>,
        //   lets us use the regular collection initialization syntax.
        if (Event.current.type == EventType.Repaint)
            for (int i = 0; i < entry.ValueCount; i++)
                if (entry.Values[i] == null)
                    entry.Values[i] = new TList() {
                        new TElement(),
                        new TElement(),
                        new TElement(),
                        new TElement(),
                        new TElement()
                    };

        this.CallNextDrawer(entry, label);
    }
}

This allows us to create some truly crazy constraints. For example, if we only wanted to draw lists of lists with elements that are nullable structs, this is entirely doable - any set of constraints you can legally specify in C# is valid. The system is very powerful.

public class CrazyDrawer<TList1, TList2, TStruct> : OdinValueDrawer<TList1>
    where TList1 : IList<TList2>
    where TList2 : IList<TStruct?>
    where TStruct : struct

Note that there are certain limitations to what Odin can infer. It has to be possible for Odin to infer, given the constraints on the drawn type, what the types of all the other generic type parameters of the drawer are. If you cannot yourself draw a causal chain of logic from the drawn type argument to all other type parameters of the drawer, then it's likely that Odin can't either, and your drawer will never apply anywhere.

Further filtering drawn types (when constraints aren't enough)

As we can see, specifying the domain of a drawer using generic constraints is extremely versatile. However, not everything can be specified as a generic constraint! There are many, many situations where generic constraints aren't quite enough to narrow your drawer down to only the exact types you want to draw.

For example, you cannot specify 'Delegate' as a generic type constraint, so if you want to only draw delegates, you're out of luck if you're only using generic constraints. Nor can you specify entirely custom logic such as "All types that contain the string 'DrawMe' in their names" as a generic constraint. For cases like this, you can specify as much as possible using generic constraints, and then override the CanDrawTypeFilter method in your drawer to implement the remaining constraint logic.

// Draw all delegates
[OdinDrawer]
public class DelegateDrawer<T> : OdinValueDrawer<T>
{
    public override bool CanDrawTypeFilter(Type type)
    {
        return typeof(Delegate).IsAssignableFrom(type);
    }
}

// Draw all classes (not structs) with "DrawMe" in their names
[OdinDrawer]
public class DrawMeDrawer<T> : OdinValueDrawer<T>
    where T : class
{
    public override bool CanDrawTypeFilter(Type type)
    {
        return type.Name.Contains("DrawMe");
    }
}

Sadly, doing this, we lose the convenience of the compiler knowing exactly what the type can do by its constraints, and therefore, there are some things we can no longer get away with in a strongly typed fashion. All of these things can still be done, of course - only now you have to use casting, reflection, and other such C# facilities in order to do them.

Now we know exactly how to make our drawers apply exactly where we want them to. We can place them anywhere in the order of the drawer chain that we want by setting their priority. Now we just need to actually make them do something! We've had a few glimpses of drawer implementations in this section, but to really get going, we first need to learn how Odin's property system works.