3

Context

So after researching a bit about UI toolkit, i decided to use it for my project as i am very used to CSS and i despise the "default/legacy" UI system Unity3D offers.

Unfortunately its on a very early stage of development and they seem to be taking it in a "editor GUI" direction. Regardless i choose it as it made designing the UI, way faster than i could ever do with the usual "Unity3D UI" method of doing things.

Objective

My objective is relatively simple, i want to create a behavior that updates the UI based on some variable while hiding/abstracting this behavior(in other words i want to bind the value of the UI to some integer on a data class).

In the future this will be further complicated due to me slowly migrating everything to a multiplayer context

Main Problem

After hours of searching/reading the documentation, and brute forcing my way into the problem, i managed to reach to a simple solution, which i will abreviate due to me hardcoding some very similar behaviour:

GameObject trackCanvas = GameObject.Find("Canvas");
UIDocument docum= trackCanvas.GetComponent<UIDocument>();
Label foodUI = docum.rootVisualElement.Q<Label>(name: "FoodVar");
        
if(playerResources!= null){
    SerializedObject pR = new SerializedObject(playerResources);
                
    SerializedProperty foodProp = pR.FindProperty("food");
                
    foodUI.BindProperty(foodProp);
                
}else{
    foodUI.Unbind();
}

Pretty simple solution and better yet i saved some thinking time. It all worked like a charm until i try to build it and... I see multiple errors relating to importing UnityEditor which i started removing it(since i pretty much import everything and only on CleanUp i start to see whats necessary or not)

Unfortunately on this very script i couldnt and after rereading the documentation on the fine print(which wasnt so fine...) i read that SerializedObject can't be used in "production/runtime/build" version because it depended on UnityEditor which wouldnt exist on the finalized product.

Which really annoyed me because it was a very eloquent solution.

Suffix

The manual seems to suggest there is ways to go around using UnityEditor namespace. Unfortunately from their very vague tutorial i wasnt able to figure out how it works(im mentioning the tank example which only seems to use unityeditor because they wanted to be able to bind stuff on edit mode, while the binding itself seems to be done through uxml)

I've tried a few things but it all seemed out of context like having some serializedField would magically bind with uxml just because the binding path was the same as variable name

Then i thought well if unity doesnt want me to use editor stuff in runtime mode ill just force it so It shouldnt be that hard to just copy paste some of its class's and then somehow hack it. Unfortunetely Unity not only has a strict proprietary license that doesnt allow you to modify its software in anyway, but some of the annotations, functions, etc... were protected(especially the C stuff that they use)

Then i thought about doing it by hand and i arrived at two options:

  1. Just put food.value = resources.food in some kind of update and hope it doesnt create any kind of issues when i migrate it to a multiplayer context

  2. Or do something more complicated like some kind of delegate i would call that would update the UI, being more efficient in theory due to me only updating whats needed to.

Since im doing an RTS, i think the values will be changing constantly, so im very divided on both. Making me want to stick to the solution that was already done

This is all the more stressful when i hate how the documentation is structured, how difficult it is to go around the source code, and the worse how it feels like the documentation goes in length for behavior that is very similar to CSS

TL;DR:

Is there an alternative to BindProperty() in UI toolkit that doesn't rely on Unity Editor?

Imeguras
  • 436
  • 6
  • 18

2 Answers2

4

You could create a wrapper class to hold your values, which could invoke an event whenever the wrapped value changes.

public interface IProperty<T> : IProperty
{
    new event Action<T> ValueChanged;
    new T Value { get; }
}

public interface IProperty
{
    event Action<object> ValueChanged;
    object Value { get; }
}

[Serializable]
public class Property<T> : IProperty<T>
{
    public event Action<T> ValueChanged;
    
    event Action<object> IProperty.ValueChanged
    {
        add => valueChanged += value;
        remove => valueChanged -= value;
    }
    
    [SerializeField]
    private T value;
    
    public T Value
    {
        get => value;
        
        set
        {
            if(EqualityComparer<T>.Default.Equals(this.value, value))
            {
                return;
            }
            
            this.value = value;
            
            ValueChanged?.Invoke(value);
            valueChanged?.Invoke(value);
        }
    }
    
    object IProperty.Value => value;

    private Action<object> valueChanged;
    
    public Property(T value) => this.value = value;
    
    public static explicit operator Property<T>(T value) => new Property<T>(value);
    public static implicit operator T(Property<T> binding) => binding.value;
}

After this you could create custom extension method similar to Unity's own BindProperty which works with this wrapper instead of a SerializedProperty.

public static class RuntimeBindingExtensions
{
    private static readonly Dictionary<VisualElement, List<(IProperty property, Action<object> binding)>> propertyBindings = new Dictionary<VisualElement, List<(IProperty property, Action<object> binding)>>();

    public static void BindProperty(this TextElement element, IProperty property)
    {
        if(!propertyBindings.TryGetValue(element, out var bindingsList))
        {
            bindingsList = new List<(IProperty, Action<object>)>();
            propertyBindings.Add(element, bindingsList);
        }

        Action<object> onPropertyValueChanged = OnPropertyValueChanged;
        bindingsList.Add((property, onPropertyValueChanged));

        property.ValueChanged += onPropertyValueChanged;
        
        OnPropertyValueChanged(property.Value);
        
        void OnPropertyValueChanged(object newValue)
        {
            element.text = newValue?.ToString() ?? "";
        }
    }

    public static void UnbindProperty(this TextElement element, IProperty property)
    {
        if(!propertyBindings.TryGetValue(element, out var bindingsList))
        {
            return;
        }

        for(int i = bindingsList.Count - 1; i >= 0; i--)
        {
            var propertyBinding = bindingsList[i];
            if(propertyBinding.property == property)
            {
                propertyBinding.property.ValueChanged -= propertyBinding.binding;
                bindingsList.RemoveAt(i);
            }
        }
    }

    public static void UnbindAllProperties(this TextElement element)
    {
        if(!propertyBindings.TryGetValue(element, out var bindingsList))
        {
            return;
        }

        foreach(var propertyBinding in bindingsList)
        {
            propertyBinding.property.ValueChanged -= propertyBinding.binding;
        }

        bindingsList.Clear();
    }
}

Usage:

public class PlayerResources
{
    public Property<int> food;
}

if(playerResources != null)
{
    foodUI.BindProperty(playerResources.food);
}

UPDATE: Added also extension methods for unbinding properties and made BindProperty immediately update the text on the element.

Sisus
  • 651
  • 4
  • 8
  • 1
    This is exaclty what i want and more, with minor alterations i achieved a very similar result to what i was looking for, to note is that foodUI.Unbind() is not specified in the answer and if my few cents on C# are of any worth you need a reference to the original assignment of the event Another thing is that i changed bind property so it ran OnPropertyValueChanged once so that they would sync up on bind instead of having to change at least once to create an update Altough this serves for my personal purpose i will wait a week or so, to see if there is any native alternatives – Imeguras Sep 04 '22 at 15:07
  • as described in the problem for other people that might stumble on this question. If noone provides an answer in said week i will accept this answer as it personally help me greatly reach the solution. – Imeguras Sep 04 '22 at 15:08
  • Oh yes, you are completely right about also needing to add some sort of custom version of Unbind. – Sisus Sep 04 '22 at 15:22
  • I extended RuntimeBindingExtensions with a solution for also unbinding properties. – Sisus Sep 04 '22 at 16:03
  • closing of this question – Imeguras Sep 08 '22 at 10:03
0

My way, maybe not the better, the class implement directly the binding solution and use c# reflection, the binding is two way with binding path (tested with label, inputfield and toggle on Editor/unity), and will try to match with all children of the visual element :

the base class (inspired by previous post) :

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UIElements;

public interface INotifyProperty
{
    event UnityAction<object, string> OnValueChanged;
    void UpdateValue(string fieldName, object value);
    void Bind(VisualElement visualElement);
}

public class NotifyProperty : INotifyProperty
{
    Dictionary<string, VisualElement> bindingPaths = new Dictionary<string, VisualElement>();

    public event UnityAction<object, string> OnValueChanged;
    public List<FieldInfo> fields = new List<FieldInfo>();
    public List<PropertyInfo> propertyInfos = new List<PropertyInfo>();
    public virtual void Bind(VisualElement element)
    {
        fields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance).ToList();
        propertyInfos = GetType().GetProperties().ToList();
        bindingPaths = GetBindingPath(element);

        // permit to affect value to bind element
        propertyInfos.ForEach(p =>
        {
            p.SetValue(this, p.GetValue(this));
        });

    }

    public virtual void NotifyValueChanged<T>(T newValue, [CallerMemberName] string property = "")
    {
        OnValueChanged?.Invoke(newValue, property);
        ValueChanged(newValue, property);
    }

    private void ValueChanged<T>(T arg0, string arg1)
    {
        if (bindingPaths.TryGetValue(arg1.ToLower(), out var element))
        {
            if (element is INotifyValueChanged<T> notifElement)
            {
                notifElement.value = arg0;
            }
            else if (element is INotifyValueChanged<string> notifString)
            {
                notifString.value = arg0?.ToString() ?? string.Empty;
            }
        }
    }


    public Dictionary<string, VisualElement> GetBindingPath(VisualElement element, Dictionary<string, VisualElement> dico = null)
    {
        if (dico == null)
        {
            dico = new Dictionary<string, VisualElement>();
        }

        if (element is BindableElement bindElement)
        {
            if (!string.IsNullOrEmpty(bindElement.bindingPath))
            {
                dico.Add(bindElement.bindingPath.ToLower(), bindElement);


                if (fields.Exists(f => f.Name == bindElement.bindingPath.ToLower()))
                {


                    // we register callback only for matching path, add other callback if you want support other control
                    bindElement.RegisterCallback<ChangeEvent<string>>((val) =>
                    {
                        UpdateValue(bindElement.bindingPath, val.newValue);
                    });

                    bindElement.RegisterCallback<ChangeEvent<bool>>((val) =>
                    {
                        UpdateValue(bindElement.bindingPath, val.newValue);
                    });
                }

            }

            if (element.childCount > 0)
            {
                foreach (var subElement in element.Children())
                {
                    GetBindingPath(subElement, dico);
                }
            }
        }
        else
        {
            if (element.childCount > 0)
            {
                foreach (var subElement in element.Children())
                {
                    GetBindingPath(subElement, dico);
                }
            }
        }
        return dico;
    }

    public virtual void UpdateValue(string fieldName, object value)
    {
        fields.FirstOrDefault(f => f.Name == fieldName)?.SetValue(this, value);
    }
}

the implemention :

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;

public class LoginProperties : NotifyProperty
{
    private string email;
    public string Email
    {
        get { return email; }
        set
        {
            email = value;
            NotifyValueChanged(value);
        }
    }
    private string status;
    public string Status
    {
        get { return status; }
        set
        {
            status = value;
            NotifyValueChanged(value);
        }
    }

    private bool save;
    public bool Save
    {
        get { return save; }
        set
        {
            save = value;
            NotifyValueChanged(value);
        }
    }
}

the uxml

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" xsi="http://www.w3.org/2001/XMLSchema-instance" engine="UnityEngine.UIElements" editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd" editor-extension-mode="False">
    <Style src="project://database/Assets/UI/Style.uss?fileID=7433441132597879392&amp;guid=710cabf677cea49439f4f5c169631b72&amp;type=3#Style" />
    <ui:VisualElement style="padding-left: 10px; padding-right: 10px; padding-top: 20px; padding-bottom: 20px; flex-grow: 1; justify-content: flex-start;">
        <ui:VisualElement style="align-items: center; justify-content: flex-end; margin-top: 100px; margin-bottom: 10px;">
            <ui:Label text="Hero" display-tooltip-when-elided="true" style="font-size: 100px;" />
            <ui:VisualElement style="width: 205px; height: 100px; background-image: url(&apos;project://database/Assets/publish/background.png?fileID=2800000&amp;guid=d21a7e330f5e8964ca379e4865e84764&amp;type=3#background&apos;);" />
        </ui:VisualElement>
        <ui:VisualElement />
        <ui:VisualElement style="flex-grow: 1; align-items: stretch; justify-content: flex-start;">
            <ui:Label text="status" display-tooltip-when-elided="true" binding-path="status" name="lblStatus" style="font-size: 24px; margin-bottom: 30px;" />
            <ui:TextField picking-mode="Ignore" label="Email :" binding-path="email" name="txtLogin" style="flex-direction: column;" />
            <ui:Toggle usage-hints="None" value="true" text=" Save email" binding-path="save" name="tgSave" class="toggle" style="flex-direction: row; align-items: center; justify-content: flex-start; flex-grow: 0; flex-shrink: 0; padding-bottom: 30px; padding-top: 20px;" />
            <ui:Button text="Connect" display-tooltip-when-elided="true" name="btnConnect" style="background-color: rgba(0, 0, 50, 0.71);" />
        </ui:VisualElement>
    </ui:VisualElement>
</ui:UXML>

usage :

using Assets.Service;
using HeroModels;
using link.magic.unity.sdk;
using Nethereum.RPC.Eth;
using Nethereum.Signer;
using Nethereum.Web3.Accounts;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UIElements;

public class LoginManager : MonoBehaviour
{
    VisualElement root;
    Button btnConnect;
    Label lblStatus;

    private string keyPass = "logkey";
    private string keyLogin = "log";

    PlayerService playerService;

    LoginProperties loginProperties = new LoginProperties();

    // Start is called before the first frame update
    void Start()
    {
        root = GetComponent<UIDocument>().rootVisualElement;
        btnConnect = root.Q<Button>();
        lblStatus = root.Q<Label>("lblStatus");

        loginProperties.Bind(root);
        loginProperties.Save = true;

        btnConnect.clicked += BtnConnect_clicked;

        if (PlayerPrefs.HasKey(keyLogin))
        {
            loginProperties.Email = PlayerPrefs.GetString(keyLogin);
        }

    }

    private void BtnConnect_clicked()
    {
        Login();
    }

    private void PlayerService_PlayerLoaded(object sender, HeroModels.PlayerDto e)
    {
        GameContext.Instance.Player = e;
        SceneManager.LoadScene("Map");
    }

    // Update is called once per frame
    void Update()
    {

    }

    public async void Login()
    {
        try
        {
            loginProperties.Status = "Login ...";
            lblStatus.style.color = Color.white;
            if (loginProperties.Save)
            {
                PlayerPrefs.SetString(keyLogin, loginProperties.Email);
                //PlayerPrefs.SetString(keyPass, password?.text);
            }
            else
            {
                PlayerPrefs.SetString(keyLogin, string.Empty);
                //PlayerPrefs.SetString(keyPass, string.Empty);
            }
            PlayerPrefs.Save();

            // top secret code hidden


        }
        catch (System.Exception ex)
        {
            loginProperties.Status = ex.Message;
            lblStatus.style.color = Color.red;
        }

    }

    private async Task<string> Sign(string account, string message)
    {
        var personalSign = new EthSign(Magic.Instance.Provider);
        var res = await personalSign.SendRequestAsync(account, message);
        return res;
    }

    private void PlayerService_PlayerNotExist(object sender, System.EventArgs e)
    {
        SceneManager.LoadScene("CreatePlayerScene");
    }
}

youtpout
  • 95
  • 1
  • 9