Skip to content

Instantly share code, notes, and snippets.

@andrew-raphael-lukasik
Last active November 4, 2024 06:03
Show Gist options
  • Save andrew-raphael-lukasik/72a4d3d14dd547a1d61ae9dc4c4513da to your computer and use it in GitHub Desktop.
Save andrew-raphael-lukasik/72a4d3d14dd547a1d61ae9dc4c4513da to your computer and use it in GitHub Desktop.
Text localization script for UIDocument (UI Toolkit @ Unity)

pattern to follow

// NOTE: this class assumes that you designate StringTable keys in label fields (as seen in Label, Button, etc) // and start them all with '#' char (so other labels will be left be)

Programming workflow:

Binding UIDocument, normally, happened at OnEnable but since StringTable can be (re)loaded at any time then we need to switch to using events.

GetComponent<UIDocumentLocalization>().onCompleted += (root) => Debug.Log("let's bind a freshly localized UI!");

I added a big remainder so ppl not miss this:

Screenshot 2023-08-28 001053

using UnityEngine;
using UnityEngine.UIElements;

[DisallowMultipleComponent]
[RequireComponent( typeof(UIDocumentLocalization) )]
public class MainMenuController : MonoBehaviour
{
    void OnEnable () => GetComponent<UIDocumentLocalization>().onCompleted += Bind;
    void OnDisable () => GetComponent<UIDocumentLocalization>().onCompleted -= Bind;
    void Bind ( VisualElement root )
    {
        /* bind here */
    }
}

Works well with:

/// void* src = https://gist.github.com/andrew-raphael-lukasik/72a4d3d14dd547a1d61ae9dc4c4513da
///
/// Copyright (C) 2022 Andrzej Rafał Łukasik (also known as: Andrew Raphael Lukasik)
///
/// This program is free software: you can redistribute it and/or modify
/// it under the terms of the GNU General Public License as published by
/// the Free Software Foundation, version 3 of the License.
///
/// This program is distributed in the hope that it will be useful,
/// but WITHOUT ANY WARRANTY; without even the implied warranty of
/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
/// See the GNU General Public License for details https://www.gnu.org/licenses/
///
using UnityEngine;
using UnityEngine.UIElements;
using UnityEngine.Localization;
using UnityEngine.Localization.Tables;
using UnityEngine.ResourceManagement.AsyncOperations;
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.UIElements;
#endif
// NOTE: this class assumes that you designate StringTable keys in label fields (as seen in Label, Button, etc)
// and start them all with '#' char (so other labels will be left be)
// example: https://i.imgur.com/H5RUIej.gif
[HelpURL("https://gist.github.com/andrew-raphael-lukasik/72a4d3d14dd547a1d61ae9dc4c4513da")]
[DisallowMultipleComponent]
[RequireComponent(typeof(UIDocument))]
public class UIDocumentLocalization : MonoBehaviour
{
[SerializeField] LocalizedStringTable _table = null;
UIDocument _uiDocument;
/// <summary> Executed after hierarchy is cloned fresh and translated. </summary>
public event System.Action<VisualElement> onCompleted = ( VisualElement root ) =>
{
#if DEBUG
Debug.Log($"{nameof(UIDocumentLocalization)}: {nameof(UIDocument)} translated");
#endif
};
void OnEnable ()
{
if( _uiDocument == null )
_uiDocument = GetComponent<UIDocument>();
_table.TableChanged += OnTableChanged;
}
void OnDisable ()
{
_table.TableChanged -= OnTableChanged;
}
void OnTableChanged ( StringTable table )
{
_uiDocument.rootVisualElement.Clear();
_uiDocument.visualTreeAsset.CloneTree(_uiDocument.rootVisualElement);
#if DEBUG
Debug.Log($"{nameof(UIDocumentLocalization)}: {nameof(StringTable)} changed, {nameof(VisualTreeAsset)} has been cloned anew" , _uiDocument);
#endif
var op = _table.GetTableAsync();
if( op.IsDone )
{
OnTableLoaded(op);
}
else
{
op.Completed -= OnTableLoaded;
op.Completed += OnTableLoaded;
}
}
void OnTableLoaded ( AsyncOperationHandle<StringTable> op )
{
StringTable table = op.Result;
LocalizeChildrenRecursively(_uiDocument.rootVisualElement , table);
_uiDocument.rootVisualElement.MarkDirtyRepaint();
onCompleted(_uiDocument.rootVisualElement);
}
void LocalizeChildrenRecursively ( VisualElement element , StringTable table )
{
VisualElement.Hierarchy elementHierarchy = element.hierarchy;
int numChildren = elementHierarchy.childCount;
for( int i = 0 ; i < numChildren ; i++ )
{
VisualElement child = elementHierarchy.ElementAt(i);
Localize(child , table);
}
for( int i = 0 ; i < numChildren ; i++ )
{
VisualElement child = elementHierarchy.ElementAt(i);
VisualElement.Hierarchy childHierarchy = child.hierarchy;
int numGrandChildren = childHierarchy.childCount;
if( numGrandChildren != 0 )
LocalizeChildrenRecursively(child , table);
}
}
void Localize ( VisualElement next , StringTable table )
{
if( typeof(TextElement).IsInstanceOfType(next) )
{
TextElement textElement = (TextElement)next;
string key = textElement.text;
if( !string.IsNullOrEmpty(key) && key[0] == '#' )
{
key = key.TrimStart('#');
StringTableEntry entry = table[key];
if( entry != null )
textElement.text = entry.LocalizedValue;
else
Debug.LogWarning($"No {table.LocaleIdentifier.Code} translation for key: '{key}'");
}
}
}
#if UNITY_EDITOR
[CustomEditor(typeof(UIDocumentLocalization))]
public class MyEditor : Editor
{
public override VisualElement CreateInspectorGUI ()
{
var ROOT = new VisualElement();
var LABEL = new Label($"- Remember -<br> Use <color=\"yellow\">{nameof(onCompleted)}</color> event instead of <color=\"yellow\">OnEnable()</color><br>to localize and bind this document correctly.");
{
var style = LABEL.style;
style.minHeight = EditorGUIUtility.singleLineHeight * 3;
style.backgroundColor = new Color(1f , 0.121f , 0 , 0.2f);
style.borderBottomLeftRadius = style.borderBottomRightRadius = style.borderTopLeftRadius = style.borderTopRightRadius = 6;
style.unityTextAlign = TextAnchor.MiddleCenter;
}
ROOT.Add(LABEL);
InspectorElement.FillDefaultInspector(ROOT , this.serializedObject , this);
return ROOT;
}
}
#endif
}
@julian-a-avar-c
Copy link

It's incredibly useful, and i want to give you the appropriate credit for it, so I would very much appreciate a specific license.

@julian-a-avar-c
Copy link

@Morigun
Copy link

Morigun commented Apr 26, 2023

Hello, I found a bug in this script. I create a script, connect it to GameObject with UIDocument component. Also connected to this GameObject is a MonoBehaviour script which receives the UIDocument in the OnEnable/Start method and registers callbacks for button presses. If the UIDocumentLocalization script is enabled, the button press event does not work, if the UIDocumentLocalization script is disabled, it works correctly.

SEE DETAILS

Localisation files:
image
Localisation table:
image
Scripts:
image
File uxml:
image
Menu:
image
A script for button handling:
image
Unity version:
image
Video:
https://user-images.githubusercontent.com/11372913/234570163-3aa2be1c-8003-402a-b8f1-8738fe21eb69.mp4

@Morigun
Copy link

Morigun commented Apr 26, 2023

The current fix was made through an abstract class from which the classes that implement the button logic are inherited. I implemented an abstract method that is called after cloning the tree in the OnTableChanged method.

SEE DETAILS

image
image
image

@VladTempest
Copy link

VladTempest commented Jun 4, 2023

The current fix was made through an abstract class from which the classes that implement the button logic are inherited. I implemented an abstract method that is called after cloning the tree in the OnTableChanged method.

SEE DETAILS
image image image

First of all, awesome script, Andrew! Very useful stuff!
However, there is a problem with it - deleting and cloning the visual tree when the table changes. This leads to losing the event subscribers on the button, which was very problematic in my case. So, I made this fix: I deleted the delete and clone functionality and added simple caching of initial values for text elements with # symbols

MODIFIED UIDocumentLocalization.cs HERE
[DisallowMultipleComponent]
[RequireComponent(typeof(UIDocument))]
public class UIDocumentLocalization : MonoBehaviour
{
    private Dictionary<VisualElement,string> _originalTexts = new Dictionary<VisualElement, string>();
    
    [SerializeField] LocalizedStringTable _table = null;
    UIDocument _uiDocument;
    
    /// <summary> Executed after hierarchy is cloned fresh and translated. </summary>
    public event System.Action onCompleted = () => {Debug.Log("Completed");};
    void OnEnable()
    {
	    if (_uiDocument == null)
		    _uiDocument = GetComponent<UIDocument>();
	    _table.TableChanged += OnTableChanged;
    }
    
    void OnDisable()
    {
	    _table.TableChanged -= OnTableChanged;
    }
    
    void OnTableChanged(StringTable table)
    {
	    ConvenientLogger.Log(nameof(UIDocumentLocalization),GlobalLogConstant.IsLocalizeLogEnabled,$"{nameof(StringTable)} changed, {nameof(VisualTreeAsset)} has been cloned anew",
		    _uiDocument);
    
	    var op = _table.GetTableAsync();
	    if (op.IsDone)
	    {
		    OnTableLoaded(op);
	    }
	    else
	    {
		    op.Completed -= OnTableLoaded;
		    op.Completed += OnTableLoaded;
	    }
    }
    
    void OnTableLoaded(AsyncOperationHandle<StringTable> op)
    {
	    StringTable table = op.Result;
	    LocalizeChildrenRecursively(_uiDocument.rootVisualElement, table);
	    _uiDocument.rootVisualElement.MarkDirtyRepaint();
	    onCompleted();
    }
    
    void LocalizeChildrenRecursively(VisualElement element, StringTable table)
    {
	    VisualElement.Hierarchy elementHierarchy = element.hierarchy;
	    int numChildren = elementHierarchy.childCount;
	    for (int i = 0; i < numChildren; i++)
	    {
		    VisualElement child = elementHierarchy.ElementAt(i);
		    Localize(child, table);
	    }
    
	    for (int i = 0; i < numChildren; i++)
	    {
		    VisualElement child = elementHierarchy.ElementAt(i);
		    VisualElement.Hierarchy childHierarchy = child.hierarchy;
		    int numGrandChildren = childHierarchy.childCount;
		    if (numGrandChildren != 0)
			    LocalizeChildrenRecursively(child, table);
	    }
    }
    
    void Localize(VisualElement next, StringTable table)
    {
	    if (_originalTexts.TryGetValue(next, out var text))
	    {
		    ((TextElement) next).text = text;
	    }
    
	    if (typeof(TextElement).IsInstanceOfType(next))
	    {
		    TextElement textElement = (TextElement) next;
		    string key = textElement.text;
		    if (!string.IsNullOrEmpty(key) && key[0] == '#')
		    {
			    if (!_originalTexts.ContainsKey(textElement))
				    _originalTexts.Add(textElement, textElement.text);
			    
			    key = key.TrimStart('#');
			    StringTableEntry entry = table[key];
			    if (entry != null)
				    textElement.text = entry.LocalizedValue;						
		    }
	    }
    }

}

@andrew-raphael-lukasik
Copy link
Author

andrew-raphael-lukasik commented Aug 27, 2023

Hello @Morigun @VladTempest !
Thank you for your feedback, these are fair observations indeed. And because of that I added a big red box in the Inspector window to remind everyone (myself included!) to use onCompleted event and not OnEnable() method here.

Screenshot 2023-08-28 001053

using UnityEngine;
using UnityEngine.UIElements;

[DisallowMultipleComponent]
[RequireComponent( typeof(UIDocumentLocalization) )]
public class MainMenuController : MonoBehaviour
{
    void OnEnable () => GetComponent<UIDocumentLocalization>().onCompleted += Bind;
    void OnDisable () => GetComponent<UIDocumentLocalization>().onCompleted -= Bind;
    void Bind ( VisualElement root )
    {
        /* bind here */
    }
}

@DaniilVdovin
Copy link

@shadowbiz
Copy link

shadowbiz commented Jun 7, 2024

You have to change Script Execution Order in Project Settings to prevent UIDocumentLocalization's OnEnable firing before your Controller's OnEnable to ensure your Bind method will trigger properly

@andrew-raphael-lukasik
Copy link
Author

andrew-raphael-lukasik commented Jun 7, 2024

Hi @shadowbiz !
You can modify Script Execution Order from C# directly with [DefaultExecutionOrder( 100 )] attribute if you ever need that.
I, personally, never observer an issue with it in this context but I can definitely add this attribute to this template if you guys keep reporting it as an issue.

@JohnJohnSartain
Copy link

Maybe I'm doing it wrong but I added this method so that per event firing I can reload specific portions of the UI that need to be localized.

        public void LocalizeElement(VisualElement element)
        {
            var op = _table.GetTableAsync();
            if (op.IsDone)
            {
                LocalizeChildrenRecursively(element, op.Result);
                element.MarkDirtyRepaint();
            }
            else
            {
                op.Completed += (completedOp) =>
                {
                    LocalizeChildrenRecursively(element, completedOp.Result);
                    element.MarkDirtyRepaint();
                };
            }
        }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment