Как стать автором
Обновить

UI Router в Unity + CustomEditor

Уровень сложностиСредний
Время на прочтение14 мин
Количество просмотров2.1K
(Не является демонстрацией создания роутера)
(Не является демонстрацией создания роутера)

Дисклеймер

Мой роутер идейно копирует роутер из статьи, однако данная там реализация показалось мне не оптимальной и не удобной в практическом использовании. Я опишу работу своей реализации + CustomEditor, без аттрибутов и SendMessage.

Сначала я буду приводить основные куски кода, а затем в спойлере полную версию скрипта со всеми методами, чтобы слишком не растягивать статью.


Концепция

Зачастую, разработка сложных пользовательских интерфейсов приводит к появлению словарей, енамов, состояний интерфейса, чтобы грамотно следить за тем что происходит. Однако, иногда это может противоречить идеологии, что интерфейс должен лишь отображать состояние, не изменяя его.

Для систематизации работы интерфейса предлагается заимствовать опыт из веб разработки:

  1. Все экраны имеют уникальный url.

  2. Вся необходимая информация для вывода на экране передаётся в виде параметров как часть ссылки.

  3. Команды окнам передаются через статический класс, который хранит историю окон в стэке аналогично браузеру.

Благодаря этому мы имеем:

  • Элементарную поддержку back. В браузере чаще всего интерфейс проектируется, чтобы начальное состояние его открытия определялось параметрами ссылки. Поэтому каждый раз, когда мы нажимаем back, то мы просто снова открываем экран, который открывали до этого с теми же параметрами и получаем тот же результат.

  • Избегаем зависимостей между окнами.

Пишем роутер

Создаём стэк для хранения истории страниц, словарь для хранения экземпляров страниц, а также путь до главной страницы и константы синтаксиса:

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 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();
}

Чтобы считывать значения передаваемые в виде параметров в url, в классе UIRouter пишем парсер:

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;
}

Он разделяет ссылку на две части: адресс и параметры при помощи separator. Сами параметры отделяют друг от друга по знаку and, а ключи от значений по знаку equally. Получившийся словарь из ключей и значений пораметров возвращает через return, а во второй аргумент кладёт часть url отвечающую за адрес.

Теперь мы можем "подписывать" страницы по их адрессу в url, не боясь записать адресс с переменными параметрами.

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, true);
        }
        else
        {
            if (_mainScreenRoute != null) OpenPageUrl(_mainScreenRoute, true);
        }
    }
}
Полный код роутера:
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;
    }

}

Имплементация интерфейса IUIPages

Теперь перейдём к имплементации интерфейса IUIPages на примере контроллера страницы с набором текстов 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
}

Кроме имплементации интерфейса, мы также сериализуем поля и создаём свойства, чтобы можно было изменять их в редакторе при помощи кастомного инспектора.

Подробнее опишем работу CreateURL: берётся только адрес из переданного url__, shouldWriteRuntimeChanges отвечает за то, чтобы при каждом обращение к свойству url собирались актуальные данные из дочерних объектов, DictToString превращает словарь параметров в готовую строку для использования в url согласно указанному синтаксису.

Также используется StringBuilder, чтобы при работе с большими строками параметров не возникало проблем с производительностью.

Полный скрипт PageWithTextController:
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
}

Многие методы указаны с модификаторами доступа protected и virtual, поэтому относительно легко можно унаследоваться от этого класса и расширить функционал, например, добавив возможность передавать цвет спрайтам.

CanvasManager

В моей реализации адрес в url определяется местоположением и именем UI объекта относительно родительского канваса.

В качестве плюса:

  • все адреса определяются автоматически, просто отследить конкретный объект для дебага.

В качестве минусов:

  • Все дочерние объекты одного родителя должны иметь уникальные имена.

  • В именах дочерних объетов не должно содержаться символов синтаксиса url.

Полный код CanvasManager
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;
    }
}

В качестве типа главной страницы вынуждено указан PageWithTextController вместо IUIPage потому, что класс должен наследоваться от MonoBehaviour.

  • FindAllPagesInChilds проходит по всем gameObject канваса, которые реализуют интерфейс IUIPages, проверяет имена на наличие синтаксиса url, записывает информацию о пути до них.

  • SubscribePages подписывает все страницы в роутере, если страница указана как главная в инспекторе, то делается главной и в роутере.

Кастомные инспекторы

Для удобства разработки, мне показалось целесообразным добавить кастомный инспектор параметров. Он позволит сразу посмотреть, каким значения определятся как дефолтные, и каким образом изменение параметров изменит страницу.

С CanvasManagerInspector всё просто:
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();
        }
    }
}

А вот с PageWithTextController всё немного сложнее.

[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;
    }
}

Внутри класса определены приватные переменные:

  • dict - словарь, используемый для хранения параметров и их значений.

  • controller - ссылка на объект типа PageWithTextController, который будет редактироваться в инспекторе.

  • spaceSize - константа для задания промежутка между элементами в редакторе.

Метод OnInspectorGUI() переопределен из базового класса Editor. Этот метод вызывается при отображении пользовательского интерфейса в инспекторе Unity.

  • Сначала получается ссылка на редактируемый объект controller.

  • Если нажата кнопка "Manually add and define parameters", вызывается метод FindAndDefineChildsProperties() у controller, затем содержимое словаря parametrs объекта controller копируется в словарь dict.

  • Отображается кнопка "Should record changes made at runtime", позволяющая включить/отключить запись изменений во время выполнения.

  • Вызывается метод ShowDict() для отображения параметров и их значений. По скольку в юнити нет дефолтного способа выводит словари в инспектр, мы это делаем самостоятельно. Чтобы не вызывать ошибок при foreach, сперва новые значения пишутся в буферное поле.

  • Отображается кнопка "Update params values through editor", которая вызывает метод Show(dict) у controller.


Выводы

Недостатки:

  • для передачи различных параметров придётся писать расширения IUIPage и поддерживать их.

  • Система без защиты от дурака, другие скрипты должны знать параметры возможные для передачи. Придётся периодически хардкодить.

  • В управляющем передачей состояний модели скрипте нужно: либо держать ссылку на экземпляр имплементации IUIPage, чтобы обращаясь к CanvasManager.GetLink(IUIPage)получать через метод ссылку до имплементации. Либо снова хардкодить ссылку.

  • Нет поддержки асинхронности (что для редких операций смены интерфейса не так критично)

  • Если вам нужно зачем-то знать когда открылось окно для анимаций (а не когда готовы данные для открытия окна), то придётся дополнять функционал.

Если есть ещё какие-то минусы, можете дополнить меня в комментариях.

Теги:
Хабы:
Всего голосов 1: ↑1 и ↓0+1
Комментарии0

Публикации

Истории

Работа

Ближайшие события