A working, stylable combo box (drop down list)

I spent most of the last two days getting a working Combo Box (aka Drop Down List) working in uGUI. However, I had some specific requirements for it that made this a lot harder than simply building all the objects and hooking them up. I’ll go over these requirements and why I think they are important below, then post the code at the bottom of this message along with an example project. The code is still pretty rough, as I’d like to improve some of the abstractions, but will likely be useful if you want to wrap your controls this way. This post is going to be long, but hopefully worth your time.

My requirements:

  1. The user should be able to create a combo box as a single item in their UI, not as 20+ objects
  2. The coder should have a single point of access for everything about the combo box and not have to worry about the objects it’s made from.
  3. The entire system should be stylable by the UI artist, without coder intervention.
  4. The styling should update automatically when the style is changed where ever it is used.
  5. You should be able to prefab the UI which contains the combo box.
  6. Any type of item should be able to be used in the combo box - want a list if images with text, a list of textures, or something else? No problem.

Many of these requirements would be met by having a better encapsulation system for Unity, which I’ll discuss later.

Here is what a combo box looks like in Unity.

Notice that it’s made from a very large number of objects. This is problematic for a number of reasons. First, it’s very time consuming to make all of this by hand and get them all working well together. Second, any programmer who needs to work with data in these components will have to have some way of finding them all first, or a giant list of references for the artist to fill out. Both of these options are extremely brittle, and IMO, not scalable to a large production.

Lets put that aside for a second and talk about reuse. I should prefab this so I don’t have to create it again - and then users can just drop them into the scene and just use them. The problem with this is that Unity does not support nested prefabs - so if I have a panel that needs to appear on every screen and prefab it, then if I change the combo box prefab it will not update in that scene.

So this is what I set out to solve.

How it works for the UI Artist:

When they want to create a combo box in the game they go to GameObject->UI->Combo box and it will create a new combo box object under the currently selected uGui element. This component has three fields for the prefabs described below. As soon as you fill them out, it will create the combo box for you, hiding it’s guts so it looks like a single item in the hierarchy. The prefabs act as the styling for the combo box - note that they aren’t part of the scene in the classical sense (they are created on start, etc)

How it works for the coder:

To populate the combo box with items, you can do one of the following:

// populate the combo box with text
comboBox.AddItems("Test1", "Test2", "Test3", "Unity", "Needs", "A", "Better", "Encapsulation", "System", "Than", "Prefabs");

// or, populate it with textures
comboBox.AddItems(myTex1, myText2);

// or populate it with both

comboBox.AddItems(StyledComboBox(new StyledItemButtonImageText.Data("MyString", myTex1));

// finally, listen for changes:
comboBox.OnSelectionChanged += delegate(StyledItemitem) 
{
   Debug.Log (item.GetText() + "" + comboBox.SelectedIndex);
}

The coder essentially only works with the StyledComboBox component, setting it’s items, getting the result, etc. They don’t have to worry about the items being created/destroyed/placed/managed at all.

How the UI artist styles the control:

The UI artist sets up a prefab for the combo box and add’s the ComboBoxPrefab template to it (or just modifies mine). This template has properties to link several things the system wants to know about; what rect to place the items under, which panel to toggle alpha on when the user opens or closes the combo box, and where to put the version that shows up in the menu.

The UI artist then creates a second prefab for how an item looks in this menu, and adds a StyleItem component to it - StyleItem is a base class, and theirs currently one usable subclass (StyledItemButtonImageText), but it’s easy to add new ones - this acts as the abstraction layer so the combo box doesn’t really have to know about it’s contents. For instance, they could make a version which has a button, an image, and a text in it, and arrange them however they should look using the current control, or a code could create a new StyledItem subclass with any number of controls inside of it.

They can optionally create a third prefab if they want the version which appears as the main control (currently selected item) to look different than the regular items in the list, but in most cases they don’t need this.


How Unity could provide better encapsulation and scale their engine better…

One of my major issues with Unity is the lack of proper encapsulation. Even if nested prefabs were to work correctly, I don’t think the system provides enough encapsulation. The code posted below is a work around to provide the level of encapsulation I feel is necessary for a project to scale. It takes a complex system of interconnected objects, encapsulates them into a single object, and provides a single, unified API for the system.

What I would like to see unity provide is a way to have a “closed prefab”. This is essentially a prefab that hides all of it’s children and data from the world, while allowing the person who sets up that prefab to provide a single API to work with it.

The workflow would go something like this, using our combo box as an example, but could be equally applied to any reasonably complex game object network.

  • The creator of the asset would create the combo box prefab in the same manner they do now.
  • The creator would mark the prefab as closed, which would hide all the subobjects and show them an “edit” button. Pressing edit would show all of the hidden objects for them to make changes.
  • While in the edit mode, the user would be able to right click on any field in any sub-object and select expose, typing in a new name for the field’s alias. This would then show up as a field on the top level prefab object for the user to edit, or the code to set.

With this, you can create a prefab, hide it’s guts, and expose only the data they want the user to be able to change, providing a single place to address the data, with a narrow aperture. More importantly, there’s no giant list of pointers to fill out, or Find(“someobject”) in the code which breaks every time someone renames something.


The Code

The code is attached to this thread. It can likely be improved considerably, as I’m currently doing manual placement because I couldn’t get the layout components to do what I expected to be able to do with them. I welcome suggestions to make the system better, or if people would like to work with me on wrapping some other common controls like this that would be wonderful. I hope this is useful for someone else, enjoy!


1746901–110363–uGUI_ComboBox.zip (17 KB)

4 Likes

This is a cool combo box.

I’ll pass on your prefab feedback to the core team as they are responsible for prefab implementation. I do agree with many of the things you say and as we developed the UI we came across a number of things that would have been easier if there was better encapsulation support, unfortunately it is a non trivial thing to add.

Some components (like this) work really well with programatic API that ‘does it all’ and a combo box is a great example of this (in fact our prototype combobox does basically what your one does). In some other components through it’s not quite as ideal. I do think we could improve API for automatic creation of buttons and other simple elements that we have.

4 Likes

Wouldn’t it work (at least as a temporary measure as long as no better way to encapsulate is available) to use a custom inspector and just include a toggle button to apply HideInHierarchy to all children of the root combo box object? That would sort of do what your mentioned Edit button would do. The whole part about a simple way of exposing fields from childen would be a bit more tricky though.

I used to design the custom engines for a few different companies. The way we handled this type of encapsulation in the past was via a component that acted as a delegate between the root object the user see’s and the internals, binding the properties of one to the other. HideInHierarchy and a custom editor could be used to accomplish the open/closed nature right now - but the property exposing would require storing all the values/target mappings in a dictionary. Doable, but likely much slower on the C# side…

@anon_35347745 : I actually plan to do this for things as simple as a button with a text component. I think all the individual pieces are great, but I never want to have a Find in my code, or list of pointers on a component so the code can know where the Text object is, etc. These things are all brittle by nature, and as production size scales up this is where Unity classically has issues. Additionally, having the styling for everything controlled in one central prefab is incredibly useful.

Being able to have users create these types of abstractions without writing tons of code is really where I’d love to see Unity go. Mecanim is one place where Unity shines in this regard, in that a content creator can create an entire state machine and expose a simple blackboard of values/events for the coder to work with. However, ideally this type of flow would work in either direction - the coder should be able to create the blackboard and have the artist bind it’s data to the state machine as well. UI is really no different, as it’s usually about this same type of binding (score → this text field or animation frame, etc)

Yeah. I understand what you are saying. I think it is the direction we are going with the UI, I would expect to see more tightly controlled interfaces appearing over time.

Hey Tim, one questions while you’re here.

So my combo box uses a large panel for the drop down area. However, if someone places this into a horrizontal layout group, how do I get it to only use the smaller area of the menu button for layout? I noticed the ILayoutIgnorer and ILayoutElement interfaces, but not a “GetLayoutRect” type function which can be overridden to provide this information…

I’m not Tim, but I’ll try to answer. :slight_smile: Usually to make elements in a horizontal group use fixed width/height, I add a Layout Element to the element I need to adjust, and set flexible width to 0 , and preferred width to some number. (If all the elements need to be of the same width, it’s better to use grid layout instead i think)

But when I parent combo box to any element, it disappears in game (not in scene) and some weird red stuff appears. This is not the first time I’ve seen this behaviour, it happened not only with combo box but with other elements as well…


1747872--110436--red.png

The red X is a control that has negative width or height values. This seems to happen a lot by accident with me too, and I’m never quite sure why it ends up that way. I personally find the RectTransform confusing to work with; between having several different editing modes and the API not having functions which match the UI, it’s easy to get confused as to what it’s going to do. It seems like a really slick tool with a simple, elegant interface, but either there’s bugs and bad behavior in it, or I’m just a dolt who can’t quite make sense of it.

What I was asking Tim for was a little different. Basically, I want to programmatically control how big a layout group thinks my element is - something like what the Layout Element actually does, in a way. Essentially, I want my control to not take the size of the panel which will open into account when it is being positioned in a layout. I don’t want this to be set with a Layout Element component, because I want the user to be able to size the rect visually for the panel, and the item instantiated into the template to determine how big each element is.

Though I’ve noticed that controls like the layout grid prefer to tell the elements inside of them how big to be instead of asking them how much space they need. This is certainly simpler and avoids lots of issues with dynamic layout and multiple sized elements, but it makes it much more difficult to do good styling since the parent control needs to be styled explicitly for the objects which go inside of it…

Hi, I’m going to ping the layout expert to take a look at this thread, I know a bit about it but want to make sure you get the right answer.

I can’t really comment on this without more details. I recommend recording a screencast (using Jing or similar) of the entire Editor (including Scene View, Hierarchy, Inspector) that shows the issues you’re having. This is usually much more useful to us than written explanations.

You can do that by creating a MonoBehaviour that implements the ILayoutElement interface.

Well this is just how the GridLayoutGroup is designed. It’s for elements that all have the same size, and for that, asking the individual elements for their size is not supported. It would be possible to make a TableLayoutGroup or similar that would arrange children in a table and ask them for their individual sizes and use that information somehow, but it’s out of the scope of 4.6.

@Tim-C Any chance the prototype ComboBox can get published to your Gists list? I remember seeing it a while back and would be good to compare notes.

Thanks for the info @runevision will have to check out the LayoutElement interface a bit more closely

I’m not sure why you made it harder on yourself… I got rid of all the hiding and added a bool check for if the menu is opened/closed to make it so you can’t interact with the items until after it’s open.

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



[RequireComponent(typeof(RectTransform))]
public class StyledComboBox : StyledItem
{
    public delegate void SelectionChangedHandler(StyledItem item);
    public SelectionChangedHandler OnSelectionChanged;

    public StyledComboBoxPrefab     containerPrefab;        // prefab for whole control
    public StyledItem                 itemPrefab;                // prefab for item in drop down
    public StyledItem                 itemMenuPrefab;        // prefab for item in menu

    [SerializeField]
    //[HideInInspector]
    private StyledComboBoxPrefab     root;
 
    [SerializeField]
    //[HideInInspector]
    private List<StyledItem> items = new List<StyledItem>();

    [SerializeField]
    private int selectedIndex = 0;
    public int SelectedIndex
    {
        get
        {
            return selectedIndex;
        }
        set
        {
            if (value >= 0 && value <= items.Count)
            {
                selectedIndex = value;
                CreateMenuButton(items[selectedIndex]);
            }

        }
    }


    public StyledItem SelectedItem
    {
        get
        {
            if (selectedIndex >= 0 && selectedIndex <= items.Count)
                return items[selectedIndex];
            return null;
        }
    }


    void Awake()
    {
        InitControl();
    }
 
    bool DropBoxOpen = false;

    private void AddItem(object data)
    {
        if (itemPrefab != null)
        {
            Vector3[] corners = new Vector3[4];
            itemPrefab.GetComponent<RectTransform>().GetLocalCorners(corners);
            Vector3 pos = corners[0];
            float sizeY = pos.y - corners[2].y;
            pos.y = items.Count * sizeY;
            StyledItem styledItem = Instantiate(itemPrefab, pos, root.itemRoot.rotation) as StyledItem;
            RectTransform trans = styledItem.GetComponent<RectTransform>();
            styledItem.Populate(data);
            trans.parent = root.itemRoot.transform;

            trans.pivot = new Vector2(0,1);
            trans.anchorMin = new Vector2(0,1);
            trans.anchorMax = Vector2.one;
            trans.anchoredPosition = new Vector2(0.0f, pos.y);
            items.Add(styledItem);

            trans.offsetMin = new Vector2(0, pos.y + sizeY);
            trans.offsetMax = new Vector2(0, pos.y);

            root.itemRoot.offsetMin = new Vector2(root.itemRoot.offsetMin.x, (items.Count + 2) * sizeY);

            Button b = styledItem.GetButton();
            int curIndex = items.Count - 1;
            if (b != null)
            {
                b.onClick.AddListener(delegate() { OnItemClicked(styledItem, curIndex); });
            }
        }
    }

    public void OnItemClicked(StyledItem item, int index)
    {
        if (DropBoxOpen)
        {
            SelectedIndex = index;

            TogglePanelState ();    // close
            if (OnSelectionChanged != null)
            {
                OnSelectionChanged (item);
                DropBoxOpen = false;
            }
        }
    }

    public void ClearItems()
    {
        for (int i = items.Count - 1; i >= 0; --i)
            DestroyObject (items [i].gameObject);
    }

    public void AddItems(params object[] list)
    {
        ClearItems();

        for (int i = 0; i < list.Length; ++i)
        {
            AddItem(list[i]);
        }
        SelectedIndex = 0;
    }
 


    public void InitControl()
    {
        if (root != null)
            DestroyImmediate(root.gameObject);

        if (containerPrefab != null)
        {
            // create
            RectTransform own = GetComponent<RectTransform>();
            root = Instantiate(containerPrefab, own.position, own.rotation) as StyledComboBoxPrefab;
            root.transform.parent = this.transform;

            RectTransform rt = root.GetComponent<RectTransform>();
            rt.pivot = new Vector2(0.5f, 0.5f);
            //root.anchoredPosition = Vector2.zero;
            rt.anchorMin = Vector2.zero;
            rt.anchorMax = Vector2.one;
            rt.offsetMax = Vector2.zero;
            rt.offsetMin = Vector2.zero;
            //root.gameObject.hideFlags = HideFlags.HideInHierarchy; // should really be HideAndDontSave, but unity crashes
            root.itemPanel.alpha = 0.0f;

            // create menu item
            StyledItem toCreate = itemMenuPrefab;
            if (toCreate == null)
                toCreate = itemPrefab;
            CreateMenuButton(toCreate);
        }
    }

    private void CreateMenuButton(StyledItem toCreate)
    {
        if (root.menuItem.transform.childCount > 0)
        {
            for (int i = root.menuItem.transform.childCount - 1; i >= 0; --i)
                DestroyObject(root.menuItem.transform.GetChild(i).gameObject);
        }
        if (toCreate != null && root.menuItem != null)
        {
            StyledItem menuItem = Instantiate(toCreate) as StyledItem;
            menuItem.transform.parent = root.menuItem.transform;
            RectTransform mt = menuItem.GetComponent<RectTransform>();
            mt.pivot = new Vector2(0.5f, 0.5f);
            mt.anchorMin = Vector2.zero;
            mt.anchorMax = Vector2.one;
            mt.offsetMin = Vector2.zero;
            mt.offsetMax = Vector2.zero;
            //root.gameObject.hideFlags = HideFlags.HideInHierarchy; // should really be HideAndDontSave, but unity crashes
            Button b = menuItem.GetButton();
            if (b != null)
            {
                b.onClick.AddListener(TogglePanelState);

            }
        }
    }
 
    public void TogglePanelState()
    {
        if (!DropBoxOpen)
            DropBoxOpen = true;
        else if (DropBoxOpen)
            DropBoxOpen = false;
        root.itemPanel.alpha = Mathf.Abs(root.itemPanel.alpha - 1.0f);
    }
}

EDIT:
Got rid of the debug log’s lol…

How would i use Screen Space - Camera and combo box together?
On the canvas when using Screen Space - Camera, instead of Screen Space - Overlay on start. The image becomes too big to display.

1805771--115202--before start combo box.jpg

The script uses Transform.parent, which is almost always a bad idea with the UI system because it keeps the world space position and scale of the object. Use Transform.SetParent(parent, false) instead.

2 Likes

Thanks for the help, now it works perfectly with screen space - camera.

I have provided the little modification that i have made for anyone running into the same issue in the future, to change it back to screen space - overlay change the bool worldSpaceOn.

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

[RequireComponent(typeof(RectTransform))]
public class StyledComboBox : StyledItem
{
    public delegate void SelectionChangedHandler(StyledItem item);
    public SelectionChangedHandler OnSelectionChanged;

    public StyledComboBoxPrefab     containerPrefab;        // prefab for whole control
    public StyledItem                 itemPrefab;                // prefab for item in drop down
    public StyledItem                 itemMenuPrefab;        // prefab for item in menu

    bool worldSpaceOn = false;

    [SerializeField]
    [HideInInspector]
    private StyledComboBoxPrefab     root;
   
    [SerializeField]
    [HideInInspector]
    private List<StyledItem> items = new List<StyledItem>();

    [SerializeField]
    private int selectedIndex = 0;
    public int SelectedIndex
    {
        get
        {
            return selectedIndex;
        }
        set
        {
            if (value >= 0 && value <= items.Count)
            {
                selectedIndex = value;
                CreateMenuButton(items[selectedIndex]);
            }

        }
    }


    public StyledItem SelectedItem
    {
        get
        {
            if (selectedIndex >= 0 && selectedIndex <= items.Count)
                return items[selectedIndex];
            return null;
        }
    }


    void Awake()
    {
        InitControl();
    }
   

    private void AddItem(object data)
    {
        if (itemPrefab != null)
        {
            Vector3[] corners = new Vector3[4];
            itemPrefab.GetComponent<RectTransform>().GetLocalCorners(corners);
            Vector3 pos = corners[0];
            float sizeY = pos.y - corners[2].y;
            pos.y = items.Count * sizeY;
            StyledItem styledItem = Instantiate(itemPrefab, pos, root.itemRoot.rotation) as StyledItem;
            RectTransform trans = styledItem.GetComponent<RectTransform>();
            styledItem.Populate(data);
            trans.parent = root.itemRoot.transform;

            trans.pivot = new Vector2(0,1);
            trans.anchorMin = new Vector2(0,1);
            trans.anchorMax = Vector2.one;
            trans.anchoredPosition = new Vector2(0.0f, pos.y);
            items.Add(styledItem);

            trans.offsetMin = new Vector2(0, pos.y + sizeY);
            trans.offsetMax = new Vector2(0, pos.y);

            root.itemRoot.offsetMin = new Vector2(root.itemRoot.offsetMin.x, (items.Count + 2) * sizeY);

            Button b = styledItem.GetButton();
            int curIndex = items.Count - 1;
            if (b != null)
            {
                b.onClick.AddListener(delegate() { OnItemClicked(styledItem, curIndex); });
            }
        }
    }

    public void OnItemClicked(StyledItem item, int index)
    {
        SelectedIndex = index;

        TogglePanelState();    // close
        if (OnSelectionChanged != null)
        {
            OnSelectionChanged(item);
        }
    }

    public void ClearItems()
    {
        for (int i = items.Count - 1; i >= 0; --i)
            DestroyObject(items[i].gameObject);
    }

    public void AddItems(params object[] list)
    {
        ClearItems();

        for (int i = 0; i < list.Length; ++i)
        {
            AddItem(list[i]);
        }
        SelectedIndex = 0;
    }

    public void InitControl()
    {
        if (root != null)
            DestroyImmediate(root.gameObject);

        if (containerPrefab != null)
        {
            // create
            RectTransform own = GetComponent<RectTransform>();
            root = Instantiate(containerPrefab, own.position, own.rotation) as StyledComboBoxPrefab;
            //root.transform.parent = this.transform;
            root.transform.SetParent(this.transform, worldSpaceOn);

            RectTransform rt = root.GetComponent<RectTransform>();
            rt.pivot = new Vector2(0.5f, 0.5f);
            //root.anchoredPosition = Vector2.zero;
            rt.anchorMin = Vector2.zero;
            rt.anchorMax = Vector2.one;
            rt.offsetMax = Vector2.zero;
            rt.offsetMin = Vector2.zero;
            root.gameObject.hideFlags = HideFlags.HideInHierarchy; // should really be HideAndDontSave, but unity crashes
            root.itemPanel.alpha = 0.0f;

            // create menu item
            StyledItem toCreate = itemMenuPrefab;
            if (toCreate == null)
                toCreate = itemPrefab;
            CreateMenuButton(toCreate);
        }
    }

    private void CreateMenuButton(StyledItem toCreate)
    {
        if (root.menuItem.transform.childCount > 0)
        {
            for (int i = root.menuItem.transform.childCount - 1; i >= 0; --i)
                DestroyObject(root.menuItem.transform.GetChild(i).gameObject);
        }
        if (toCreate != null && root.menuItem != null)
        {
            StyledItem menuItem = Instantiate(toCreate) as StyledItem;
            //menuItem.transform.parent = root.menuItem.transform;
            menuItem.transform.SetParent(root.menuItem.transform, worldSpaceOn);

            RectTransform mt = menuItem.GetComponent<RectTransform>();
            mt.pivot = new Vector2(0.5f, 0.5f);
            mt.anchorMin = Vector2.zero;
            mt.anchorMax = Vector2.one;
            mt.offsetMin = Vector2.zero;
            mt.offsetMax = Vector2.zero;
            root.gameObject.hideFlags = HideFlags.HideInHierarchy; // should really be HideAndDontSave, but unity crashes
            Button b = menuItem.GetButton();
            if (b != null)
            {
                b.onClick.AddListener(TogglePanelState);
            }
        }
    }
   
    public void TogglePanelState()
    {
        root.itemPanel.alpha = Mathf.Abs(root.itemPanel.alpha - 1.0f);
    }
}

Keep getting two of these errors:
Type Button' does not contain a definition for onClick’ and no extension method onClick' of type Button’ could be found (are you missing a using directive or an assembly reference?)

sounds like your missing :

#using UnityEngine.UI

I wish it was that simple, im just using your zip file as it is, no modifications at all.
The strange thing is that at work i get no error and home i get it, both using 4.6b21 : /

is there a way to make the height bigger? doesn’t seem to let me go any higher than a certain height :confused: