Awesome
UnityMvvmToolkit
A package that brings data-binding to your Unity project.
:open_book: Table of Contents
- About
- Folder Structure
- Installation
- Introduction
- Quick start
- How To Use
- External Assets
- Performance
- Contributing
- License
:pencil: About
The UnityMvvmToolkit allows you to use data binding to establish a connection between the app UI and the data it displays. This is a simple and consistent way to achieve clean separation of business logic from UI. Use the samples as a starting point for understanding how to utilize the package.
Key features:
- Runtime data-binding
- UI Toolkit & uGUI integration
- Multiple-properties binding
- Custom UI Elements support
- Compatible with UniTask
- Mono & IL2CPP support*
Samples
The following example shows the UnityMvvmToolkit in action using the Counter app.
<details open><summary><b>CounterView</b></summary> <br /><UXML>
<BindableContentPage binding-theme-mode-path="ThemeMode" class="counter-screen">
<VisualElement class="number-container">
<BindableCountLabel binding-text-path="Count" class="count-label count-label--animation" />
</VisualElement>
<BindableThemeSwitcher binding-value-path="ThemeMode, Converter={ThemeModeToBoolConverter}" />
<BindableCounterSlider increment-command="IncrementCommand" decrement-command="DecrementCommand" />
</BindableContentPage>
</UXML>
</details> <details><summary><b>CounterViewModel</b></summary> <br />Note: The namespaces are omitted to make the example more readable.
public class CounterViewModel : IBindingContext
{
public CounterViewModel()
{
Count = new Property<int>();
ThemeMode = new Property<ThemeMode>();
IncrementCommand = new Command(IncrementCount);
DecrementCommand = new Command(DecrementCount);
}
public IProperty<int> Count { get; }
public IProperty<ThemeMode> ThemeMode { get; }
public ICommand IncrementCommand { get; }
public ICommand DecrementCommand { get; }
private void IncrementCount() => Count.Value++;
private void DecrementCount() => Count.Value--;
}
</details>
<table>
<tr>
<td align="center">Counter</td>
<td align="center">Calculator</td>
<td align="center">ToDoList</td>
</tr>
<tr>
<td align="center" width=25%>
<video src="https://user-images.githubusercontent.com/28132516/187030099-a440bc89-4c28-44e3-9898-9894eac5bff4.mp4" alt="CounterSample" />
</td>
<td align="center" width=25%>
<video src="https://user-images.githubusercontent.com/28132516/187471982-4acdeddb-ec4d-45b6-a2a3-4198a03760de.mp4" alt="CalculatorSample" />
</td>
<td align="center" width=25%>
<video src="https://user-images.githubusercontent.com/28132516/187030101-ad1f2123-59d5-4d1e-a9ca-ab983589e52f.mp4" alt="ToDoListSample" />
</td>
</tr>
</table>
You will find all the samples in the
samples
folder.
:cactus: Folder Structure
.
├── samples
│ ├── Unity.Mvvm.Calc
│ ├── Unity.Mvvm.Counter
│ ├── Unity.Mvvm.ToDoList
│ └── Unity.Mvvm.CounterLegacy
│
├── src
│ ├── UnityMvvmToolkit.Core
│ └── UnityMvvmToolkit.UnityPackage
│ ...
│ ├── Core # Auto-generated
│ ├── Common
│ ├── External
│ ├── UGUI
│ └── UITK
│
├── UnityMvvmToolkit.sln
:gear: Installation
You can install UnityMvvmToolkit in one of the following ways:
<details><summary>1. Install via Package Manager</summary> <br />The package is available on the OpenUPM.
-
Open
Edit/Project Settings/Package Manager
-
Add a new
Scoped Registry
(or edit the existing OpenUPM entry)Name package.openupm.com URL https://package.openupm.com Scope(s) com.cysharp.unitask com.chebanovdd.unitymvvmtoolkit
-
Open
Window/Package Manager
-
Select
My Registries
-
Install
UniTask
andUnityMvvmToolkit
packages
You can add https://github.com/ChebanovDD/UnityMvvmToolkit.git?path=src/UnityMvvmToolkit.UnityPackage/Assets/Plugins/UnityMvvmToolkit
to the Package Manager.
If you want to set a target version, UnityMvvmToolkit uses the v*.*.*
release tag, so you can specify a version like #v1.0.0
. For example https://github.com/ChebanovDD/UnityMvvmToolkit.git?path=src/UnityMvvmToolkit.UnityPackage/Assets/Plugins/UnityMvvmToolkit#v1.0.0
.
IL2CPP restriction
The UnityMvvmToolkit uses generic virtual methods under the hood to create bindable properties, but IL2CPP
in Unity 2021
does not support Full Generic Sharing this restriction will be removed in Unity 2022
.
To work around this issue in Unity 2021
you need to change the IL2CPP Code Generation
setting in the Build Settings
window to Faster (smaller) builds
.
:ledger: Introduction
The package contains a collection of standard, self-contained, lightweight types that provide a starting implementation for building apps using the MVVM pattern.
The included types are:
- IBindingContext
- CanvasView<TBindingContext>
- DocumentView<TBindingContext>
- Property<T> & ReadOnlyProperty<T>
- Command & Command<T>
- AsyncCommand & AsyncCommand<T>
- PropertyValueConverter<TSourceType, TTargetType>
- ParameterValueConverter<TTargetType>
- IProperty<T> & IReadOnlyProperty<T>
- ICommand & ICommand<T>
- IAsyncCommand & IAsyncCommand<T>
- IPropertyValueConverter<TSourceType, TTargetType>
- IParameterValueConverter<TTargetType>
IBindingContext
The IBindingContext
is a base interface for ViewModels. It is a marker for Views that the class contains observable properties to bind to.
Here's an example of a simple ViewModel.
public class CounterViewModel : IBindingContext
{
public CounterViewModel()
{
Count = new Property<int>();
}
public IProperty<int> Count { get; }
}
Note: In case your ViewModel doesn't have a parameterless constructor, you need to override the
GetBindingContext
method in the View.
CanvasView<TBindingContext>
The CanvasView<TBindingContext>
is a base class for uGUI
views.
Key functionality:
- Provides a base implementation for
Canvas
based view - Automatically searches for bindable UI elements on the
Canvas
- Allows to override the base viewmodel instance creation
- Allows to define property & parameter value converters
public class CounterView : CanvasView<CounterViewModel>
{
// Override the base viewmodel instance creation.
// Required in case the viewmodel doesn't have a parameterless constructor.
protected override CounterViewModel GetBindingContext()
{
return _appContext.Resolve<CounterViewModel>();
}
// Define 'property' & 'parameter' value converters.
protected override IValueConverter[] GetValueConverters()
{
return _appContext.Resolve<IValueConverter[]>();
}
// Define a collection item templates.
protected override IReadOnlyDictionary<Type, object> GetCollectionItemTemplates()
{
return _appContext.Resolve<IReadOnlyDictionary<Type, object>>();
}
}
DocumentView<TBindingContext>
The DocumentView<TBindingContext>
is a base class for UI Toolkit
views.
Key functionality:
- Provides a base implementation for
UI Document
based view - Automatically searches for bindable UI elements on the
UI Document
- Allows to override the base viewmodel instance creation
- Allows to define property & parameter value converters
public class CounterView : DocumentView<CounterViewModel>
{
// Override the base viewmodel instance creation.
// Required in case the viewmodel doesn't have a parameterless constructor.
protected override CounterViewModel GetBindingContext()
{
return _appContext.Resolve<CounterViewModel>();
}
// Define 'property' & 'parameter' value converters.
protected override IValueConverter[] GetValueConverters()
{
return _appContext.Resolve<IValueConverter[]>();
}
// Define a collection item templates.
protected override IReadOnlyDictionary<Type, object> GetCollectionItemTemplates()
{
return _appContext.Resolve<IReadOnlyDictionary<Type, object>>();
}
}
Property<T> & ReadOnlyProperty<T>
The Property<T>
and ReadOnlyProperty<T>
provide a way to bind properties between a ViewModel and UI elements.
Key functionality:
- Provide a base implementation of the
IBaseProperty
interface - Implement the
IProperty<T>
&IReadOnlyProperty<T>
interface, which exposes aValueChanged
event
Simple property
Here's an example of how to implement a simple property.
public class CounterViewModel : IBindingContext
{
public CounterViewModel()
{
Count = new Property<int>();
}
public IProperty<int> Count { get; }
}
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabel binding-text-path="Count" />
</ui:UXML>
Note: You need to define
IntToStrConverter
to convert int to string. See the PropertyValueConverter section for more information.
Observable property
public class MyViewModel : IBindingContext
{
[Observable("Count")]
private readonly IProperty<int> _amount = new Property<int>();
// The field name will be used if you don't provide a property name.
// Names '_title' and 'm_title' will be auto-converted to 'Title'.
[Observable]
private readonly IProperty<string> _title = new Property<string>();
}
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabel binding-text-path="Count" />
<uitk:BindableLabel binding-text-path="Title" />
</ui:UXML>
Note: You need to define
IntToStrConverter
to convert int to string. See the PropertyValueConverter section for more information.
You can use the Observable
attribute even on public
properties to override the binding path.
public class MyViewModel : IBindingContext
{
[Observable("PreviousPropertyName")]
public IReadOnlyProperty<string> NewPropertyName { get; }
}
Wrapping a non-observable model
A common scenario, for instance, when working with database items, is to create a wrapping "bindable" model that relays properties of the database model, and raises the property changed notifications when needed.
public class UserViewModel : IBindingContext
{
private readonly User _user;
[Observable(nameof(Name))]
private readonly IProperty<string> _name = new Property<string>();
public UserViewModel(User user)
{
_user = user;
_name.Value = user.Name;
}
public string Name
{
get => _user.Name;
set
{
if (_name.TrySetValue(value))
{
_user.Name = value;
}
}
}
}
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabel binding-text-path="Name" />
</ui:UXML>
To achieve the same result, but with minimal boilerplate code, you can automatically create an observable backing field using the [WithObservableBackingField]
attribute from UnityMvvmToolkit.Generator.
public partial class UserViewModel : IBindingContext
{
private readonly User _user;
public UserViewModel(User user)
{
_user = user;
_name.Value = user.Name;
}
[WithObservableBackingField]
public string Name
{
get => _user.Name;
set
{
if (_name.TrySetValue(value))
{
_user.Name = value;
}
}
}
}
<details><summary><b>Generated code</b></summary>
<br />
UserViewModel.BackingFields.g.cs
partial class UserViewModel
{
[global::System.CodeDom.Compiler.GeneratedCode("UnityMvvmToolkit.Generator", "1.0.0.0")]
[global::UnityMvvmToolkit.Core.Attributes.Observable(nameof(Name))]
private readonly global::UnityMvvmToolkit.Core.Interfaces.IProperty<string> _name = new global::UnityMvvmToolkit.Core.Property<string>();
}
</details>
Waiting for the partial properties support to make it even shorter.
public partial class UserViewModel : IBindingContext
{
private readonly User _user;
public UserViewModel(User user)
{
_user = user;
_name.Value = user.Name;
}
[WithObservableBackingField]
public partial string Name { get; set; }
}
Note: The UnityMvvmToolkit.Generator is available exclusively for my patrons.
Serializable ViewModel
A common scenario, for instance, when working with collection items, is to create a "bindable" item that can be serialized.
public class ItemViewModel : ICollectionItem
{
[Observable(nameof(Name))]
private readonly IProperty<string> _name = new Property<string>();
public int Id { get; set; }
public string Name
{
get => _name.Value;
set => _name.Value = value;
}
}
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabel binding-text-path="Name" />
</ui:UXML>
The ItemViewModel
can be serialized and deserialized without any issues.
The same result, but using the [WithObservableBackingField]
attribute from UnityMvvmToolkit.Generator.
public partial class ItemViewModel : ICollectionItem
{
public int Id { get; set; }
[WithObservableBackingField]
public string Name
{
get => _name.Value;
set => _name.Value = value;
}
}
<details><summary><b>Generated code</b></summary>
<br />
ItemViewModel.BackingFields.g.cs
partial class ItemViewModel
{
[global::System.CodeDom.Compiler.GeneratedCode("UnityMvvmToolkit.Generator", "1.0.0.0")]
[global::UnityMvvmToolkit.Core.Attributes.Observable(nameof(Name))]
private readonly global::UnityMvvmToolkit.Core.Interfaces.IProperty<string> _name = new global::UnityMvvmToolkit.Core.Property<string>();
}
</details>
Note: The UnityMvvmToolkit.Generator is available exclusively for my patrons.
Command & Command<T>
The Command
and Command<T>
are ICommand
implementations that can expose a method or delegate to the view. These types act as a way to bind commands between the viewmodel and UI elements.
Key functionality:
- Provide a base implementation of the
ICommand
interface - Implement the
ICommand
&ICommand<T>
interface, which exposes aRaiseCanExecuteChanged
method to raise theCanExecuteChanged
event - Expose constructor taking delegates like
Action
andAction<T>
, which allow the wrapping of standard methods and lambda expressions
The following shows how to set up a simple command.
using UnityMvvmToolkit.Core;
using UnityMvvmToolkit.Core.Interfaces;
public class CounterViewModel : IBindingContext
{
public CounterViewModel()
{
Count = new Property<int>();
IncrementCommand = new Command(IncrementCount);
}
public IProperty<int> Count { get; }
public ICommand IncrementCommand { get; }
private void IncrementCount() => Count.Value++;
}
And the relative UI could then be.
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabel binding-text-path="Count" />
<uitk:BindableButton command="IncrementCommand" />
</ui:UXML>
The BindableButton
binds to the ICommand
in the viewmodel, which wraps the private IncrementCount
method. The BindableLabel
displays the value of the Count
property and is updated every time the property value changes.
Note: You need to define
IntToStrConverter
to convert int to string. See the PropertyValueConverter section for more information.
AsyncCommand & AsyncCommand<T>
The AsyncCommand
and AsyncCommand<T>
are ICommand
implementations that extend the functionalities offered by Command
, with support for asynchronous operations.
Key functionality:
- Extend the functionalities of the synchronous commands included in the package, with support for UniTask-returning delegates
- Can wrap asynchronous functions with a
CancellationToken
parameter to support cancelation, and they expose aDisableOnExecution
property, as well as aCancel
method - Implement the
IAsyncCommand
&IAsyncCommand<T>
interfaces, which allows to replace a command with a custom implementation, if needed
Let's say we want to download an image from the web and display it as soon as it downloads.
public class ImageViewerViewModel : IBindingContext
{
[Observable(nameof(Image))]
private readonly IProperty<Texture2D> _image;
private readonly IImageDownloader _imageDownloader;
public ImageViewerViewModel(IImageDownloader imageDownloader)
{
_image = new Property<Texture2D>();
_imageDownloader = imageDownloader;
DownloadImageCommand = new AsyncCommand(DownloadImageAsync);
}
public Texture2D Image => _image.Value;
public IAsyncCommand DownloadImageCommand { get; }
private async UniTask DownloadImageAsync(CancellationToken cancellationToken)
{
_image.Value = await _imageDownloader.DownloadRandomImageAsync(cancellationToken);
}
}
With the related UI code.
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<BindableImage binding-image-path="Image" />
<uitk:BindableButton command="DownloadImageCommand">
<ui:Label text="Download Image" />
</uitk:BindableButton>
</ui:UXML>
Note: The
BindableImage
is a custom control from the create custom control section.
To disable the BindableButton
while an async operation is running, simply set the DisableOnExecution
property of the AsyncCommand
to true
.
public class ImageViewerViewModel : IBindingContext
{
public ImageViewerViewModel(IImageDownloader imageDownloader)
{
...
DownloadImageCommand = new AsyncCommand(DownloadImageAsync) { DisableOnExecution = true };
}
}
To allow the same async command to be invoked concurrently multiple times, set the AllowConcurrency
property of the AsyncCommand
to true
.
public class MainViewModel : IBindingContext
{
public MainViewModel()
{
RunConcurrentlyCommand = new AsyncCommand(RunConcurrentlyAsync) { AllowConcurrency = true };
}
}
If you want to create an async command that supports cancellation, use the WithCancellation
extension method.
public class MyViewModel : IBindingContext
{
public MyViewModel()
{
MyAsyncCommand = new AsyncCommand(DoSomethingAsync).WithCancellation();
CancelCommand = new Command(Cancel);
}
public IAsyncCommand MyAsyncCommand { get; }
public ICommand CancelCommand { get; }
private async UniTask DoSomethingAsync(CancellationToken cancellationToken)
{
...
}
private void Cancel()
{
// If the underlying command is not running, this method will perform no action.
MyAsyncCommand.Cancel();
}
}
If a command supports cancellation and the AllowConcurrency
property is set to true
, all running commands will be canceled.
Note: You need to import the UniTask package in order to use async commands.
PropertyValueConverter<TSourceType, TTargetType>
Property value converter provides a way to apply custom logic to a property binding.
Built-in property value converters:
- IntToStrConverter
- FloatToStrConverter
If you want to create your own property value converter, create a class that inherits the PropertyValueConverter<TSourceType, TTargetType>
abstract class and then implement the Convert
and ConvertBack
methods.
public enum ThemeMode
{
Light = 0,
Dark = 1
}
public class ThemeModeToBoolConverter : PropertyValueConverter<ThemeMode, bool>
{
// From source to target.
public override bool Convert(ThemeMode value)
{
return (int) value == 1;
}
// From target to source.
public override ThemeMode ConvertBack(bool value)
{
return (ThemeMode) (value ? 1 : 0);
}
}
Don't forget to register the ThemeModeToBoolConverter
in the view.
public class MyView : DocumentView<MyViewModel>
{
protected override IValueConverter[] GetValueConverters()
{
return new IValueConverter[] { new ThemeModeToBoolConverter() };
}
}
Then you can use the ThemeModeToBoolConverter
as in the following example.
<UXML>
<!--Full expression-->
<MyBindableElement binding-value-path="ThemeMode, Converter={ThemeModeToBoolConverter}" />
<!--Short expression-->
<MyBindableElement binding-value-path="ThemeMode, ThemeModeToBoolConverter" />
<!--Minimal expression - the first appropriate converter will be used-->
<MyBindableElement binding-value-path="ThemeMode" />
</UXML>
ParameterValueConverter<TTargetType>
Parameter value converter allows to convert a command parameter.
Built-in parameter value converters:
- ParameterToIntConverter
- ParameterToFloatConverter
By default, the converter is not needed if your command has a string
parameter type.
public class MyViewModel : IBindingContext
{
public MyViewModel()
{
PrintParameterCommand = new Command<string>(PrintParameter);
}
public ICommand<string> PrintParameterCommand { get; }
private void PrintParameter(string parameter)
{
Debug.Log(parameter);
}
}
<UXML>
<BindableButton command="PrintParameterCommand, Parameter={MyParameter}" />
<!--or-->
<BindableButton command="PrintParameterCommand, MyParameter" />
</UXML>
If you want to create your own parameter value converter, create a class that inherits the ParameterValueConverter<TTargetType>
abstract class and then implement the Convert
method.
public class ParameterToIntConverter : ParameterValueConverter<int>
{
public override int Convert(string parameter)
{
return int.Parse(parameter);
}
}
Don't forget to register the ParameterToIntConverter
in the view.
public class MyView : DocumentView<MyViewModel>
{
protected override IValueConverter[] GetValueConverters()
{
return new IValueConverter[] { new ParameterToIntConverter() };
}
}
Then you can use the ParameterToIntConverter
as in the following example.
public class MyViewModel : IBindingContext
{
public MyViewModel()
{
PrintParameterCommand = new Command<int>(PrintParameter);
}
public ICommand<int> PrintParameterCommand { get; }
private void PrintParameter(int parameter)
{
Debug.Log(parameter);
}
}
<UXML>
<!--Full expression-->
<BindableButton command="PrintIntParameterCommand, Parameter={5}, Converter={ParameterToIntConverter}" />
<!--Short expression-->
<BindableButton command="PrintIntParameterCommand, 5, ParameterToIntConverter" />
<!--Minimal expression - the first appropriate converter will be used-->
<BindableButton command="PrintIntParameterCommand, 5" />
</UXML>
:watch: Quick start
Once the UnityMVVMToolkit
is installed, create a class MyFirstViewModel
that implements the IBindingContext
interface.
using UnityMvvmToolkit.Core;
using UnityMvvmToolkit.Core.Interfaces;
public class MyFirstViewModel : IBindingContext
{
public MyFirstViewModel()
{
Text = new ReadOnlyProperty<string>("Hello World");
}
public IReadOnlyProperty<string> Text { get; }
}
UI Toolkit
The next step is to create a class MyFirstDocumentView
that inherits the DocumentView<TBindingContext>
class.
using UnityMvvmToolkit.UITK;
public class MyFirstDocumentView : DocumentView<MyFirstViewModel>
{
}
Then create a file MyFirstView.uxml
, add a BindableLabel
control and set the binding-text-path
to Text
.
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabel binding-text-path="Text" />
</ui:UXML>
Finally, add UI Document
to the scene, set the MyFirstView.uxml
as a Source Asset
and add the MyFirstDocumentView
component to it.
Unity UI (uGUI)
For the uGUI
do the following. Create a class MyFirstCanvasView
that inherits the CanvasView<TBindingContext>
class.
using UnityMvvmToolkit.UGUI;
public class MyFirstCanvasView : CanvasView<MyFirstViewModel>
{
}
Then add a Canvas
to the scene, and add the MyFirstCanvasView
component to it.
Finally, add a Text - TextMeshPro
UI element to the canvas, add the BindableLabel
component to it and set the BindingTextPath
to Text
.
:joystick: How To Use
Data-binding
The package contains a set of standard bindable UI elements out of the box.
The included UI elements are:
- BindableLabel
- BindableTextField
- BindableButton
- BindableDropdownField
- BindableListView
- BindableScrollView
- BindingContextProvider
Note: The
BindableListView
&BindableScrollView
are provided forUI Toolkit
only.
BindableLabel
The BindableLabel
element uses the OneWay
binding by default.
public class LabelViewModel : IBindingContext
{
public LabelViewModel()
{
IntValue = new Property<int>(55);
StrValue = new Property<string>("69");
}
public IReadOnlyProperty<int> IntValue { get; }
public IReadOnlyProperty<string> StrValue { get; }
}
public class LabelView : DocumentView<LabelViewModel>
{
protected override IValueConverter[] GetValueConverters()
{
return new IValueConverter[] { new IntToStrConverter() };
}
}
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabel binding-text-path="StrValue" />
<uitk:BindableLabel binding-text-path="IntValue" />
</ui:UXML>
BindableTextField
The BindableTextField
element uses the TwoWay
binding by default.
public class TextFieldViewModel : IBindingContext
{
public TextFieldViewModel()
{
TextValue = new Property<string>();
}
public IProperty<string> TextValue { get; }
}
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableTextField binding-value-path="TextValue" />
</ui:UXML>
BindableButton
The BindableButton
can be bound to the following commands:
To pass a parameter to the viewmodel, see the ParameterValueConverter section.
BindableDropdownField
The BindableDropdownField
allows the user to pick a choice from a list of options. The BindingSelectedItemPath
attribute is optional.
public class DropdownFieldViewModel : IBindingContext
{
public DropdownFieldViewModel()
{
var items = new ObservableCollection<string>
{
"Value 1",
"Value 2",
"Value 3"
};
Items = new ReadOnlyProperty<ObservableCollection<string>>(items);
SelectedItem = new Property<string>(items[0]);
}
public IReadOnlyProperty<ObservableCollection<string>> Items { get; }
public IProperty<string> SelectedItem { get; }
}
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableDropdownField binding-items-source-path="Items" binding-selected-item-path="SelectedItem" />
</ui:UXML>
BindableListView
The BindableListView
control is the most efficient way to create lists. It uses virtualization and creates VisualElements only for visible items. Use the binding-items-source-path
of the BindableListView
to bind to an ObservableCollection
.
The following example demonstrates how to bind to a collection of users with BindableListView
.
Create a UI Document
named UserItemView.uxml
for the individual items in the list.
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabel binding-text-path="Name" />
</ui:UXML>
Create a UserItemViewModel
class that implements ICollectionItem
to store user data.
public class UserItemViewModel : ICollectionItem
{
[Observable(nameof(Name))]
private readonly IProperty<string> _name = new Property<string>();
public UserItemViewModel()
{
Id = Guid.NewGuid().GetHashCode();
}
public int Id { get; }
public string Name
{
get => _name.Value;
set => _name.Value = value;
}
}
Create a UserListView
that inherits the BindableListView<TItemBindingContext>
abstract class.
public class UserListView : BindableListView<UserItemViewModel>
{
public new class UxmlFactory : UxmlFactory<UserListView, UxmlTraits> {}
}
Create a UsersViewModel
.
public class UsersViewModel : IBindableContext
{
public UsersViewModel()
{
var users = new ObservableCollection<UserItemViewModel>
{
new() { Name = "User 1" },
new() { Name = "User 2" },
new() { Name = "User 3" },
};
Users = new ReadOnlyProperty<ObservableCollection<UserItemViewModel>>(users);
}
public IReadOnlyProperty<ObservableCollection<UserItemViewModel>> Users { get; }
}
Now we need to provide an item template for the UserItemViewModel
. Create a UsersView
as follows.
public class UsersView : DocumentView<UsersViewModel>
{
[SerializeField] private VisualTreeAsset _userItemViewAsset;
protected override IReadOnlyDictionary<Type, object> GetCollectionItemTemplates()
{
return new Dictionary<Type, object>
{
{ typeof(UserItemViewModel), _userItemViewAsset }
};
}
}
Starting with Unity 2023, you can select an ItemTemplate directly in the UI Builder.
<details><summary>UI Builder Inspector</summary> <br /> </details>Finally, create a main UI Document
named UsersView.uxml
with the following content.
<ui:UXML ...>
<UserListView binding-items-source-path="Users" />
</ui:UXML>
BindableScrollView
The BindableScrollView
has the same binding logic as the BindableListView
. It does not use virtualization and creates VisualElements for all items regardless of visibility.
BindingContextProvider
The BindingContextProvider
allows you to provide a custom IBindingContext
for all child elements.
Let's say we have the following binding contexts.
public class MainViewModel : IBindingContext
{
[Observable]
private readonly IReadOnlyProperty<string> _title;
[Observable]
private readonly IReadOnlyProperty<CustomViewModel> _customViewModel;
public MainViewModel()
{
_title = new ReadOnlyProperty<string>("Main Context");
_customViewModel = new ReadOnlyProperty<CustomViewModel>(new CustomViewModel());
}
}
public class CustomViewModel : IBindingContext
{
[Observable]
private readonly IReadOnlyProperty<string> _title;
public CustomViewModel()
{
_title = new ReadOnlyProperty<string>("Custom Context");
}
}
To provide the CustomViewModel
as a binding context for certain elements, we have to use the BindingContextProvider
as the parent for those elements.
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabel name="Label1" binding-text-path="Title" />
<!-- Binding context not specified. Will be used MainViewModel for all childs. -->
<uitk:BindingContextProvider>
<uitk:BindableLabel name="Label2" binding-text-path="Title" />
</uitk:BindingContextProvider>
<!-- Binding context is specified. Will be used CustomViewModel for all childs. -->
<uitk:BindingContextProvider binding-context-path="CustomViewModel">
<uitk:BindableLabel name="Label3" binding-text-path="Title" />
</uitk:BindingContextProvider>
</ui:UXML>
In this example, Label1
and Label2
will display the text "Main Context", while Label3
will display the text "Custom Context".
We can create a BindingContextProvider
for a specific IBindingContext
to avoid allocating memory for a new PropertyCastWrapper
class. Let's create a CustomViewModelProvider
element.
[UxmlElement]
public partial class CustomViewModelProvider : BindingContextProvider<CustomViewModel>
{
}
Note: We use a UxmlElement attribute to create a custom control.
Now we can use the CustomViewModelProvider
just like the default BindingContextProvider
.
<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabel name="Label1" binding-text-path="Title" />
<!-- Binding context not specified. Will be used MainViewModel for all childs. -->
<CustomViewModelProvider>
<uitk:BindableLabel name="Label2" binding-text-path="Title" />
</CustomViewModelProvider>
<!-- Binding context is specified. Will be used CustomViewModel for all childs. -->
<CustomViewModelProvider binding-context-path="CustomViewModel">
<uitk:BindableLabel name="Label3" binding-text-path="Title" />
</CustomViewModelProvider>
</ui:UXML>
Create custom control
Let's create a BindableImage
UI element.
First of all, create a base Image
class.
public class Image : VisualElement
{
public void SetImage(Texture2D image)
{
style.backgroundImage = new StyleBackground(image);
}
public new class UxmlFactory : UxmlFactory<Image, UxmlTraits> {}
}
Then create a BindableImage
class and implement the data binding logic.
public class BindableImage : Image, IBindableElement
{
private PropertyBindingData _imagePathBindingData;
private IReadOnlyProperty<Texture2D> _imageProperty;
public string BindingImagePath { get; private set; }
public void SetBindingContext(IBindingContext context, IObjectProvider objectProvider)
{
_imagePathBindingData ??= BindingImagePath.ToPropertyBindingData();
_imageProperty = objectProvider.RentReadOnlyProperty<Texture2D>(context, _imagePathBindingData);
_imageProperty.ValueChanged += OnImagePropertyValueChanged;
SetImage(_imageProperty.Value);
}
public void ResetBindingContext(IObjectProvider objectProvider)
{
if (_imageProperty == null)
{
return;
}
_imageProperty.ValueChanged -= OnImagePropertyValueChanged;
objectProvider.ReturnReadOnlyProperty(_imageProperty);
_imageProperty = null;
SetImage(null);
}
private void OnImagePropertyValueChanged(object sender, Texture2D newImage)
{
SetImage(newImage);
}
public new class UxmlFactory : UxmlFactory<BindableImage, UxmlTraits> { }
public new class UxmlTraits : Image.UxmlTraits
{
private readonly UxmlStringAttributeDescription _bindingImageAttribute = new()
{ name = "binding-image-path", defaultValue = "" };
public override void Init(VisualElement visualElement, IUxmlAttributes bag, CreationContext context)
{
base.Init(visualElement, bag, context);
((BindableImage) visualElement).BindingImagePath = _bindingImageAttribute.GetValueFromBag(bag, context);
}
}
}
Now you can use the new UI element as following.
public class ImageViewerViewModel : IBindingContext
{
public ImageItemViewModel(Texture2D image)
{
Image = new ReadOnlyProperty<Texture2D>(image);
}
public IReadOnlyProperty<Texture2D> Image { get; }
}
<UXML>
<BindableImage binding-image-path="Image" />
</UXML>
Source code generator
The best way to speed up the creation of custom VisualElement
is to use source code generators. With this powerful tool, you can achieve the same great results with minimal boilerplate code and focus on what really matters: programming!
Let's create the BindableImage
control, but this time using source code generators.
For a visual element without bindings, we will use a UnityUxmlGenerator.
[UxmlElement]
public partial class Image : VisualElement
{
public void SetImage(Texture2D image)
{
style.backgroundImage = new StyleBackground(image);
}
}
<details><summary><b>Generated code</b></summary>
<br />
Image.UxmlFactory.g.cs
partial class Image
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityUxmlGenerator", "1.0.0.0")]
public new class UxmlFactory : global::UnityEngine.UIElements.UxmlFactory<Image, UxmlTraits>
{
}
}
</details>
For a bindable visual element, we will use a UnityMvvmToolkit.Generator.
[BindableElement]
public partial class BindableImage : Image
{
[BindableProperty]
private IReadOnlyProperty<Texture2D> _imageProperty;
partial void AfterSetBindingContext(IBindingContext context, IObjectProvider objectProvider)
{
SetImage(_imageProperty?.Value);
}
partial void AfterResetBindingContext(IObjectProvider objectProvider)
{
SetImage(null);
}
partial void OnImagePropertyValueChanged([CanBeNull] Texture2D value)
{
SetImage(value);
}
}
<details><summary><b>Generated code</b></summary>
<br />
BindableImage.Bindings.g.cs
partial class BindableImage : global::UnityMvvmToolkit.Core.Interfaces.IBindableElement
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
private global::UnityMvvmToolkit.Core.PropertyBindingData? _imageBindingData;
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public void SetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IBindingContext context,
global::UnityMvvmToolkit.Core.Interfaces.IObjectProvider objectProvider)
{
BeforeSetBindingContext(context, objectProvider);
if (string.IsNullOrWhiteSpace(BindingImagePath) == false)
{
_imageBindingData ??=
global::UnityMvvmToolkit.Core.Extensions.StringExtensions.ToPropertyBindingData(BindingImagePath!);
_imageProperty = objectProvider.RentReadOnlyProperty<global::UnityEngine.Texture2D>(context, _imageBindingData!);
_imageProperty!.ValueChanged += OnImagePropertyValueChanged;
}
AfterSetBindingContext(context, objectProvider);
}
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public void ResetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IObjectProvider objectProvider)
{
BeforeResetBindingContext(objectProvider);
if (_imageProperty != null)
{
_imageProperty!.ValueChanged -= OnImagePropertyValueChanged;
objectProvider.ReturnReadOnlyProperty(_imageProperty);
_imageProperty = null;
}
AfterResetBindingContext(objectProvider);
}
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
private void OnImagePropertyValueChanged(object sender, global::UnityEngine.Texture2D value)
{
OnImagePropertyValueChanged(value);
}
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
partial void BeforeSetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IBindingContext context,
global::UnityMvvmToolkit.Core.Interfaces.IObjectProvider objectProvider);
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
partial void AfterSetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IBindingContext context,
global::UnityMvvmToolkit.Core.Interfaces.IObjectProvider objectProvider);
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
partial void BeforeResetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IObjectProvider objectProvider);
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
partial void AfterResetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IObjectProvider objectProvider);
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
partial void OnImagePropertyValueChanged(global::UnityEngine.Texture2D value);
}
BindableImage.Uxml.g.cs
partial class BindableImage
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
private string BindingImagePath { get; set; }
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
public new class UxmlFactory : global::UnityEngine.UIElements.UxmlFactory<BindableImage, UxmlTraits>
{
}
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
public new class UxmlTraits : global::BindableUIElements.Image.UxmlTraits
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
private readonly global::UnityEngine.UIElements.UxmlStringAttributeDescription _bindingImagePath = new()
{ name = "binding-image-path", defaultValue = "" };
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public override void Init(global::UnityEngine.UIElements.VisualElement visualElement,
global::UnityEngine.UIElements.IUxmlAttributes bag,
global::UnityEngine.UIElements.CreationContext context)
{
base.Init(visualElement, bag, context);
var control = (BindableImage) visualElement;
control.BindingImagePath = _bindingImagePath.GetValueFromBag(bag, context);
}
}
}
</details>
As you can see, using UnityUxmlGenerator and UnityMvvmToolkit.Generator we can achieve the same results but with just a few lines of code.
Note: The UnityMvvmToolkit.Generator is available exclusively for my patrons.
:link: External Assets
UniTask
To enable async commands support, you need to add the UniTask package to your project.
In addition to async commands UnityMvvmToolkit provides extensions to make USS transition's awaitable.
For example, your VisualElement
has the following transitions.
.panel--animation {
transition-property: opacity, padding-bottom;
transition-duration: 65ms, 150ms;
}
You can await
these transitions using several methods.
public async UniTask DeactivatePanel()
{
try
{
panel.style.opacity = 0;
panel.style.paddingBottom = 0;
// Await for the 'opacity' || 'paddingBottom' to end or cancel.
await panel.WaitForAnyTransitionEnd();
// Await for the 'opacity' & 'paddingBottom' to end or cancel.
await panel.WaitForAllTransitionsEnd();
// Await 150ms.
await panel.WaitForLongestTransitionEnd();
// Await 65ms.
await panel.WaitForTransitionEnd(0);
// Await for the 'paddingBottom' to end or cancel.
await panel.WaitForTransitionEnd(new StylePropertyName("padding-bottom"));
// Await for the 'paddingBottom' to end or cancel.
// Uses ReadOnlySpan to match property names to avoid memory allocation.
await panel.WaitForTransitionEnd(nameof(panel.style.paddingBottom));
// Await for the 'opacity' || 'paddingBottom' to end or cancel.
// You can write your own transition predicates, just implement a 'ITransitionPredicate' interface.
await panel.WaitForTransitionEnd(new TransitionAnyPredicate());
}
finally
{
panel.visible = false;
}
}
Note: All transition extensions have a
timeoutMs
parameter (default value is2500ms
).
:rocket: Performance
Memory allocation
The UnityMvvmToolkit uses object pools under the hood and reuses created objects. You can warm up certain objects in advance to avoid allocations during execution time.
public abstract class BaseView<TBindingContext> : DocumentView<TBindingContext>
where TBindingContext : class, IBindingContext
{
protected override IObjectProvider GetObjectProvider()
{
return new BindingContextObjectProvider(new IValueConverter[] { new IntToStrConverter() })
// Finds and warmups all classes from calling assembly that implement IBindingContext.
.WarmupAssemblyViewModels()
// Finds and warmups all classes from certain assembly that implement IBindingContext.
.WarmupAssemblyViewModels(Assembly.GetExecutingAssembly())
// Warmups a certain class.
.WarmupViewModel<CounterViewModel>()
// Warmups a certain class.
.WarmupViewModel(typeof(CounterViewModel))
// Creates 5 instances to rent 'IProperty<string>' without any allocations.
.WarmupValueConverter<IntToStrConverter>(5);
}
}
:bookmark_tabs: Contributing
You may contribute in several ways like creating new features, fixing bugs or improving documentation and examples.
Discussions
Use discussions to have conversations and post answers without opening issues.
Discussions is a place to:
- Share ideas
- Ask questions
- Engage with other community members
Report a bug
If you find a bug in the source code, please create bug report.
Please browse existing issues to see whether a bug has previously been reported.
Request a feature
If you have an idea, or you're missing a capability that would make development easier, please submit feature request.
If a similar feature request already exists, don't forget to leave a "+1" or add additional information, such as your thoughts and vision about the feature.
Show your support
Give a :star: if this project helped you!
<a href="https://www.buymeacoffee.com/chebanovdd" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-orange.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
:balance_scale: License
Usage is provided under the MIT License.