UI Router in Unity + CustomEditor

(Not a demonstration of creating a router)

(Not a demonstration of creating a router)

Disclaimer

My router ideologically copies the router from the article, but the implementation given there seemed to me not optimal and not convenient in practical use. I will describe the work of my implementation + CustomEditor, without attributes and SendMessage.

First, I will give the main pieces of code, and then in the spoiler the full version of the script with all the methods, so as not to stretch the article too much.


Concept

Often, the development of complex user interfaces leads to the appearance of dictionaries, enams, interface states in order to competently monitor what is happening. However, sometimes this may be contrary to the ideology that the interface should only display the state without changing it.

To systematize the work of the interface, it is proposed to borrow experience from web development:

  1. All screens have a unique url.

  2. All the necessary information to display on the screen is passed as parameters as part of the link.

  3. Commands to windows are passed through a static class that stores the history of windows on the stack, similar to a browser.

Thanks to this we have:

  • Elementary back support. In the browser, most often the interface is designed so that the initial state of its opening is determined by the parameters of the link. Therefore, every time we press back, we simply reopen the screen that we opened before with the same parameters and get the same result.

  • Avoid dependencies between windows.

We write a router

We create a stack for storing page history, a dictionary for storing page instances, as well as the path to the main page and syntax constants:

public static class UIRouter
{
    private static Stack<string> _screensStack = new Stack<string>();
    private static Dictionary<string, IUIPage> _routesPageData = new Dictionary<string, IUIPage>();
    private static string _mainScreenRoute = string.Empty;

    #region const
    // Чтобы синтаксис url был единообразным, явно указываем его через константы
    // Пример ссылки:
    // canvas.Name + slash + gameObj1.Name + separator + key + equally + value + and + key2 + equally + value2 = "Canvas/Name1?key=value&key2=value2"
    public const string slash = "/";
    public const string separator = "?";
    public const string and = "&";
    public const string equally = "=";
    #endregion

    // Чтобы не выбрасовало NullReferenceException, первым обращением инициализируем поля.
    public static void ForceCreate()
    {
        if (_routesPageData == null || _screensStack == null)
        {
            _routesPageData = new Dictionary<string, IUIPage>();
            _screensStack = new Stack<string>();
            _mainScreenRoute = string.Empty;
        }        
    }
}

All pages must have basic functionality for correct interaction with the router (and the canvas manager in the future). So let’s create an interface:

public interface IUIPage
{
    public string url { get; }
    public void CreateURL(string url, bool shouldUpdatePropertiesValues);

    public void Show(Dictionary<string, string> data);
    public string Hide();
    
    public void Subscribe();
    public void Unsubscribe();

    public void FindAndDefineChildsProperties();
}

To read the values ​​passed as parameters to the url, in the class UIRouter write a parser:

private static Dictionary<string, string> ParseParams(string fullRoute, out string route)
{
    string routeEnd = "";
    if (fullRoute.Contains(slash))
    {
        routeEnd = fullRoute.Split(slash)[^1];
    }
    else
    {
        routeEnd = fullRoute;
    }
    var result = new Dictionary<string, string>();
    var routeParams = routeEnd.Split(separator);
    if (routeParams.Length > 1)
    {
        var parameters = routeParams[1].Split(and);
        if (parameters.Length > 0)
        {
            foreach (var param in parameters)
            {
                var data = param.Split(equally);
                if (data.Length > 1)
                {
                    result[data[0]] = data[1];
                }
            }
        }
        route = fullRoute.Split(separator)[0];
    }
    else
    {
        route = fullRoute;
    }
    return result;
}

It splits the link into two parts: address and parameters using separator. The parameters themselves are separated from each other by sign andand keys of values ​​by sign equally. The resulting dictionary of keys and parameter values ​​returns through returnand puts the part of the url responsible for the address in the second argument.

Now we can “sign” pages by their address in the url, without being afraid to write the address with variable parameters.

