Skip to content

Repository files navigation

UnityMvvmToolkit

A package that brings data-binding to your Unity project.

git-main

📖 Table of Contents

📝 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.

CounterView
<UXML>
<BindableContentPagebinding-theme-mode-path="ThemeMode"class="counter-screen">
<VisualElementclass="number-container">
<BindableCountLabelbinding-text-path="Count"class="count-label count-label--animation" />
</VisualElement>
<BindableThemeSwitcherbinding-value-path="ThemeMode, Converter={ThemeModeToBoolConverter}" />
<BindableCounterSliderincrement-command="IncrementCommand"decrement-command="DecrementCommand" />
</BindableContentPage>
</UXML>

Note: The namespaces are omitted to make the example more readable.

CounterViewModel
publicclassCounterViewModel:IBindingContext{publicCounterViewModel(){Count=newProperty<int>();ThemeMode=newProperty<ThemeMode>();IncrementCommand=newCommand(IncrementCount);DecrementCommand=newCommand(DecrementCount);}publicIProperty<int>Count{get;}publicIProperty<ThemeMode>ThemeMode{get;}publicICommandIncrementCommand{get;}publicICommandDecrementCommand{get;}privatevoidIncrementCount()=>Count.Value++;privatevoidDecrementCount()=>Count.Value--;}
CounterCalculatorToDoList
UnityMvvmCounter.mp4
UnityMvvmCalc.mp4
UnityMvvmToDoList.mp4

You will find all the samples in the samples folder.

🌵 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

⚙️ Installation

You can install UnityMvvmToolkit in one of the following ways:

1. Install via Package Manager

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 and UnityMvvmToolkit packages

2. Install via Git URL

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.

Instruction

build-settings

📒 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

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.

publicclassCounterViewModel:IBindingContext{publicCounterViewModel(){Count=newProperty<int>();}publicIProperty<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
publicclassCounterView:CanvasView<CounterViewModel>{// Override the base viewmodel instance creation.// Required in case the viewmodel doesn't have a parameterless constructor.protectedoverrideCounterViewModelGetBindingContext(){return_appContext.Resolve<CounterViewModel>();}// Define 'property' & 'parameter' value converters.protectedoverrideIValueConverter[]GetValueConverters(){return_appContext.Resolve<IValueConverter[]>();}// Define a collection item templates.protectedoverrideIReadOnlyDictionary<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
publicclassCounterView:DocumentView<CounterViewModel>{// Override the base viewmodel instance creation.// Required in case the viewmodel doesn't have a parameterless constructor.protectedoverrideCounterViewModelGetBindingContext(){return_appContext.Resolve<CounterViewModel>();}// Define 'property' & 'parameter' value converters.protectedoverrideIValueConverter[]GetValueConverters(){return_appContext.Resolve<IValueConverter[]>();}// Define a collection item templates.protectedoverrideIReadOnlyDictionary<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 a ValueChanged event

Simple property

Here's an example of how to implement a simple property.

publicclassCounterViewModel:IBindingContext{publicCounterViewModel(){Count=newProperty<int>();}publicIProperty<int>Count{get;}}
<ui:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabelbinding-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

publicclassMyViewModel:IBindingContext{[Observable("Count")]privatereadonlyIProperty<int>_amount=newProperty<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]privatereadonlyIProperty<string>_title=newProperty<string>();}
<ui:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabelbinding-text-path="Count" />
<uitk:BindableLabelbinding-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.

publicclassMyViewModel:IBindingContext{[Observable("PreviousPropertyName")]publicIReadOnlyProperty<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.

publicclassUserViewModel:IBindingContext{privatereadonlyUser_user;[Observable(nameof(Name))]privatereadonlyIProperty<string>_name=newProperty<string>();publicUserViewModel(Useruser){_user=user;_name.Value=user.Name;}publicstringName{get=>_user.Name;set{if(_name.TrySetValue(value)){_user.Name=value;}}}}
<ui:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabelbinding-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.

publicpartialclassUserViewModel:IBindingContext{privatereadonlyUser_user;publicUserViewModel(Useruser){_user=user;_name.Value=user.Name;}[WithObservableBackingField]publicstringName{get=>_user.Name;set{if(_name.TrySetValue(value)){_user.Name=value;}}}}
Generated code

UserViewModel.BackingFields.g.cs

partialclassUserViewModel{[global::System.CodeDom.Compiler.GeneratedCode("UnityMvvmToolkit.Generator","1.0.0.0")][global::UnityMvvmToolkit.Core.Attributes.Observable(nameof(Name))]privatereadonlyglobal::UnityMvvmToolkit.Core.Interfaces.IProperty<string>_name=newglobal::UnityMvvmToolkit.Core.Property<string>();}

Waiting for the partial properties support to make it even shorter.

publicpartialclassUserViewModel:IBindingContext{privatereadonlyUser_user;publicUserViewModel(Useruser){_user=user;_name.Value=user.Name;}[WithObservableBackingField]publicpartialstringName{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.

publicclassItemViewModel:ICollectionItem{[Observable(nameof(Name))]privatereadonlyIProperty<string>_name=newProperty<string>();publicintId{get;set;}publicstringName{get=>_name.Value;set=>_name.Value=value;}}
<ui:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabelbinding-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.

publicpartialclassItemViewModel:ICollectionItem{publicintId{get;set;}[WithObservableBackingField]publicstringName{get=>_name.Value;set=>_name.Value=value;}}
Generated code

ItemViewModel.BackingFields.g.cs

partialclassItemViewModel{[global::System.CodeDom.Compiler.GeneratedCode("UnityMvvmToolkit.Generator","1.0.0.0")][global::UnityMvvmToolkit.Core.Attributes.Observable(nameof(Name))]privatereadonlyglobal::UnityMvvmToolkit.Core.Interfaces.IProperty<string>_name=newglobal::UnityMvvmToolkit.Core.Property<string>();}

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 a RaiseCanExecuteChanged method to raise the CanExecuteChanged event
  • Expose constructor taking delegates like Action and Action<T>, which allow the wrapping of standard methods and lambda expressions

The following shows how to set up a simple command.

usingUnityMvvmToolkit.Core;usingUnityMvvmToolkit.Core.Interfaces;publicclassCounterViewModel:IBindingContext{publicCounterViewModel(){Count=newProperty<int>();IncrementCommand=newCommand(IncrementCount);}publicIProperty<int>Count{get;}publicICommandIncrementCommand{get;}privatevoidIncrementCount()=>Count.Value++;}

And the relative UI could then be.

<ui:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabelbinding-text-path="Count" />
<uitk:BindableButtoncommand="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 a DisableOnExecution property, as well as a Cancel 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.

publicclassImageViewerViewModel:IBindingContext{[Observable(nameof(Image))]privatereadonlyIProperty<Texture2D>_image;privatereadonlyIImageDownloader_imageDownloader;publicImageViewerViewModel(IImageDownloaderimageDownloader){_image=newProperty<Texture2D>();_imageDownloader=imageDownloader;DownloadImageCommand=newAsyncCommand(DownloadImageAsync);}publicTexture2DImage=>_image.Value;publicIAsyncCommandDownloadImageCommand{get;}privateasyncUniTaskDownloadImageAsync(CancellationTokencancellationToken){_image.Value=await_imageDownloader.DownloadRandomImageAsync(cancellationToken);}}

With the related UI code.

<ui:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<BindableImagebinding-image-path="Image" />
<uitk:BindableButtoncommand="DownloadImageCommand">
<ui:Labeltext="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.

publicclassImageViewerViewModel:IBindingContext{publicImageViewerViewModel(IImageDownloaderimageDownloader){
...DownloadImageCommand=newAsyncCommand(DownloadImageAsync){DisableOnExecution=true};}}

To allow the same async command to be invoked concurrently multiple times, set the AllowConcurrency property of the AsyncCommand to true.

publicclassMainViewModel:IBindingContext{publicMainViewModel(){RunConcurrentlyCommand=newAsyncCommand(RunConcurrentlyAsync){AllowConcurrency=true};}}

If you want to create an async command that supports cancellation, use the WithCancellation extension method.

publicclassMyViewModel:IBindingContext{publicMyViewModel(){MyAsyncCommand=newAsyncCommand(DoSomethingAsync).WithCancellation();CancelCommand=newCommand(Cancel);}publicIAsyncCommandMyAsyncCommand{get;}publicICommandCancelCommand{get;}privateasyncUniTaskDoSomethingAsync(CancellationTokencancellationToken){
...}privatevoidCancel(){// 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.

publicenumThemeMode{Light=0,Dark=1}publicclassThemeModeToBoolConverter:PropertyValueConverter<ThemeMode,bool>{// From source to target. publicoverrideboolConvert(ThemeModevalue){return(int)value==1;}// From target to source.publicoverrideThemeModeConvertBack(boolvalue){return(ThemeMode)(value?1:0);}}

Don't forget to register the ThemeModeToBoolConverter in the view.

publicclassMyView:DocumentView<MyViewModel>{protectedoverrideIValueConverter[]GetValueConverters(){returnnewIValueConverter[]{newThemeModeToBoolConverter()};}}

Then you can use the ThemeModeToBoolConverter as in the following example.

<UXML>
<!--Full expression-->
<MyBindableElementbinding-value-path="ThemeMode, Converter={ThemeModeToBoolConverter}" />
<!--Short expression-->
<MyBindableElementbinding-value-path="ThemeMode, ThemeModeToBoolConverter" />
<!--Minimal expression - the first appropriate converter will be used-->
<MyBindableElementbinding-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.

publicclassMyViewModel:IBindingContext{publicMyViewModel(){PrintParameterCommand=newCommand<string>(PrintParameter);}publicICommand<string>PrintParameterCommand{get;}privatevoidPrintParameter(stringparameter){Debug.Log(parameter);}}
<UXML>
<BindableButtoncommand="PrintParameterCommand, Parameter={MyParameter}" />
<!--or-->
<BindableButtoncommand="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.

publicclassParameterToIntConverter:ParameterValueConverter<int>{publicoverrideintConvert(stringparameter){returnint.Parse(parameter);}}

Don't forget to register the ParameterToIntConverter in the view.

publicclassMyView:DocumentView<MyViewModel>{protectedoverrideIValueConverter[]GetValueConverters(){returnnewIValueConverter[]{newParameterToIntConverter()};}}

Then you can use the ParameterToIntConverter as in the following example.

publicclassMyViewModel:IBindingContext{publicMyViewModel(){PrintParameterCommand=newCommand<int>(PrintParameter);}publicICommand<int>PrintParameterCommand{get;}privatevoidPrintParameter(intparameter){Debug.Log(parameter);}}
<UXML>
<!--Full expression-->
<BindableButtoncommand="PrintIntParameterCommand, Parameter={5}, Converter={ParameterToIntConverter}" />
<!--Short expression-->
<BindableButtoncommand="PrintIntParameterCommand, 5, ParameterToIntConverter" />
<!--Minimal expression - the first appropriate converter will be used-->
<BindableButtoncommand="PrintIntParameterCommand, 5" />
</UXML>

⌚ Quick start

Once the UnityMVVMToolkit is installed, create a class MyFirstViewModel that implements the IBindingContext interface.

usingUnityMvvmToolkit.Core;usingUnityMvvmToolkit.Core.Interfaces;publicclassMyFirstViewModel:IBindingContext{publicMyFirstViewModel(){Text=newReadOnlyProperty<string>("Hello World");}publicIReadOnlyProperty<string>Text{get;}}

UI Toolkit

The next step is to create a class MyFirstDocumentView that inherits the DocumentView<TBindingContext> class.

usingUnityMvvmToolkit.UITK;publicclassMyFirstDocumentView:DocumentView<MyFirstViewModel>{}

Then create a file MyFirstView.uxml, add a BindableLabel control and set the binding-text-path to Text.

<ui:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabelbinding-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.

UI Document Inspector

ui-document-inspector

Unity UI (uGUI)

For the uGUI do the following. Create a class MyFirstCanvasView that inherits the CanvasView<TBindingContext> class.

usingUnityMvvmToolkit.UGUI;publicclassMyFirstCanvasView:CanvasView<MyFirstViewModel>{}

Then add a Canvas to the scene, and add the MyFirstCanvasView component to it.

Canvas Inspector

canvas-inspector

Finally, add a Text - TextMeshPro UI element to the canvas, add the BindableLabel component to it and set the BindingTextPath to Text.

Canvas Text Inspector

canvas-text-inspector

🕹️ How To Use

Data-binding

The package contains a set of standard bindable UI elements out of the box.

The included UI elements are:

Note: The BindableListView & BindableScrollView are provided for UI Toolkit only.

BindableLabel

The BindableLabel element uses the OneWay binding by default.

publicclassLabelViewModel:IBindingContext{publicLabelViewModel(){IntValue=newProperty<int>(55);StrValue=newProperty<string>("69");}publicIReadOnlyProperty<int>IntValue{get;}publicIReadOnlyProperty<string>StrValue{get;}}publicclassLabelView:DocumentView<LabelViewModel>{protectedoverrideIValueConverter[]GetValueConverters(){returnnewIValueConverter[]{newIntToStrConverter()};}}
<ui:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabelbinding-text-path="StrValue" />
<uitk:BindableLabelbinding-text-path="IntValue" />
</ui:UXML>

BindableTextField

The BindableTextField element uses the TwoWay binding by default.

publicclassTextFieldViewModel:IBindingContext{publicTextFieldViewModel(){TextValue=newProperty<string>();}publicIProperty<string>TextValue{get;}}
<ui:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableTextFieldbinding-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.

publicclassDropdownFieldViewModel:IBindingContext{publicDropdownFieldViewModel(){varitems=newObservableCollection<string>{"Value 1","Value 2","Value 3"};Items=newReadOnlyProperty<ObservableCollection<string>>(items);SelectedItem=newProperty<string>(items[0]);}publicIReadOnlyProperty<ObservableCollection<string>>Items{get;}publicIProperty<string>SelectedItem{get;}}
<ui:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableDropdownFieldbinding-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:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabelbinding-text-path="Name" />
</ui:UXML>

Create a UserItemViewModel class that implements ICollectionItem to store user data.

publicclassUserItemViewModel:ICollectionItem{[Observable(nameof(Name))]privatereadonlyIProperty<string>_name=newProperty<string>();publicUserItemViewModel(){Id=Guid.NewGuid().GetHashCode();}publicintId{get;}publicstringName{get=>_name.Value;set=>_name.Value=value;}}

Create a UserListView that inherits the BindableListView<TItemBindingContext> abstract class.

publicclassUserListView:BindableListView<UserItemViewModel>{publicnewclassUxmlFactory:UxmlFactory<UserListView,UxmlTraits>{}}

Create a UsersViewModel.

publicclassUsersViewModel:IBindableContext{publicUsersViewModel(){varusers=newObservableCollection<UserItemViewModel>{new(){Name="User 1"},new(){Name="User 2"},new(){Name="User 3"},};Users=newReadOnlyProperty<ObservableCollection<UserItemViewModel>>(users);}publicIReadOnlyProperty<ObservableCollection<UserItemViewModel>>Users{get;}}

Now we need to provide an item template for the UserItemViewModel. Create a UsersView as follows.

publicclassUsersView:DocumentView<UsersViewModel>{[SerializeField]privateVisualTreeAsset_userItemViewAsset;protectedoverrideIReadOnlyDictionary<Type,object>GetCollectionItemTemplates(){returnnewDictionary<Type,object>{{typeof(UserItemViewModel),_userItemViewAsset}};}}

Starting with Unity 2023, you can select an ItemTemplate directly in the UI Builder.

UI Builder Inspector

collection-item-template

Finally, create a main UI Document named UsersView.uxml with the following content.

<ui:UXML ...>
<UserListViewbinding-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.

publicclassMainViewModel:IBindingContext{[Observable]privatereadonlyIReadOnlyProperty<string>_title;[Observable]privatereadonlyIReadOnlyProperty<CustomViewModel>_customViewModel;publicMainViewModel(){_title=newReadOnlyProperty<string>("Main Context");_customViewModel=newReadOnlyProperty<CustomViewModel>(newCustomViewModel());}}
publicclassCustomViewModel:IBindingContext{[Observable]privatereadonlyIReadOnlyProperty<string>_title;publicCustomViewModel(){_title=newReadOnlyProperty<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:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabelname="Label1"binding-text-path="Title" />
<!-- Binding context not specified. Will be used MainViewModel for all childs. -->
<uitk:BindingContextProvider>
<uitk:BindableLabelname="Label2"binding-text-path="Title" />
</uitk:BindingContextProvider>
<!-- Binding context is specified. Will be used CustomViewModel for all childs. -->
<uitk:BindingContextProviderbinding-context-path="CustomViewModel">
<uitk:BindableLabelname="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]publicpartialclassCustomViewModelProvider: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:UXMLxmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
<uitk:BindableLabelname="Label1"binding-text-path="Title" />
<!-- Binding context not specified. Will be used MainViewModel for all childs. -->
<CustomViewModelProvider>
<uitk:BindableLabelname="Label2"binding-text-path="Title" />
</CustomViewModelProvider>
<!-- Binding context is specified. Will be used CustomViewModel for all childs. -->
<CustomViewModelProviderbinding-context-path="CustomViewModel">
<uitk:BindableLabelname="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.

publicclassImage:VisualElement{publicvoidSetImage(Texture2Dimage){style.backgroundImage=newStyleBackground(image);}publicnewclassUxmlFactory:UxmlFactory<Image,UxmlTraits>{}}

Then create a BindableImage class and implement the data binding logic.

publicclassBindableImage:Image,IBindableElement{privatePropertyBindingData_imagePathBindingData;privateIReadOnlyProperty<Texture2D>_imageProperty;publicstringBindingImagePath{get;privateset;}publicvoidSetBindingContext(IBindingContextcontext,IObjectProviderobjectProvider){_imagePathBindingData??=BindingImagePath.ToPropertyBindingData();_imageProperty=objectProvider.RentReadOnlyProperty<Texture2D>(context,_imagePathBindingData);_imageProperty.ValueChanged+=OnImagePropertyValueChanged;SetImage(_imageProperty.Value);}publicvoidResetBindingContext(IObjectProviderobjectProvider){if(_imageProperty==null){return;}_imageProperty.ValueChanged-=OnImagePropertyValueChanged;objectProvider.ReturnReadOnlyProperty(_imageProperty);_imageProperty=null;SetImage(null);}privatevoidOnImagePropertyValueChanged(objectsender,Texture2DnewImage){SetImage(newImage);}publicnewclassUxmlFactory:UxmlFactory<BindableImage,UxmlTraits>{}publicnewclassUxmlTraits:Image.UxmlTraits{privatereadonlyUxmlStringAttributeDescription_bindingImageAttribute=new(){name="binding-image-path",defaultValue=""};publicoverridevoidInit(VisualElementvisualElement,IUxmlAttributesbag,CreationContextcontext){base.Init(visualElement,bag,context);((BindableImage)visualElement).BindingImagePath=_bindingImageAttribute.GetValueFromBag(bag,context);}}}

Now you can use the new UI element as following.

publicclassImageViewerViewModel:IBindingContext{publicImageItemViewModel(Texture2Dimage){Image=newReadOnlyProperty<Texture2D>(image);}publicIReadOnlyProperty<Texture2D>Image{get;}}
<UXML>
<BindableImagebinding-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]publicpartialclassImage:VisualElement{publicvoidSetImage(Texture2Dimage){style.backgroundImage=newStyleBackground(image);}}
Generated code

Image.UxmlFactory.g.cs

partialclassImage{[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityUxmlGenerator","1.0.0.0")]publicnewclassUxmlFactory:global::UnityEngine.UIElements.UxmlFactory<Image,UxmlTraits>{}}

For a bindable visual element, we will use a UnityMvvmToolkit.Generator.

[BindableElement]publicpartialclassBindableImage:Image{[BindableProperty]privateIReadOnlyProperty<Texture2D>_imageProperty;partialvoidAfterSetBindingContext(IBindingContextcontext,IObjectProviderobjectProvider){SetImage(_imageProperty?.Value);}partialvoidAfterResetBindingContext(IObjectProviderobjectProvider){SetImage(null);}partialvoidOnImagePropertyValueChanged([CanBeNull]Texture2Dvalue){SetImage(value);}}
Generated code

BindableImage.Bindings.g.cs

partialclassBindableImage:global::UnityMvvmToolkit.Core.Interfaces.IBindableElement{[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")]privateglobal::UnityMvvmToolkit.Core.PropertyBindingData?_imageBindingData;[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")][global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]publicvoidSetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IBindingContextcontext,global::UnityMvvmToolkit.Core.Interfaces.IObjectProviderobjectProvider){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]publicvoidResetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IObjectProviderobjectProvider){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]privatevoidOnImagePropertyValueChanged(objectsender,global::UnityEngine.Texture2Dvalue){OnImagePropertyValueChanged(value);}[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")]partialvoidBeforeSetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IBindingContextcontext,global::UnityMvvmToolkit.Core.Interfaces.IObjectProviderobjectProvider);[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")]partialvoidAfterSetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IBindingContextcontext,global::UnityMvvmToolkit.Core.Interfaces.IObjectProviderobjectProvider);[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")]partialvoidBeforeResetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IObjectProviderobjectProvider);[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")]partialvoidAfterResetBindingContext(global::UnityMvvmToolkit.Core.Interfaces.IObjectProviderobjectProvider);[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")]partialvoidOnImagePropertyValueChanged(global::UnityEngine.Texture2Dvalue);}

BindableImage.Uxml.g.cs

partialclassBindableImage{[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")][global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]privatestringBindingImagePath{get;set;}[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")]publicnewclassUxmlFactory:global::UnityEngine.UIElements.UxmlFactory<BindableImage,UxmlTraits>{}[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")]publicnewclassUxmlTraits:global::BindableUIElements.Image.UxmlTraits{[global::System.CodeDom.Compiler.GeneratedCodeAttribute("UnityMvvmToolkit.Generator","1.0.0.0")]privatereadonlyglobal::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]publicoverridevoidInit(global::UnityEngine.UIElements.VisualElementvisualElement,global::UnityEngine.UIElements.IUxmlAttributesbag,global::UnityEngine.UIElements.CreationContextcontext){base.Init(visualElement,bag,context);varcontrol=(BindableImage)visualElement;control.BindingImagePath=_bindingImagePath.GetValueFromBag(bag,context);}}}

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.

🔗 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.

publicasyncUniTaskDeactivatePanel(){try{panel.style.opacity=0;panel.style.paddingBottom=0;// Await for the 'opacity' || 'paddingBottom' to end or cancel.awaitpanel.WaitForAnyTransitionEnd();// Await for the 'opacity' & 'paddingBottom' to end or cancel.awaitpanel.WaitForAllTransitionsEnd();// Await 150ms.awaitpanel.WaitForLongestTransitionEnd();// Await 65ms.awaitpanel.WaitForTransitionEnd(0);// Await for the 'paddingBottom' to end or cancel.awaitpanel.WaitForTransitionEnd(newStylePropertyName("padding-bottom"));// Await for the 'paddingBottom' to end or cancel.// Uses ReadOnlySpan to match property names to avoid memory allocation.awaitpanel.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.awaitpanel.WaitForTransitionEnd(newTransitionAnyPredicate());}finally{panel.visible=false;}}

Note: All transition extensions have a timeoutMs parameter (default value is 2500ms).

🚀 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.

publicabstractclassBaseView<TBindingContext>:DocumentView<TBindingContext>whereTBindingContext:class,IBindingContext{protectedoverrideIObjectProviderGetObjectProvider(){returnnewBindingContextObjectProvider(newIValueConverter[]{newIntToStrConverter()})// 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);}}

📑 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 ⭐ if this project helped you!

Buy Me A Coffee

⚖️ License

Usage is provided under the MIT License.

About

Brings data-binding to your Unity project

Topics

Resources

Stars

543 stars

Watchers

10 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages