tl;dr: Please get rid of the View class (or autogenerate it using SourceGenerator for a specific ViewModel) so that you can directly hook the UI component binders to the ViewModel.
Okay, so I read the documentation, checked out a few examples you prepared, and I have one big problem with the package architecture.
As I mentioned in my previous post, I am currently using the “Data Bind for Unity” package, and apart from the fact that its documentation is very limited and simply poor, it approaches the subject of binding differently than Aspid.MVVM. In my opinion, it’s better. What’s it all about?
Main problem: Aspid.MVVM unnecessarily divides the View layer in the MVVM model into two parts:
- UI components (Image, Text, Button) arranged in the editor by the designer
- the View class created by the programmer
In .NET MVVM, the View layer is defined in XAML (created manually or generated by various UI building block tools). In Unity, I would expect the View layer to be defined using the Unity Editor and adding the appropriate UI objects to the Canvas. In the case of Aspid, not only does the designer arrange UI elements in the editor, but they also have to develop the View class along with it, which often looks like a Cpp Header file for ViewModel (I mean that it often declares what is already defined in the ViewModel class).
Why can’t we just have a ViewModel that is visible to Binder components, instead of needing an additional View class? Additionally, it looks as if data binding (key in MVVM architecture) is between UI components ↔ View, rather than between View (which consists of UI components and is not a separate part of it) ↔ ViewModel.
For comparison, here’s how it currently looks in my project using “Data Bind for Unity”:
For example, to display a message on the UI:
- I define ViewModel (also known as Context)
[Serializable]
public class MessageContext : BindableContextBase
{
[SerializeField] private TalkAction _talkAction;
private string _messageId;
public string MessageId
{
get => _messageId;
private set => SetField(ref _messageId, value);
}
private string _nickname;
public string Nickname
{
get => _nickname;
private set => SetField(ref _nickname, value);
}
private bool _isPlayer;
public bool IsPlayer
{
get => _isPlayer;
private set => SetField(ref _isPlayer, value);
}
private string _message;
public string Message
{
get => _message;
private set => SetField(ref _message, value);
}
public MessageContext()
{ }
public MessageContext(TalkAction talkAction) : this()
{
_talkAction = talkAction;
}
public override void Initialize()
{
base.Initialize();
MessageId = _talkAction.Id.ToString();
Nickname = _talkAction.Talkable.Nickname;
IsPlayer = _talkAction.Talkable is PlayerController;
Message = _talkAction.Message;
_talkAction.PropertyChanged += OnTalkActionPropertyChanged;
}
protected override void Dispose(bool disposing)
{
_talkAction.PropertyChanged -= OnTalkActionPropertyChanged;
base.Dispose(disposing);
}
private void OnTalkActionPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TalkAction.Message))
{
Message = _talkAction.Message;
}
}
}
public abstract class BindableContextBase : IBindableContext
{ define Initialize, Dispose, SetField, other helper methods }
public interface IBindableContext : INotifyPropertyChanged, IInitializable, IDisposable
{ }
TalkAction is my model for user messages. In my case, Message can change at runtime, so I listen for changes from the model, but “Id, Nickname, and IsPlayer” cannot, so I assign them only once in Initialize. Properties could easily be generated using Source Generator and an attribute, as you did with [Bind] attribute.
- I define ContextCreator, which is limited to an empty class (Source Generator would also help here, just like your [ViewModel] attribute could generate it):
public class MessageContextCreator : ContextCreatorBase<MessageContext>
{ }
public abstract partial class ContextCreatorBase : MonoBehaviour
{
[GenerateProperty(Access.Public, Access.Private)]
[SerializeField] private ContextHolder _contextHolder;
protected virtual void Reset()
{
_contextHolder = GetComponent<ContextHolder>();
}
}
// BASE class
public abstract class ContextCreatorBase<T> : ContextCreatorBase
where T : BindableContextBase
{
[SerializeReference] private T _context;
private void Start()
{
_context.Initialize();
ContextHolder.Context = _context;
}
private void OnDestroy()
{
_context.Dispose();
}
}
ContextCreatorBase is a MonoBehavior component that creates Contexts (ViewModels) by adding it as a component to the inspector and passing it to ContextHolder. You don’t have to use it, and you could delegate the creation of ViewModel (Context) to another place, e.g., using DI tools or anywhere else. Creating it from the MonoBehavior/Inspector component is simply convenient for the designer.
ContextHolder is a universal component through which UI components are attached to a given context (ViewModel).
- I create the UI using UI components (Image, Text, Button, etc.). The parent of all these objects is the object where I placed ContextHolder component. Using binding components, I can connect to ContextHolder (or, more precisely, ViewModel). ContextHolder is only a dumb MonoBehavior class, which can be used as a Component to hold ViewModel which is a pure C# class and cannot be attached as a component to the Inspector.
- If you define a method in MessageContext, you can also call that method from a Button (Command pattern).
As you can see, the programmer defines what should be in the ViewModel (MessageContext), including the data and methods that can be called from the UI, as well as the models that require access. We can connect these models from the Inspector (MessageContextCreator exposes serialized variables) or even use DI.
ContextCreator (MessageContextCreator) is a component that creates context, but it could also be created from another place (Bootstraper, other Context or something else). The MessageContextCreator component could be generated from Source Generator if there is applied the [ViewModel] attribute on the MessageContext class.
Instead, the designer arranges the UI using objects and components, and when they want to connect Text, Button, or anything else, they use Binder components (TextMeshPro Text Setter, Button Click Command, etc.), which indirectly provide data/methods from ViewModel through the universal ContextHolder.
The programmer exposes data/methods, the designer just hooks them up!
However, Aspid.MVVM has one additional step that, in my opinion, makes things more difficult, namely the View class, in which you have to define MonoBinders. Even in typical .NET applications, you don’t have anything like that. You define the View using XAML and the ViewModel in another class. There is no View class that you MUST define, as in Aspid. Who should write such a class? A programmer or a designer? The programmer will actually repeat what they have already defined in the ViewModel class, only using the MonoBinder type and [RequireBinder]. The variables must also have exactly the same names in ViewModel and View, which is not conducive to later code refactoring:
If a programmer writes code (ViewModel and View?) and a designer arranges the UI in an editor, this approach is not convenient, but rather limiting for the designer, who will then want to connect to it:
You should not limit someone who is making UI to using only 1 binder per data (_inputText in this case).
Summary: In my opinion, View classes are unnecessary. The View layer in .NET is XAML, and in the context of Unity, it is UI components and their arrangement in the editor. The View layer should be accessible to the designer (hence the simple XAML markup language in .NET instead of defining the UI in C# code; or some sort of visual UI editors such as UGUI or UI Toolkit Builder). Furthermore, UI elements should be attached to the ViewModel, not to the View class. Okay, something has to hold a reference to the ViewModel, but there definitely shouldn’t be any extra code to make that possible. In the example I gave above, it is ContextHolder (universal for every ViewModel) that holds the ViewModel, and ContextCreator that creates a ViewModel.
—===—===—===—===—
tl;dr: Get rid of OneWay, TwoWay, OneTime, OneWayToSource binding attributes. Bind attribute should only expose data, not define how it needs to be linked to UI.
Another shorter, but equally serious problem:
ViewModels expose state and notifications. Views decides whether a given property is OneWay, TwoWay, OneTime, etc. ViewModel shouldn’t know how the UI will present or edit the data!
Bind attribute should only expose the data, not decide how the UI can be attached to it.
It took me some time to write this and explain the things. Please consider these changes. I am also open to discussion, because in general, the package looks good, but it has these flaws 