public static void SubscribePath(string path, IUIPage page)
{
    ParseParams(path, out path);
    if (!_routesPageData.ContainsKey(path))
    {
        _routesPageData.Add(path, page);
    }
}
public static void UnsubscribePath(string path)
{
    ParseParams(path, out path);
    if (_routesPageData.ContainsKey(path))
    {
        _routesPageData.Remove(path);
    }
}
public static void SetMainScreenRoute(string route, bool forceAdd=false)
{
    if (_mainScreenRoute == "" || forceAdd)
    {
        _mainScreenRoute = route;
        if (_screensStack.Count == 0)
        {
            _screensStack.Push(route);
        }
    }
}

Methods for opening and closing pages:

public static void OpenPageUrl(string route)
{
    if (_screensStack.Count > 0)
    {
        string url = _screensStack.Peek();
        if (url.Contains(separator))
        {
            url = url.Split(separator)[0];
        }
        _screensStack.Push(_routesPageData[url].Hide());
    }
    _screensStack.Push(route);
    
    var payload = ParseParams(route, out route);
    if (_routesPageData.ContainsKey(route))
    {
        var page = _routesPageData[route];
        page.Show(payload);
    }
    else
    {
        Debug.LogError($"There no route with name: {route}");
    }
}
public static void ReleaseLastPage()
{
    if (_screensStack.Count > 0)
    {
        _screensStack.Pop();
        if (_screensStack.Count >= 1)
        {
            string route = _screensStack.Peek();
            OpenPageUrl(route, true);
        }
        else
        {
            if (_mainScreenRoute != null) OpenPageUrl(_mainScreenRoute, true);
        }
    }
}
Full router code:
public static class UIRouter
{
    private static Stack<string> _screensStack = new Stack<string>();
    private static Dictionary<string, IUIPage> _routesPageData = new Dictionary<string, IUIPage>();
    private static string _mainScreenRoute = string.Empty;

    #region const
    // Чтобы синтаксис url был единообразным, явно указываем его через константы
    // Пример ссылки:
    // canvas.Name + slash + gameObj1.Name + separator + key + equally + value + and + key2 + equally + value2 = "Canvas/Name1?key=value&key2=value2"
    public const string slash = "/";
    public const string separator = "?";
    public const string and = "&";
    public const string equally = "=";
    #endregion

    // Чтобы не выбрасовало NullReferenceException первым обращением инициализируем поля.
    public static void ForceCreate()
    {
        if (_routesPageData == null || _screensStack == null)
        {
            _routesPageData = new Dictionary<string, IUIPage>();
            _screensStack = new Stack<string>();
            _mainScreenRoute = string.Empty;
        }        
    }

    public static void SubscribePath(string path, IUIPage page)
    {
        ParseParams(path, out path);
        if (!_routesPageData.ContainsKey(path))
        {
            _routesPageData.Add(path, page);
        }
    }

    public static void UnsubscribePath(string path)
    {
        ParseParams(path, out path);
        if (_routesPageData.ContainsKey(path))
        {
            _routesPageData.Remove(path);
        }
    }

    public static void SetMainScreenRoute(string route, bool forceAdd = false)
    {
        if (_mainScreenRoute == "" || forceAdd)
        {
            _mainScreenRoute = route;
            if (_screensStack.Count == 0)
            {
                _screensStack.Push(route);
            }
        }
    }

    public static void OpenPageUrl(string route)
    {
        if (_screensStack.Count > 0)
        {
            string url = _screensStack.Peek();
            if (url.Contains(separator))
            {
                url = url.Split(separator)[0];
            }
            _screensStack.Push(_routesPageData[url].Hide());
        }
        _screensStack.Push(route);
        
        var payload = ParseParams(route, out route);
        if (_routesPageData.ContainsKey(route))
        {
            var page = _routesPageData[route];
            page.Show(payload);
        }
        else
        {
            Debug.LogError($"There no route with name: {route}");
        }
    }

    public static void ReleaseLastPage()
    {
        if (_screensStack.Count > 0)
        {
            _screensStack.Pop();
            if (_screensStack.Count >= 1)
            {
                string route = _screensStack.Peek();
                OpenPageUrl(route);
            }
            else
            {
                if (_mainScreenRoute != null) OpenPageUrl(_mainScreenRoute);
            }
        }
    
    
    public static bool CheckNameForURL(string name)
    {
        if (name.Contains(slash) || name.Contains(separator) || name.Contains(and) || name.Contains(equally))
        {
            Debug.LogError("Name must not include url syntacis chars! Error with name " + name);
            return false;
        }
        return true;
    }

    private static Dictionary<string, string> ParseParams(string fullRoute, out string route)
    {
        string routeEnd = "";
        if (fullRoute.Contains(slash))
        {
            routeEnd = fullRoute.Split(slash)[^1];
        }
        else
        {
            routeEnd = fullRoute;
        }
        var result = new Dictionary<string, string>();
        var routeParams = routeEnd.Split(separator);
        if (routeParams.Length > 1)
        {
            var parameters = routeParams[1].Split(and);
            if (parameters.Length > 0)
            {
                foreach (var param in parameters)
                {
                    var data = param.Split(equally);
                    if (data.Length > 1)
                    {
                        result[data[0]] = data[1];
                    }
                }
            }
            route = fullRoute.Split(separator)[0];
        }
        else
        {
            route = fullRoute;
        }
        return result;
    }

}

Implementation of the IUIPages interface

Now let’s move on to the implementation of the interface IUIPages on the example of a page controller with a set of texts TextMeshPro:

public class PageWithTextController : MonoBehaviour, IUIPage
{
    #region Serialized Fields
    [SerializeField] private string url_ = "";
    [SerializeField] private List<TMP_Text> _Texts = new List<TMP_Text>();
    [SerializeField] private Dictionary<string, string> params_ = new Dictionary<string, string>();

    [SerializeField] public bool shouldWriteRuntimeChanges = true;
    #endregion

    #region Properties
    public string url 
    {
        get 
        {
            CreateURL(url_);
            return url_; 
        } 
    }
    public Dictionary<string, string> parametrs { get { return params_; } }
    #endregion

    #region Unity method
    private void OnDestroy()
    {
        Unsubscribe();
    }
    #endregion

    #region interface IUIPage implementation
    public void CreateURL(string url__)
    {
        if (url__.Contains(UIRouter.separator))
        {
            url__ = url__.Split(UIRouter.separator)[0];
        }
        if (shouldWriteRuntimeChanges || params_.Count == 0) { FindAndDefineChildsProperties(); }
        System.Text.StringBuilder builder = new System.Text.StringBuilder();
        builder.Append(url__);
        builder.Append(DictToString(params_));
        url_ = builder.ToString();
    }


    public void Show(Dictionary<string, string> data)
    {
        gameObject.SetActive(true);
        TryAssignProperties(data);
    }

    public string Hide()
    {
        CreateURL(url_);
        gameObject.SetActive(false);
        return url_;
    }

    public void Subscribe()
    {
        UIRouter.SubscribePath(url_, this);
    }
    public void Unsubscribe()
    {
        UIRouter.UnsubscribePath(url_);
    }
    public virtual void FindAndDefineChildsProperties()
    {
        FindAndSubscribeChildsText();
    }
    #endregion
}

In addition to implementing the interface, we also serialize fields and create properties so that we can change them in the editor using a custom inspector.

Let’s describe the work in more detail CreateURL: take only the address from the passed url__, shouldWriteRuntimeChanges is responsible for ensuring that each time the url property is accessed, actual data is collected from child objects, DictToString turns the parameter dictionary into a ready-made string for use in a url according to the specified syntax.

Also used StringBuilderso that there are no performance issues when working with large parameter strings.

Full PageWithTextController script:
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using UnityEditor;

public class PageWithTextController : MonoBehaviour, IUIPage
{
    #region Serialized Fields
    [SerializeField] private string url_ = "";
    [SerializeField] private List<TMP_Text> _Texts = new List<TMP_Text>();
    [SerializeField] private Dictionary<string, string> params_ = new Dictionary<string, string>();

    [SerializeField] public bool shouldWriteRuntimeChanges = true;
    #endregion

    #region Properties
    public string url 
    {
        get 
        {
            CreateURL(url_);
            return url_; 
        } 
    }
    public Dictionary<string, string> parametrs { get { return params_; } }
    #endregion

    #region Unity method
    private void OnDestroy()
    {
        Unsubscribe();
    }
    #endregion

    #region interface IUIPage implementation
    public void CreateURL(string url__)
    {
        if (url__.Contains(UIRouter.separator))
        {
            url__ = url__.Split(UIRouter.separator)[0];
        }
        if (shouldWriteRuntimeChanges || params_.Count == 0) { FindAndDefineChildsProperties(); }
        System.Text.StringBuilder builder = new System.Text.StringBuilder();
        builder.Append(url__);
        builder.Append(DictToString(params_));
        url_ = builder.ToString();
    }


    public void Show(Dictionary<string, string> data)
    {
        gameObject.SetActive(true);
        TryAssignProperties(data);
    }

    public string Hide()
    {
        CreateURL(url_);
        gameObject.SetActive(false);
        return url_;
    }

    public void Subscribe()
    {
        UIRouter.SubscribePath(url_, this);
    }
    public void Unsubscribe()
    {
        UIRouter.UnsubscribePath(url_);
    }
    #endregion

    /// <summary>
    /// Useful when you need to enable page through button
    /// </summary>
    public void ForceShow() { UIRouter.OpenPageUrl(url_); }


    public virtual void FindAndDefineChildsProperties()
    {
        FindAndSubscribeChildsText();
    }

    #region private methods

    protected void FindAndSubscribeChildsText()
    {
        _Texts = TextChildsCheck(gameObject);
        InitTextParametrs();
    }

    protected List<TMP_Text> TextChildsCheck(GameObject game)
    {
        if (UIRouter.CheckNameForURL(game.name))
        {
            List<TMP_Text> textList = new List<TMP_Text>(game.transform.childCount);
            List<GameObject> childs = GetAllChilds(game);
            foreach (GameObject child in childs)
            {
                TMP_Text tryText = null;
                if (child.TryGetComponent(out tryText))
                {
                    if (!textList.Contains(tryText) && UIRouter.CheckNameForURL(child.name))
                    {
                        textList.Add(tryText);
                    }
                    else
                    {
                        Debug.LogError(string.Format("All child object of IUIPage must have unique names! Not a unique name {}", child.name));
                    }
                }
                var childRes = TextChildsCheck(child);
                if (childRes != null)
                {
                    textList.AddRange(childRes);
                }
            }
            return textList;
        }
        return null;
    }

    protected void InitTextParametrs()
    {
        Dictionary<string, string> _params2 = new Dictionary<string, string>();
        foreach (TMP_Text tMP_ in _Texts)
        {
            if (tMP_ != null)
            {
                _params2.Add(tMP_.name, tMP_.text);
            }
        }
        params_ = _params2;
    }

    protected string DictToString(Dictionary<string, string> dict)
    {
        bool first = true;
        System.Text.StringBuilder builder = new System.Text.StringBuilder();
        foreach (string key in dict.Keys)
        {
            if (dict[key] != null)
            {
                if (first)
                {
                    first = false;
                    builder.AppendFormat(string.Format("{0}{1}{2}{3}", UIRouter.separator, key, UIRouter.equally, dict[key]));
                }
                else
                {
                    builder.AppendFormat(string.Format("{0}{1}{2}{3}", UIRouter.and, key, UIRouter.equally, dict[key]));
                }
            }
        }
        return builder.ToString();
    }

    protected virtual void TryAssignProperties(Dictionary<string, string> data)
    {
        foreach (TMP_Text text in _Texts)
        {
            string newText = text.text;
            if (data.TryGetValue(text.name, out newText))
            {
                text.text = newText;
                if (GUI.changed)
                {
                    EditorUtility.SetDirty(text);
                }
            }
        }
    }
    protected virtual void TryAssignProperties(string key, string value)
    {
        foreach (TMP_Text text in _Texts)
        {
            if (text.name == key)
            {
                string newText = string.Empty;
                if (params_.TryGetValue(text.name, out newText))
                {
                    text.text = newText;
                    if (GUI.changed)
                    {
                        EditorUtility.SetDirty(text);
                    }
                }
                return;
            }
        }
    }

    protected List<GameObject> GetAllChilds(GameObject game)
    {
        int children = game.transform.childCount;
        List<GameObject> childs = new List<GameObject>();
        for (int i = 0; i < children; ++i)
        {
            childs.Add(game.transform.GetChild(i).gameObject);
        }
        return childs;
    }
    #endregion
}

Many methods are specified with access modifiers protected And virtualso it is relatively easy to inherit from this class and extend the functionality, for example, by adding the ability to pass color to sprites.

CanvasManager

In my implementation, the url is determined by the location and name of the UI object relative to the parent canvas.

As a plus:

As cons:

  • All child objects of the same parent must have unique names.

  • Child object names must not contain url syntax characters.

Complete CanvasManager Code
public class CanvasManager : MonoBehaviour
{
    [SerializeField] private PageWithTextController mainPage;

    private List<IUIPage> uIPages = new List<IUIPage>();
    private static Dictionary<IUIPage, string> pagePathes = new Dictionary<IUIPage, string>();

    // Start is called before the first frame update
    private void Start()
    {
        UIRouter.ForceCreate();
        InitAllPages();
    }

    private void OnDestroy()
    {
        UnSubscribe();
    }

    public void InitAllPages()
    {
        List<IUIPage> allPagesInChilds = FindAllPagesInChilds(gameObject, gameObject.name);
        if (allPagesInChilds != null) { uIPages.AddRange(allPagesInChilds); }
        SubscribePages();
    }

    /// <summary>
    /// Чтобы не хардкодить адресс страниц, можно обращаться через этот метод и получать ссылки
    /// </summary>
    public static string GetLink(IUIPage page)
    {
        if (pagePathes.ContainsKey(page))
        {
            return pagePathes[page];
        }
        return null;
    }

    private void UnSubscribe()
    {
        foreach(var uIPage in uIPages)
        {
            uIPage.Unsubscribe();
        }
    }

    private List<IUIPage> FindAllPagesInChilds(GameObject game, string curPath)
    {
        if (UIRouter.CheckNameForURL(game.name))
        {
            string locPath = string.Empty;
            List<IUIPage> pageList = new List<IUIPage>(game.transform.childCount);
            List<GameObject> childss = GetAllChilds(game);
            foreach (GameObject child in childss)
            {
                IUIPage tryPage = null;
                if (child.TryGetComponent(out tryPage) && UIRouter.CheckNameForURL(child.name))
                {
                    locPath = UIRouter.slash + child.name;
                    if (pagePathes.ContainsValue(curPath + locPath))
                    {
                        Debug.LogError("All child objects of a canvas with an IUIPage must have unique names. Not a unique name " + child.name);
                        return null;
                    }
                    pagePathes.Add(tryPage, curPath + locPath);
                    pageList.Add(tryPage);
                }
                var childRes = FindAllPagesInChilds(child, curPath + locPath);
                if (childRes != null)
                {
                    pageList.AddRange(childRes);
                }
            }
            return pageList;
        }
        return null;
    }

    private void SubscribePages()
    {
        foreach (var page in uIPages) 
        {
            page.CreateURL(pagePathes[page]);
            page.Subscribe();
        }
        if (uIPages.Contains(mainPage))
        {
            UIRouter.SetMainScreenRoute(pagePathes[mainPage]);
        }
    }

    private List<GameObject> GetAllChilds(GameObject game)
    {
        int children = game.transform.childCount;
        List<GameObject> childs = new List<GameObject>();
        for (int i = 0; i < children; ++i)
        {
            childs.Add(game.transform.GetChild(i).gameObject);
        }
        return childs;
    }
}

Forced to be specified as the main page type PageWithTextController instead of IUIPage because the class must inherit from MonoBehaviour.

  • FindAllPagesInChilds goes through all gameObject canvases that implement the interface IUIPageschecks names for url syntax, records path information before them.

  • SubscribePages signs all pages in the router, if the page is specified as the main one in the inspector, then it becomes the main one in the router.

Custom inspectors

For the convenience of development, it seemed to me appropriate to add a custom parameter inspector. It will allow you to immediately see how the values ​​​​are defined as default, and how changing the parameters will change the page.

With CanvasManagerInspector, everything is simple:
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(CanvasManager))]
public class CanvasManagerInspector : Editor
{
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        var manager = target as CanvasManager;
        if (GUILayout.Button("Manually subscribe childs"))
        {
            manager.InitAllPages();
        }
    }
}

But with PageWithTextController , things are a little more complicated.

[CustomEditor(typeof(PageWithTextController))]
public class PageWithTextControllerInspector : Editor
{

    private Dictionary<string, string> dict = new Dictionary<string, string>();
    private PageWithTextController controller;
    
    private const int spaceSize = 5;

    public override void OnInspectorGUI()
    {
        
        controller = target as PageWithTextController;
        if (GUILayout.Button("Manually add and define parameters"))
        {
            controller.FindAndDefineChildsProperties();
            dict = new Dictionary<string, string>(controller.parametrs);
        }
        
        EditorGUILayout.Space(spaceSize);

        EditorGUIUtility.labelWidth = 240;
        controller.shouldWriteRuntimeChanges = EditorGUILayout.Toggle("Should record changes made at runtime", controller.shouldWriteRuntimeChanges);
        
        EditorGUILayout.Space(spaceSize);

        ShowDict(dict);

        EditorGUILayout.Space(spaceSize);

        if (GUILayout.Button("Update params values through editor"))
        {
            controller.Show(dict);
        }

        serializedObject.ApplyModifiedProperties();

        if (GUI.changed)
        {
            EditorUtility.SetDirty(controller);
        }
    }

    private Dictionary<string, string> ShowDict(Dictionary<string, string> dictt)
    {
        Dictionary<string, string> newDict = new Dictionary<string, string>(dictt);
        foreach(string key in dictt.Keys)
        {
            EditorGUILayout.BeginHorizontal();

            GUILayout.Label(key + " = ");
            newDict[key] = EditorGUILayout.TextField(dictt[key]);

            EditorGUILayout.EndHorizontal();
        }
        dict = new Dictionary<string, string>(newDict);
        return newDict;
    }
}

Private variables are defined inside the class:

  • dict – a dictionary used to store parameters and their values.

  • controller – reference to an object of type PageWithTextControllerwhich will be edited in the inspector.

  • spaceSize – constant for setting the spacing between elements in the editor.

Method OnInspectorGUI() overridden from base class Editor. This method is called when the user interface is displayed in the Unity inspector.

  • First, a reference to the object being edited is obtained. controller.

  • If the “Manually add and define parameters” button is pressed, the method is called FindAndDefineChildsProperties() at controllerthen the contents of the dictionary parametrs object controller copied to dictionary dict.

  • The “Should record changes made at runtime” button is displayed, allowing you to enable/disable the recording of changes made at runtime.

  • Method is called ShowDict() to display parameters and their values. Since the unit does not have a default way to display dictionaries in the inspector, we do it ourselves. In order not to cause errors when foreachnew values ​​are first written to the buffer field.

  • The “Update params values ​​through editor” button is displayed, which calls the method Show(dict) at controller.


conclusions

Flaws:

  • to pass various parameters, you will have to write extensions IUIPage and support them.

  • Non-fool-proof system, other scripts need to know the parameters they can pass. You have to hardcode occasionally.

  • In the script that controls the transfer of model states, you need to: either keep a link to the implementation instance IUIPageto refer to CanvasManager.GetLink(IUIPage)receive a link through the method before implementation. Or hardcode the link again.

  • No support for asynchrony (which is not so critical for rare interface change operations)

  • If for some reason you need to know when the window for animations was opened (and not when the data is ready to open the window), then you will have to supplement the functionality.

If there are any other cons, you can add me in the comments.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *