Creating UI Tabs in Unity: Part 2

Posted On: 2020-08-17

By Mark

Today's post is part 2 in a series about how to add tabs to an in-game UI in Unity. In part 1, I explained what tabs are as well as when to use them. Part 2 will instead focus on the implementation - starting with the high-level structure and then diving into the details of a basic implementation.

High Level Design

As mentioned in part 1, any tab control is made up of two parts: a tab strip and the tab's content area. When implementing it in Unity, however, a little bit more specificity is needed. The tab strip itself is a series of buttons, constrained to fit together within the space allocated for the tab strip. The tab content area can be implemented in a number of different ways, but the simplest is to approach it as a series of individual content elements, one for each tab, with each one completely filling the tab content area.

Each of these smaller parts can be composed out of built-in (or third-party) controls, such that the only custom component necessary is the tab strip itself - the one component that will be responsible for orchestrating all the tabs' behavior. To that end, each of the individual tabs need only be a Unity Button control - positioned and labeled as desired. For the tab content area, one needs a simple way to show one element, while hiding all the others. To achieve that, we'll use a Canvas Group, which can be used to hide an element and all its child contents*.

While the exact order of building out the UI components is not particularly important, at some point the buttons for the tabs and the actual content (including canvas groups) need to be created. For simplicity, this tutorial will assume that you do so now: create and position the buttons as desired, as well as create and position the content for the various tabs*.

While setting up the buttons, there are a couple important things to note:

  1. The buttons should use the default transition mode: color tint*.
  2. The tab strip should be a parent component to the buttons.

Without those requirements, the implementation described in this tutorial may not behave correctly: either by causing graphical bugs (#1) or potentially leaking event handlers (#2). Both those requirements can be alleviated with code changes - which is something I'll cover in a later part in this series.

Tab Strip Code

As mentioned previously, the tab strip will be the one (and only) custom component used in this tutorial. As such, I'll dive into the details for that here, covering both the code and the design intent behind it.

Data Setup

The first step of building a new component is to store the data that it will need to use, and the first piece of data for the tab strip is a record of the tabs and their respective content areas. Since each tab is paired with only one content area, this association can be easily represented by a simple (custom) data structure: a TabPair class.

[Serializable]
public class TabPair{
  public Button TabButton;
  public CanvasGroup TabContent;
}

The data type of the TabButton and TabContent are Unity's built-in UI elements (mentioned earlier), the Button and CanvasGroup. They are in the UnityEngine.UI namespace, so you may need to add the appropriate "using" statement:

using UnityEngine.UI;

The tab strip will contain a collection of tab pairs*, which must contain all the possible tabs in the tab strip. By assuming that the collection contains all the tabs**, it becomes simple for the tab strip to control the tabs and their content.

In addition to the various tab pairs, there are also some additional configuration sections that the tab strip expects a designer will set up (in Unity's inspector). The first of these is a pair of sprites, one for a tab when it is picked by the user, and one for when it is not (TabIconPicked and TabIconDefault, respectively.) The second is the tab that should be picked by default, when the tab strip first starts (a Button called DefaultTab):

public class TabStrip: MonoBehaviour {
  public TabPair[] TabCollection;
  public Sprite TabIconPicked;
  public Sprite TabIconDefault;
  public Button DefaultTab;
  //Tutorial continues adding things here...
}

Additionally, there is one piece of state information that the tab strip needs to track over the course of its use: the currently selected tab.

  protected int CurrentTabIndex { get; set; }

Switching Tabs

Armed with enough state to represent the tabs, one can begin with a simple SetTabState method, to change whether or not a particular tab is picked:

protected void SetTabState(int index, bool picked){
  TabPair affectedItem = TabCollection[index];
  affectedItem.TabContent.interactable = picked;
  affectedItem.TabContent.blocksRaycasts = picked;
  affectedItem.TabContent.alpha = picked ? 1 : 0;
  affectedItem.TabButton.image.sprite = picked ? TabIconPicked : TabIconDefault;
}

The logic is very simple here: it sets the CanvasGroup that stores that tab's content to either be active (interactable, raycastable, and visible) or not. Additionally, it sets the image for the tab's button as well, which is the primary way to indicate to the user which tab is currently picked.

By itself, setting the tab state does not enforce that only one tab is picked at a time. Thus, it makes sense to make a more general purpose method, that facilitates only picking a single tab:

public void PickTab(int index){
  SetTabState(CurrentTabIndex, false);
  CurrentTabIndex = index;
  SetTabState(CurrentTabIndex, true);
}

As you can see, the CurrentTabIndex is used here to control which tab is considered "picked". This code is able to be quite simple* in large part thanks to two big assumptions:

  1. The CurrentTabIndex is set correctly.
  2. All tabs that are not currently picked are in the correct state ("not picked")

To support these assumptions, the tab strip must be correctly configured when it first starts. This will include not only setting the initial starting conditions, but also picking the default tab. As such, we'll use the OnEnable method, to make sure that any time the tab is disabled and then re-enabled, it swaps to the correct default*.

protected int? FindTabIndex(Button tabButton)
{
  var currentTabPair = TabCollection.FirstOrDefault(x => x.TabButton == tabButton);
  if (currentTabPair == default)
  {
    Debug.LogWarning("The tab " + DefaultTab.gameObject.name + " does not belong to the tab strip " + name + ".");
    return null;
  }
  return Array.IndexOf(TabCollection, currentTabPair);
}
protected void OnEnable(){
  //Initialize all tabs to an unpicked state
  for (var i = 0; i < TabCollection.Length; i++)
  {
    SetTabState(i, false);
  }
  //Pick the default tab
  if (TabCollection.Length > 0)
  {
    var index = FindTabIndex(DefaultTab);
    //If tab is invalid, instead default to the first tab.
    if (index == null)
      index = 0;
    CurrentTabIndex = index.Value;
    SetTabState(CurrentTabIndex, true);
  }
}

There are two interesting methods here: firstly, the FindTabIndex is a handy utility for finding a particular tab in the tab strip. It makes use of Linq's FirstOrDefault to do the brunt of the work, and reports a warning if the provided tab is not in the collection. This method is primarily useful for simplifying the inspector: without it, the designer using the tab strip would have to specify the index themselves, instead of the desired button.

The second method is the OnEnable (which is automatically called by Unity as a lifecycle event). In that, we initialize all tabs to the "un-picked" status, and then pick the default tab (as specified in the inspector.) This code is fairly simple, thanks to the FindTabIndex and SetTabState doing most of the heavy lifting here.

Adding Event Listeners

The last bit of code in the tab strip is optional, but convenient. By default, Unity expects designers to set the event listeners for a button using the inspector. If one chooses to do so, then no additional code is needed: simply set each listener to call the PickTab with the correct index.

If you're like me, however, you may think that sounds tedious and error prone. Fortunately, this is something that is relatively simple to set up. Button listeners can be configured via code in the Start method, which will keep them configured for as long as the component exists:

protected void Start()
{
  for (var i = 0; i < TabCollection.Length; i++)
  {
    //Storing the current value of i in a locally scoped variable.
    var index = i;
    TabCollection[index].TabButton.onClick.AddListener(new UnityAction(() => PickTab(index)));
  }
}

This code is deceptively simple: it loops through each tab and adds an event listener that will occur when the button is clicked. This event handler is simply a call to PickTab, using the index of that tab in the collection. What is interesting about this, however, is that a seemingly useless variable assignment (var index = i) is actually avoiding a subtle bug: the value of i keeps changing as the loop progresses, so attempting to use i inside the event handler will cause it to look for the value at the time it is clicked*. By capturing the current value of i into a new variable, we can safely use that inside the event handler, and it will reflect the value of i at the time the listener was created.

Wiring Everything Up

With the code for the tab strip complete, it's time to use it! Add it to a game object*, and assign the desired default tab and sprites for the tabs (picked vs not.) Set the TabCollection to the correct size and (carefully) fill in each pair, making sure that each tab button is correctly paired to the corresponding tab content.

Once that's all set, you can run the code and try it out. Enjoy the tabs tinting in response to mouse movement, and changing to consistently reflect which tab is picked! Then, try it with a keyboard/controller - only to cringe as Unity's automatic navigation system provides its usual mixed bag of quality.

More to come

Although the code in this section is technically working, there's still plenty of room for polish. Tune in next week for part 3, where I will explore adding controller support*.