Intro
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.
I'd like to highlight the implementation of windows with multiple states. I have seen many variations, which can be divided into two approaches:
-
First, any change in the window state occurs using code. To change the color of text, change an image, play an animation, move an object on the screen — all the involved objects and parameters require a corresponding 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).
-
Another approach can be described as the “all-powerful Animator”. In addition to the View class, an Animator Controller is created and controlled through parameters. A new Animator appears in the new window, and so on, until the FPS when displaying windows begins to drop.
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.
Stateful UI
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.
Stateful Component
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.):
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:
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.
The types of elements that are currently accessible are:
-
Buttons, Images, Toggles, Sliders, Dropdowns, VideoPlayers, Animators
-
Texts, including UnityEngine.UI.Text and TextMeshProUGUI
-
TextInputs, including UnityEngine.UI.InputField and TMP_InputField
-
Objects — for references to arbitrary objects.
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:
Texts and Localization
The tab with texts, in addition to the role and link to the object, contains the following columns:
-
Code: a text key for localization
-
Localize checkbox: an indicator that the text field is subject to localization
-
Value: the current text content of the object
-
Localized: the current text found by the key from the Code field
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.
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.
Internal Components
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:
Containers
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: