Unity:嘗試使用Zenject、Unity的Localization在地化插件與UIToolkit在一起

 


 

前陣子接觸到Unity的新的UI工具UIToolkit,讓我想到以前碰到網頁排版和樣式要寫CSS的日子。很好奇目前重製版專案裡的UI是否也能順利轉換到UIToolkit上,由於我UI在功能上有用到Zenject所以想趁著這陣子有空來測試看看,能不能順利轉換。

之所以寫這篇文章是因為網路上查到有用 UIToolkit和Zenject在一起的人並不多,所以想拋磚引玉看看有沒有人也有類似經驗可分享。

測試對象:一個會使用到Zenject和Localization功能的按鈕

測試的項目很簡單,挑一個比較有代表性的元素做測試。

我選擇的是一個帶有文字敘述和圖片的按鈕,這個按鈕其實是展示畫作圖片的按鈕。UGUI版本中是一個Prefab,上面有個元件可以指定這個按鈕的圖片和文字敘述,文字敘述會由Localization這個插件負責載入在地化版本的文本,而這個按鈕的功能很簡單,按下去後會使用Zenject的Signal發出訊息,會滿版顯示這個按鈕的圖片和文字敘述,以下的影片為UGUI版本的展示:

由於我選擇的按鈕文字敘述和圖片是綁在一起的, 所以我就用ScriptableObject包起來,裡面裝有LocalizedString和Sprite。其中有個事件,用來通知在地化文本的語言變更,UI的部分接收到通知後再取得最新的在地化文本。

怎麼寫客製化的VisualElement

針對怎麼寫一個客製化的VisualElement在官方的文件有簡單的說明,可以先看此:

我這邊客製化的VisualElement稱作ImageBoxElement,以下的程式也會用這個名字示範。

客製化的VisualElement裡面的內容可以透過程式添加,類似這樣:

ImageBoxElement.Add(new Label("NewLabel"));

或者是透過UIBuilder做出一個範本(類型是VisualTreeAsset),並透過在程式中載入的方式添加,有點像是在UGUI排好UI元素然後做成Prefab的概念。記住裡面會用程式控制的元素記得命名。

如下圖就是我製作的範本,稱作ImageBox:



載入並動態生成的方法,我是用Addressables載入:

public class ImageBoxElement : VisualElement
{
    //_paintData是儲存畫作資料的ScriptableObject,怎麼給就看個人做法
    private PaintingData _paintData;
    private Label _header = default;
    private VisualElement _image = default;
    private Label _name = default;
}
    public ImageBoxElement()
    {
		//從Addressables載入製作好的UI範本
		VisualTreeAsset tree = Addressables.LoadAsset<VisualTreeAsset>("ImageBox").WaitForCompletion();
		//從範本裡面複製一份副本到客製化的VisualElement裡
		tree.CloneTree(this);
		//把底下有關文本和圖片的VisualElement存起來,提供之後修改內容使用
		_header = this.Q<Label>("Header");
		_image = this.Q<VisualElement>("Image");
		_name = this.Q<Label>("Name");
    }
}

如此一來客製化的VisualElement裡面就會有在UIBuilder做的ImageBox裡所含的UI。而使用上跟一般內建的VisualElement差不多,可以用程式添加或是在UIBuilder中拖曳添加。

客製化的VisualElement中處理文字在地化

UIToolkit據官方說法,目前並沒有官方支援Localization插件的功能,所以要使用Localization來處理在地化就需要自己寫。作法大致上是自己寫一個類似Label的VisualElement(或是TextElement),透過LocalizedString自行訂閱LocalizedString.StringChanged來更新文字的內容,這樣就能透過Localization插件依照語言在UIToolkit上顯示在地化的文本。雖然我這邊只提到文字,其他如圖片的在地化應該也差不多。有人有嘗試實作可參考此文章。 
 
由於UIToolkit目前的限制,無法直接在UIBuilder裡面選取指定的LocalizedString,所以只能填LocalizedString的Key來查找,像是ScriptableObject的物件參考也無法。

而訂閱LocalizedString.StringChanged來變更Label的文本內容會像是:
LocalizedString.StringChanged += (str) => VisualElement.Q<Label>("Header").text = str;

由於我的在地化文本有使用到SmartString,所以我並沒有直接用這種方法,LocalizedString.StringChanged只是用來通知文本變更,然後透過LocalizedString.GetLocalizedString取得處理好的文本,這些部分被我封裝在ScriptableObject裡。

有關處理文字在地化的部分差不多了,再來就是怎麼讓這些客製化VisualElement使用Zenject的Signal互相溝通呢?

 

客製化的VisualElement與Zenject Signal

為了測試使用Zenject Signal的功能,我在UIBuilder做了一個很類似UGUI版本的介面,裡面含有四個ImageBoxElement,其中一個平時隱藏,並且名稱為Target,只有點選其他三個才會顯示這個隱藏的,並顯示全螢幕的圖片與文字敘述。功能上就跟上面影片中展示的很像。
為了讓這幾個ImageBoxElement可以互相溝通,ImageBoxElement要可以發出Signal和接收Signal。跟平常一樣我們要在ImageBoxElement注入SignalBus以發送Signal。在ImageBoxElement裡簡單加個欄位注入:
[Inject] readonly SignalBus _signalBus;

並且在Installer裡將ImageBoxElement抓出來,並做Binding,才能完成BindSignal和注入SignalBus。

為了將我們做好的UI顯示在畫面上和抓取ImageBoxElement,就需要在場上放一個GameObject並掛上UIDocument元件,將我們做好的UIVisualTreeAsset放上去,這樣就能在畫面上看到UI,並且可以透過程式控制UI的元素,並抓取底下的VisualElement。

從UIDocument取得最上層的rootVisualElement後查找所有ImageBoxElement,先儲存起來,並用匿名方法Binding。然後針對每一個ImageBoxElement做注入,否則會Zenject不會注入,因為ImageBoxElement生成不是由Zenject處理,而且生成的時間很早。

 更新:Zenject 9.3.1版已經有內建處理了,無須手動操作。

有關注入,手動對每個物件要求注入是我目前是出來唯一可以做到的方法,不知道有沒有其他更好的方法可以做到。🤔

IEnumerable<ImageBoxElement> elements = UIDocument.rootVisualElement.Query<ImageBoxElement>().ToList().OfType<ImageBoxElement>();
Container.Bind<ImageBoxElement>().FromMethodMultiple((ctx) => elements);
foreach (ImageBoxElement element in elements)
{
   Container.QueueForInject(element);
}

完成注入後,剩下的就相對簡單,只需要處理訊息發送和接收。與UGUI時使用Zenject的一樣,首先是在執行完CloneTree後註冊Callback以在滑鼠按下時發送Signal,Signal裡面會裝有存放畫作資料的ScriptableObject。

//傳送畫作資料的Signal
public class TestEvent { public PaintingData Data; }

//在ImageBoxElement建構式中CloneTree之後
this.Q<Button>("Box").RegisterCallback<ClickEvent>((click) => _signalBus.Fire(new TestEvent() { Data = _paintData }));
然後處理接收訊息的部分
//在Installer中
Container.DeclareSignal<TestEvent>();
Container.BindSignal<TestEvent>().ToMethod<ImageBoxElement>(x => x.ReactWhenOnClick).FromResolveAll();

//在ImageBoxElement中,這邊只是簡單示範開關VisualElement和更換內容而已
public void ReactWhenOnClick(TestEvent t)
{
	//只有名稱叫做Target的ImageBoxElement才需要反應
	if (this.name == "Target")
	{
		//開關顯示VisualElement
		if (this.style.display == DisplayStyle.Flex)
			this.style.display = DisplayStyle.None;
		else
			this.style.display = DisplayStyle.Flex;
		_header.text = t.Data.GetHeader();
		_image.style.backgroundImage = new StyleBackground(t.Data.GetImage());
		_name.text = t.Data.GetSubtitile();
	}
}
這邊寫完後大致上功能就完成了,剩下的功能就差不多完成了,進入PlayMode測試就是最開頭影片的樣子。

留言

這個網誌中的熱門文章

Unity:嘗試製作可相交的視差遮蔽貼圖Shader (Parallax Occlusion Mapping Shader with Pixel Depth Offset)

Blender:Geometry Node 應用於營造法式生成大木作構件初探-梭柱篇

Blender:Geometry Node 應用於營造法式生成大木作構件初探-卷殺折線標準化篇