Hello everyone, my name is Dmitrii Ivashchenko, and I'm a Software Engineer at MY.GAMES. In this article, we'll talk about developing a user interface in Unity based on states and markup of elements.
First of all, it should be noted that we'll be talking in the context of the Unity UI (uGUI) technology, which is still recommended for Runtime according to the documentation. The approach described is not applicable to UI Toolkit, IMGUI, or other UI building systems.
Most often in Unity projects, you'll come across UI implementation built on View classes inherited from MonoBehaviour
and peppered with a large number of SerializeField
fields. This approach provides full control over the behavior of the UI, but it also makes it necessary to write a large amount of code at the View and Presenter levels (depending on the architecture used).
Often, as project development continues, these classes swell to incredible sizes, and the components on GameObject themselves are covered with a huge number of links to internal objects:
Modifying components like this is also not enjoyable: to get a reference to a new element in a class, you need to add SerializeField
, recompile the code, find the new field in the prefab component, and drag the necessary object into it. As the project grows, the compile time, the number of fields, and the complexity of organizing prefabs also increase in turn.
As a result, we end up with bulky and overloaded subclasses of MonoBehaviour
(or a large number of small ones, depending on your preference).
It's also worth considering that any changes to the behavior of such a UI is a task for the programmer, and that task comes with all the associated costs: code review, resolving merge conflicts, code coverage with tests, and so on.
SerializeField
, and then a large amount of code is written to make it work according to the requirements. Naturally, only a programmer can handle this, and the implementation turns out to be lengthy, expensive, and super-efficient (often much more efficient than anyone can notice).
Now that we've highlighted some of the difficulties of working with uGUI, I would like to talk about a different approach to solving this problem.
During my work on one of my pet-projects, I developed a library for structured UI development in Unity. Later, my team and I tested it on production and we were pleased with the results.
The source code for the library is available for download on GitHub.
The key element of the library is the StatefulComponent
component. This component is placed on the root GameObject of each screen and contains all the necessary references to internal elements, distributed across tabs:
Each link is named based on its role. From a code perspective, the set of roles is a regular enum
. Separate sets of roles are prepared for each type of UI element (buttons, images, texts, etc.):
public enum ButtonRole { ... }
public enum ImageRole { ... }
public enum TextRole { ... }
...
Roles are generated directly from the component, and there is no need to manually edit the enum
. Waiting for recompilation when creating a role is also not necessary, as these enum
elements can be used immediately after creation.
To simplify merge conflicts, enumeration values are calculated based on the names of the elements:
[StatefulUI.Runtime.RoleAttributes.ButtonRoleAttribute]
public enum ButtonRole
{
Unknown = 0,
Start = -1436209294, // -1436209294 == "Start".GetHashCode()
Settings = 681682073,
Close = -1261564850,
Quests = 1375430261,
}
This allows you to avoid breaking serialized values in prefabs if you and your colleagues happen to simultaneously create new roles for buttons in different branches.
Each type of UI element (buttons, texts, images) is located on its own tab:
By using roles, the complete markup of all elements inside the prefab is achieved. Sets of SerializeField
are no longer needed to access images and texts, and it is enough to have one reference to StatefulComponent
and know the role of the desired image in order to, for example, replace its sprite.
UnityEngine.UI.Text
and TextMeshProUGUI
UnityEngine.UI.InputField
and TMP_InputField
There are corresponding methods for working with annotated objects. In the code, you can use a reference to StatefulComponent
or inherit the class from StatefulView
:
public class ExamplePresenter
{
private StatefulComponent _view;
public void OnOpen()
{
_view.GetButton(ButtonRole.Settings).onClick.AddListener(OnSettingsClicked);
_view.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked);
_view.GetSlider(SliderRole.Volume).onValueChanged.AddListener(OnVolumeChanged);
}
}
public class ExampleScreen : StatefulView
{
private void Start()
{
SetText(TextRole.Title, "Hello World");
SetTextValues(TextRole.Timer, hours, minutes, seconds);
SetImage(ImageRole.UserAvatar, avatarSprite);
}
}
The tab with texts, in addition to the role and link to the object, contains the following columns:
The library does not include a built-in subsystem for working with translations. To connect your localization system, you'll need to create an implementation of the ILocalizationProvider
interface. This can be constructed, for example, based on your Back-end, ScriptableObjects, or Google Sheets.
public class HardcodeLocalizationProvider : ILocalizationProvider
{
private Dictionary<string, string> _dictionary = new Dictionary<string, string>
{
{ "timer" , "{0}h {1}m {2}s" },
{ "title" , "Título do Jogo" },
{ "description" , "Descrição longa do jogo" },
};
public string GetPhrase(string key, string defaultValue)
{
return _dictionary.TryGetValue(key, out var value) ? value : defaultValue;
}
}
By clicking on the Copy Localization button, the contents of the Code and Value columns will be copied to the clipboard in a format suitable for pasting into Google Sheets.
Often, in order to facilitate reuse, separate parts of the UI are extracted into separate prefabs. StatefulComponent
also allows us to create a hierarchy of components, where each component only works with its own child interface elements.
On the Inner Comps tab, you can assign roles to internal components:
Configured roles can be used in code similarly to other types of elements:
var header = GetInnerComponent(InnerComponentRole.Header);
header.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked);
header.SetText(TextRole.Title, "Header Title");
var footer = GetInnerComponent(InnerComponentRole.Footer);
footer.GetButton(ButtonRole.Continue).onClick.AddListener(OnContinueClicked);
footer.SetText(TextRole.Message, "Footer Message");
To create a list of similar elements, you can use the ContainerView
component. You need to specify the prefab for instantiation and the root object (optional). In Edit-mode, you can add and remove elements using StatefulComponent
:
It's convenient to use StatefulComponent
for marking up the content of instantiated prefabs. In Runtime, you can use the methods AddInstance<T>
, AddStatefulComponent
, or FillWithItems
to populate the container:
var container = GetContainer(ContainerRole.Players);
container.Clear();
container.FillWithItems(_player, (StatefulComponent view, PlayerData data) =>
{
view.SetText(TextRole.Name, data.Name);
view.SetText(TextRole.Level, data.Level);
view.SetImage(ImageRole.Avatar, data.Avatar);
});
If the standard Object.Instantiate()
does not suit you for creating objects, you can override this behavior, for example, for instantiation using Zenject:
StatefulUiManager.Instance.CustomInstantiateMethod = prefab =>
{
return _diContainer.InstantiatePrefab(prefab);
};
Internal Components and Containers provide static and dynamic nesting for StatefulComponent
, respectively.
We've considered the markup of prefabs, localization, and instantiation. Now it's time to move on to the most interesting part — developing UIs based on states.
We'll consider the concept of state as a named set of changes to a prefab. The name in this case is a role from the StateRole
enum, and examples of changes to the prefab can be:
A set of changes (State Description) can be configured on the States tab. A configured state can be applied directly from the inspector:
A configured state can be applied from code using the SetState
method:
switch (colorScheme)
{
case ColorScheme.Orange:
SetState(StateRole.Orange);
break;
case ColorScheme.Red:
SetState(StateRole.Red);
break;
case ColorScheme.Purple:
SetState(StateRole.Purple);
break;
}
On the Tools tab, when the Apply Initial State On Enable parameter is enabled, you can configure the State that will be applied immediately upon object instantiation.
Using states allows for a significant reduction in the amount of code required at the View class level. Simply describe each state of your screen as a set of changes in the StatefulComponent
and apply the necessary State from the code depending on the game situation.
Actually, developing a UI based on states is incredibly convenient. So much so that, over time, it leads to another problem — as the project evolves, the list of states for a single window can grow to an unruly length, and thus becomes difficult to navigate. In addition, there are states that only make sense in the context of some other states. To solve this problem, Statful UI has another tool: State Tree. You can access it by clicking on the State Tree Editor button in the States tab.
Parent states (in this example Reward) are applied every time child states are called:
Managing a configured StatefulComponent
comes down to a minimal amount of simple and understandable code that populates the components with necessary data and switches states at the right moment:
public void ShowIntro()
{
SetState(StateRole.Intro);
}
public void ShowReward(IReward reward)
{
// Update the inner view with the reward
reward.UpdateView(GetInnerComponent(InnerComponentRole.Reward));
// Switch on the type of reward
switch (reward)
{
case ICardsReward cardsReward: SetState(StateRole.Cards); break;
case IMoneyReward moneyReward: SetState(StateRole.Money); break;
case IEmojiReward emojiReward: SetState(StateRole.Emoji); break;
}
}
public void ShowResults(IEnumerable<IReward> rewards)
{
SetState(StateRole.Results);
// Fill the container with the rewards
GetContainer(ContainerRole.TotalReward)
.FillWithItems(rewards, (view, reward) => reward.UpdateView(view));
}
Roles are intended to provide a convenient and unambiguous way to name links and states for later use in the code. However, there are situations where describing a state would require a name that is too long, and it would be more convenient to leave a small comment about what this link points to, or what behavior the state reflects. For such cases, each link and state in a StatefulComponent
allows you to add a description:
You may have already noticed the Copy API and Copy Docs buttons on each tab — these copy information for the selected section. In addition to those, there are similar buttons in the Tools tab — these copy information for all sections at once. When you click the Copy API button, the generated code for managing this StatfulComponent
object will be copied to the clipboard. Here's an example for our rewards window:
// Insert the name of the chest here
SetText(TextRole.Title, "Lootbox");
// Button to proceed to the reward issuance phase
GetButton(ButtonRole.Overlay);
// Button to display information about the card
GetButton(ButtonRole.Info);
// Container for displaying the complete list of awarded rewards
GetContainer(ContainerRole.TotalReward);
// Insert the card image here
SetImage(ImageRole.Avatar, null);
// Animated appearance of a chest
SetState(StateRole.Intro);
When you click the Copy Docs button, the documentation for this prefab will be copied to the clipboard in Markdown format:
### RewardScreen
Buttons:
- Overlay - Button to proceed to the reward issuance phase
- Info - Button to display information about the card
Texts:
- Title - Insert the name of the chest here
Containers:
- TotalReward - Container for displaying the complete list of awarded rewards
Images:
- Avatar - Insert the card image here
States:
- Intro - Animated appearance of a chest
- Cards - Displaying rewards in the form of a card
- Money - Displaying rewards in the form of game currency
- Emoji - Displaying rewards in the form of an emoji
- Results - Displaying a complete list of issued rewards
It's obvious that making a mistake when implementing this screen with such detailed instructions is quite difficult. You can easily maintain up-to-date information about your UI organization in the project's knowledge base.
At the same time, Stateful UI allows for delegating the creation of UI prefabs. In fact, state-based markup allows for fully testing the prefab's behavior before passing it on to programmers. This means that game designers, technical designers, or even separate UI developers can prepare prefabs. Furthermore, since an API is created between the code and prefab, programming and configuring prefabs can be done in parallel! All that is required is to formulate the API in advance. But, even if the task of configuring prefabs remains with programmers, using Stateful UI significantly speeds up this work.
As we have seen, Stateful UI significantly simplifies working with UI element states. Long cycles are no longer needed to create SerializeFields, recompile code, and search for references amongst a huge number of View class fields. In the View classes themselves, it's no longer necessary to write a large amount of code for repetitive operations such as turning objects on and off or changing text color.
The library allows for a consistent approach to organizing layouts in a project, marking objects within prefabs, creating states, linking them to UI elements, and providing an API and documentation for UI management. It also allows for delegating the creation of UI prefabs and speeds up work with them.
Expanding the capabilities of States, supporting new types of UI changes in Description, such as new types of animations, playing sounds in states, and so on
Adding support for color palettes for coloring text and images
Adding support for lists of items with GameObjects reuse
Supporting a larger number of Unity UI elements
Automating the unloading of added texts for localization
Implementing a Test Framework. Since we have exhaustive markup of our prefabs, we can create easy-to-set-up ScriptableObject-based scenarios in the following format:
Click the ButtonRole.Settings
button
Check the text in TextRole.SomeText
to be equal to "some value"
Check the image in ImageRole.SomeImage
to ensure it is equal to a certain sprite
A tutorial system. Similarly to testing, marked layout allows creating ScriptableObject-based tutorial scenarios in the form of instructions like "Show pointer on the button ButtonRole.UpgradeHero
".
The project source code is available on GitHub. You're welcome to create issues or contribute to the library!