Model-View-ViewModel Toolkit for using with Unity's UI Toolkit.
Main goal of this project - bring MVVM into UI Toolkit.
Reminder: this is WIP and until first stable release many breaking changes might be pushed without a warning.
Read more about CommunityToolkit.MVVM
- General requirements
- Prerequisites
- Make a basic view
- Enabling/Disabling View
- Localization text binding
- Smart-string binding
- Tooltip Binding
- Input binding
- Value Changed binding
- Reflection binding
- String format binding
- Smart-String nested variables
- Nested Localized String binding
- Burst Messenger support
- Localization Asset Binding
- Binding Types Limitations
Tooltip text binding.Tooltips don't work in runtime :(Tooltips extension.Done.Nested variable support for smart-stringDone.Burst-compatible wrapper for Messenger.Done.Localization Asset Table support.Done.Localization Multi-Table support.Done.Binding type conversion support.Done.
- UniTask. It is used widely to provide spike less Localization's string generation.
- Net Standard 2.1 in Project Settings-Player-Configuration-Api Compatibility Level
- Unity 2022.2+. While all previous version are also partially supported (as long as they support NS 2.1 and UniTask), 2022.2 also supports Roslyn 4.0.1 API which gives an opportunity to use all power of CommunityToolkit.mvvm source generators.
#
,>
and@
symbols are reserved in Localization package operators
1. Install via Package Manager
The package is available on the OpenUPM.
- Open
Project Settings-Package Manager
- Add a new
Scoped Registry
(or edit the existing OpenUPM entry)
URL
https://package.openupm.com
Scopes
com.cysharp.unitask
com.bustedbunny.mvvmtoolkit
- Open
Window/Package Manager
- Select
My Registries
- Install
UniTask
andModel-View-ViewModel Toolkit
packages
2. Install via Git URL
You can add https://github.com/bustedbunny/com.bustedbunny.mvvmtoolkit.git
to the Package Manager.
In your TSS (Theme Style Sheet) asset you must include MVVMTK Default stylesheet. It's included in package, so you can find it via search.
First we define viewmodel type.
ViewModel
is a baseline class to inherit from,
which inherits from MonoBehavior
.
public class TestView : BaseView
{
}
public partial class TestViewModel : ViewModel
{
}
Now we create a GameObject in scene with TestView
and TestViewModel
components and attach our uxml asset to proper field.
In a BindingContext we assign our ViewModel.
We should also attach our string localization table to support localization binding.
Now we need to define our UI hierarchy.
We create game object with UIRoot
component attached.
Here we assign our UIDocument
we want to use.
Although it is not required and we can attach UIDocument
later.
We can even remove it.
Our UI hierarchy will be automatically attached to it's root.
Now we need to assign your View
game object as a child of UIRoot
.
So far our scene looks like this and now we need to Initialize
our UIRoot
.
Let's create a simple script to do so:
public class SampleUIInitializer : MonoBehaviour
{
// Internally UI is initialized in Awake
// Actual initialization should be done at least after Start
void Start() => InitializeAsync().Forget();
private async UniTask InitializeAsync()
{
var root = GetComponent<UIRoot>();
// We call UIRoot.Initialize method and provide StrongReferenceMessenger and ServiceProvider instances.
// If you have external services on which your Views or ViewModels rely you must register them
// before calling Initialize.
var messenger = new StrongReferenceMessenger();
var serviceProvider = new ServiceProvider();
// Before we can make any calls to UI, we need to await it's initialization
await root.Initialize(messenger, serviceProvider);
messenger.Send<OpenTestViewMessage>();
}
}
After we attached this script to UIRoot
we can start PlayMode, but View will not appear.
This framework is meant to use CommunityToolkit.MVVM Message system. But it's not necessary and if you want to control your navigation differently it's up to you.
You can read more about CommunityToolkit.MVVM here.
In order to enable any View
we will need to implement a message:
public class OpenTestViewMessage { }
Then we will need to subscribe our View
to that message. There are several ways to do so:
- First we can simply inherit from
IRecipient<T>
. OurView
will be automatically subscribed.
public class TestView : BaseView, IRecipient<OpenTestViewMessage>
{
// On message receive we will enable our View
public void Receive(OpenTestViewMessage message)
{
enabled = true;
}
}
- Alternatively we can subscribe manually.
public class TestView : BaseView
{
// You can override OnInit to make manual changes to hierarchy
protected override void OnInit()
{
Messenger.Register<OpenTestViewMessage>(this, (recipient, message) => enabled = true);
}
}
Or you can enable/disable BaseView manually in any way you want if you are not interested in messaging system.
Now we need to use Messenger and send this message:
public class UIInitializer : MonoBehaviour
{
void Start()
{
...
await root.Initialize(messenger, serviceProvider);
messenger.Send<OpenTestViewMessage>();
}
}
Messenger should only be used after UIRoot
is Initialized.
And now our View
should be displayed on PlayMode.
In order to disable view we can use either built in CloseViewsMessage
or implement your own message callbacks that will call enabled = false
method of our View
.
In order to bind TextElement
's text
value to localized string we need:
- Create localization string table using Unity's localization package.
- Assign that table to your View's localization table field.
- Assign
text
attribute ofTextElement
in.uxml
to required key with#
operator.
<ui:Label text="#Text"/>
Binding automatically updates text
value on every table change.
For example when we switch language or when we modify table in editor.
You can also bind tooltips with this, using tooltip
attribute.
Now we want to display some variables.
We need to make our Localization entry Smart
and define variables with >
operator.
Variable name must match Property on BindingContext
(ViewModel
our View
is attached to).
You can also bind tooltips with this, using tooltip
attribute.
public partial class TestViewModel : ViewModel
{
// To bind a simple property, just create a backing field
// and attach [ObservableProperty] attribute. TestInt property will be generated.
[ObservableProperty] private int _testInt = 12;
}
In our .uxml
asset we define text
attribute with entry's key with #
operator.
<ui:Label text="#VariableTest"/>
Now our View
will be automatically updated as we change TestInt
property value.
In order to enable custom tooltips with binding support - just use
tooltip
attribute for any VisualElement
with any of supported
bindings: Localization or string.Format.
<ui:Label text="Localization tooltip" tooltip="#TooltipTest"/>
<ui:Label text="String Format tooltip" tooltip="$This is a tooltip with variable = {Counter}"/>
TooltipElement
consists of VisualElemet
and Label
, which
have MVVMTK-tooltip-container
and MVVMTK-tooltip-label
USS classes,
so you can override them with your USS styles.
To bind a button to specific method we will need to
implement a void method
with [RelayCommand]
attribute or create ICommand
property ourselves.
Let's create a simple counter:
public partial class TestViewModel : ViewModel
{
// To bind a method to click event you will need ICommand property.
// [RelayCommand] will automatically generate it for you.
[RelayCommand]
private void Increment() => Counter++;
[ObservableProperty]
private int _counter;
}
In our .uxml
asset we define view-data-key
attribute. Each binding needs to be wrapped in braces.
ICommand
binding requires @
operator.
<ui:Button text="Counter" view-data-key="{@IncrementCommand}"/>
We can also bind button's enabled state to boolean field/property/method, which will be updated automatically.
[RelayCommand(CanExecute = nameof(CanIncrement))]
We can also send bool, int, float or string as parameter.
<ui:Button view-data-key="{@FooCommand:5}"/>
[RelayCommand]
private void Foo(int arg)
{
// arg is going to be 5
}
In order to bind elements with INotifyValueChanged<T>
we will need to implement a property with
matching type.
Value Changed binding uses %
operator.
<ui:IntegerField label="Counter" view-data-key="{%Counter}"/>
Now Counter
property value will be mirrored
if you manually type a value into field.
Sometimes we want to bind something very custom and specific. In order to do so we can use reflection binding.
Reflection binding uses ^
operator.
<ui:Label text="This text font size is bound to Counter" view-data-key="{^style.fontSize=FontSize}"/>
In ViewModel we will need to define matching type.
public partial class TestViewModel : ViewModel
{
// In some scenarios multiple properties can be attached to one backing field.
// In this case use [NotifyPropertyChangedFor] attribute
[ObservableProperty, NotifyPropertyChangedFor(nameof(FontSize))]
private int _counter;
// This property provides a proper type for VisualElement.style.fontSize for binding
public StyleLength FontSize => Counter;
}
Now as we modify our Counter
value, this Label
's fontSize will
also change accordingly.
Sometimes we don't need localization and we just want to bind string.
String format binding uses $
operator.
In .uxml
we simply define our format the same way as we do in C#.
Matching properties must exist in bound ViewModel
.
<ui:Label text="$Counter={Counter}"/>
Nested variables are fully supported.
To define a group we need to use #
operator and >
operator for a variable.
With full support we can go fully nuts:
In order to nest LocalizedString inside another one use '@' operator.
In case you want Burst code to send messages with data first declare
a type inheriting from IUnmanagedMessage
.
private struct TestInt : IUnmanagedMessage
{
public int value;
}
Now we need to create WrapperReference
using
our Messenger
instance as argument.
var messenger = new StrongReferenceMessenger();
var wrapper = new WrapperReference(messenger);
After that we can pass struct obtained from wrapper.Wrapper
to our
unmanaged code and call Send
method on it. For example:
var value = new TestInt { value = 62 };
Wrapper.Send(value);
Once our unmanaged code is finished, messages need to be unwrapped:
wrapper.Unwrap();
This method will send messages of type Wrapper<TestInt>
to all subscribed
recipients.
IRecipient<T>
interfacing is not supported.
Subscription is only supported manually. For example:
void Receive(object _, Wrapped<TestInt> message)
{
result = message.data;
}
messenger.Register<Wrapped<TestInt>>(recipient, Receive);
Asset binding is a special kind of reflection binding. Syntax works as follows:
<ui:Image view-data-key="{#Flag>image}"/>
Where key must be stored in view-data-key
attribute
and start with #
operator.
After #
follows table entry name.
After entry name follows >
symbol. It is used as separator.
And then property path of current VisualElement
is specified.
Property used in example:
UnityEngine.UIElements.Image.image
In order to create bindings package relies on explicitly declared generic types and if it can't find one implements generic fallback.
Generic fallbacks are mostly fine with Mono scripting backend as JIT properly compiles them and performance different is rather insignificant (but still exists).
But with IL2CPP fallbacks result in huge performance degradation compared to explicitly declared types.
In order to enable warnings when fallbacks are used define MVVMTK_FALLBACK_WARNINGS
in your project settings.
Warnings will suggest what type you need to implement to improve performance.
There are 2 types of explicit binding solvers:
ISingleSolver
- provides binding support for specified type.IMultiSolver
- provides binding support for converting one type to another.
For example there are some built in generics solvers implemented in package:
public class IntSolver : SingleSolver<int> { }
public class UintSolver : SingleSolver<uint> { }
public class ByteSolver : SingleSolver<byte> { }
public class FloatSolver : SingleSolver<float> { }
public class DoubleSolver : SingleSolver<double> { }
public class StringSolver : SingleSolver<string> { }
public class BoolSolver : SingleSolver<bool> { }
In order to support other types you need to implement solvers yourself
simply inheriting from SingleSolver<T>
or MultiSolver<TFrom,TTo>
.
// Converter need to be preserved in case you use code stripping
[Preserve]
public class TextureToTexture2DConverter : MultiSolver<Texture2D, Texture> { }
IMultiSolver
are only required for Localized Asset Binding.