🎬 Movie
demo.mp4
Though functional, this project is in the early stages of development. More advanced features could still yet be developed, including:
- Binding control children
- Instantiate scenes as control children
- Control validation
- Control style formatting
- Creating an editor plugin to specify bindings in the editor
- Code generation to implement OnPropertyChanged via an attribute decorator
Source generators such as PropertyBinding.SourceGenerator can be used to implement
INotifyPropertyChanged
Godot.Community.ControlBinding implements control bindings using Microsofts System.ComponentModel INotifyPropertyChanged
and INotifyCollectionChanged
interfaces.
Simple property binding from Godot controls to C# properties
Bind list controls to an ObservableCollection<T>
. List bindings support OptionButton
and ItemList
controls.
If the list objects implement INotifyPropertyChanged
the controls will be updated to reflect changes made to the backing list.
A very specific list binding implementation to bind Enums to an OptionButton with support for a target property to store the selected option.
bindingContext.BindEnumProperty<BindingMode>(GetNode<OptionButton>("%OptionButton"), $"{nameof(SelectedPlayerData)}.BindingMode");
Some controls support two-way binding by subscribing to their update signals for supported properties. Supported properties:
- LineEdit.Text
- TextEdit.Text
- CodeEdit.Text
- Slider.Value, Progress.Value, SpinBox.Value, and ScrollBar.Value
- CheckBox.ButtonPressed
- OptionButton.Selected
Automatic type conversion for most common types. Eg, binding string value "123" to int
Specify a custom IValueFormatter
to format/convert values to and from the bound control and target property
List items can be further customised during binding by implementing a custom IValueFormatter
that returns a ListItem
with the desired properties
Binding to target properties is implemented using a path syntax. eg. MyClass.MyClassName
will bind to the MyClassName
property on the MyClass
object.
If any objects along the path are updated, the binding will be refreshed. Objects along the path must inherit from ObservableObject
or implement INotifyPropertyChanged
.
Bind an ObservableCollection<T>
to any control and provide a scene to instiate as child nodes. Modifications (add/remove) are reflected in the control's child list.
Scene list bindings have limited TwoWay binding support. Child items removed from the tree will also be removed from the bound list.
The main components of control binding are the ObservableObject
, ControlViewModel
, and NodeViewModel
classes which implement INotifyPropertyChanged
. These classes are included for ease of use, but you can inherit from your own base classes which implement INotifyPropertyChanged
or use source generators to implement this interface instead.
The script which backs your scene must implement INotifyPropertyChanged
.
Bindings are registered against a BindingContext
instance. This also provides support for input validation.
See the for some bindings in action!
Create a property with a backing field and trigger OnPropertyChanged
in the setter
private int spinBoxValue;
public int SpinBoxValue
{
get { return spinBoxValue; }
set { spinBoxValue = value; OnPropertyChanged(); }
}
Alternatively, use the SetValue
method to update the backing field and trigger OnPropertyChanged
private int spinBoxValue;
public int SpinBoxValue
{
get { return spinBoxValue; }
set { SetValue(ref spinBoxValue, value); }
}
Or use a source generator instead
[Notify] private int _spinBoxValue;
Add a binding in _Ready()
. This binding targets a control in the scene with the unique name %SpinBox with the BindingMode
TwoWay. A BindingMode of TwoWay states that we want the spinbox value to be set into the target property and vice-versa.
public override void _Ready()
{
BindingContext bindingContext = new(this);
bindingContext.BindProperty(GetNode("%SpinBox"), nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay);
// alternatively, use the extensions methods
GetNode("%SpinBox").BindProperty(bindingContext, nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay);
}
Bind to property members on other objects. These objects and properties must be relative to the current scene script.
details
// Bind to SelectedPlayerData.Health
bindingContext.BindProperty(GetNode("%LineEdit"), nameof(LineEdit.Text), $"{nameof(SelectedPlayerData)}.{nameof(PlayerData.Health)}", BindingMode.TwoWay);
// Alternatively represent this as a string path instead
bindingContext.BindProperty(GetNode("%LineEdit"), nameof(LineEdit.Text), "SelectedPlayerData.Health", BindingMode.TwoWay);
The property SelectedPlayerData
must notify about changes to automatically rebind the control. TwoWay binding also requires that the PlayerData class implements INotifyPropertyChanged
to notify of property changes.
private PlayerData selectedPlayerData = new();
public PlayerData SelectedPlayerData
{
get { return selectedPlayerData; }
set { SetValue(ref selectedPlayerData, value); }
}
A binding can be declared with an optional formatter to format the value between your control and the target property or implement custom type conversion. Formatters can also be used to modify list items properties by returning a ListItem
object.
Formatter also have access to the target property value. In the example below, the v
parameter is the value from the source property and p
is the value of the target property.
details
public class PlayerHealthFormatter : IValueFormatter
{
public Func<object, object, object> FormatControl => (v, p) =>
{
return $"Player health: {v}";
};
public Func<object, object, object> FormatTarget => (v, p) =>
{
throw new NotImplementedException();
};
}
bindingContext.BindProperty(GetNode("%SpinBox"), nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay, new PlayerHealthFormatter());
This formatter will set a string value into the target control using the input value substituted into a string. FormatControl
is not implemented here so the value would be passed back as-is in the case of a two-way binding.
List bindings can be bound to an ObservableCollection<T>
(or any data structure that implements INotifyCollectionChanged
) to benefit from adding and removing items
details
public ObservableList<PlayerData> PlayerDatas {get;set;} = new(){
new PlayerData{Health = 500},
};
bindingContext.BindListProperty(GetNode("%ItemList2"), nameof(PlayerDatas), formatter: new PlayerDataListFormatter());
The PlayerDataListFormatter
formats the PlayerData entry into a usable string value using a ListItem
to also provided conditional formatting to the control
public class PlayerDataListFormatter : IValueFormatter
{
public Func<object, object> FormatControl => (v) =>
{
var pData = v as PlayerData;
var listItem = new ListItem
{
DisplayValue = $"Health: {pData.Health}",
Icon = ResourceLoader.Load<Texture2D>("uid://bfdb75li0y86u"),
Disabled = pData.Health < 1,
Tooltip = pData.Health == 0 ? "Health must be greater than 0" : null,
};
return listItem;
};
public Func<object, object> FormatTarget => throw new NotImplementedException();
}
Bind an ObservableCollection<T>
to a control's child list to add/remove children. The target scene must have a script attached and implement IViewModel
, which inherits from INotifyPropertyChanged
. It must also provide an implementation for SetViewModeldata()
from the IViewModel
interface.
details
Bind the control to a list and provide a path to the scene to instiate
bindingContext.BindSceneList(GetNode("%VBoxContainer"), nameof(PlayerDatas), "uid://die1856ftg8w8");
Scene implementation
public partial class PlayerDataListItem : ObservableNode
{
private PlayerData ViewModelData { get; set; }
public override void SetViewModelData(object viewModelData)
{
ViewModelData = viewModelData as PlayerData;
base.SetViewModelData(viewModelData);
}
public override void _Ready()
{
BindingContext bindingContext = new(this);
bindingContext.BindProperty(GetNode("%TextEdit"), "Text", "ViewModelData.Health", BindingMode.TwoWay);
base._Ready();
}
}
Control bindings can be validated by either:
- Adding validation function to the binding
- Throwing a
ValidationException
from a formatter
There also two main ways of subscribing to validation changed events:
- Subscribe to the
ControlValidationChanged
event on theBindingContext
your bindings reside on - Add a validation handler to the control binding
You can also use the HasErrors
property on a BindingContext
to notify your UI of errors and review a full list of validation errors using the GetValidationMessages()
method.
details
Adding validators and validation callbacks
Property bindings implement a fluent builder pattern for modify the binding upon creation to add validators and a validator callback.
You can have any number of validators but only one validation callback.
Validators are run the in the order they are registered and validation will stop at the first validator to return a non-empty string. Validators are run before formatters. The formatter will not be executed if a validation error occurs.
This example adds two validators and a callback to modulate the control and set the tooltip text.
bindingContext.BindProperty(GetNode("%LineEdit"), nameof(LineEdit.Text), $"{nameof(SelectedPlayerData)}.{nameof(PlayerData.Health)}", BindingMode.TwoWay)
.AddValidator(v => int.TryParse((string)v, out int value) ? null : "Health must be a number")
.AddValidator(v => int.TryParse((string)v, out int value) && value > 0 ? null : "Health must be greater than 0")
.AddValidationHandler((control, isValid, message) => {
control.Modulate = isValid ? Colors.White : Colors.Red;
control.TooltipText = message;
});
Subscribing to ControlValidationChanged
events
If you want to have common behaviour for many or all controls, you can subscribe to the ControlValidationChanged
event and get updates about all control validations.
This example subscribes to all validation changed events to modulate the target control and set the tooltip text.
The last validation error message is also stored in the local ErrorMessage property to be bound to a UI label.
public partial class MyClass : ObservableNode
{
private string errorMessage;
public string ErrorMessage
{
get { return errorMessage; }
set { SetValue(ref errorMessage, value); }
}
public override void _Ready()
{
BindingContext bindingContext = new(this);
bindingContext.BindProperty(GetNode("%ErrorLabel"), nameof(Label.Visible), $"{nameof(bindingContext)}.{nameof(HasErrors)}", BindingMode.OneWay);
bindingContext.BindProperty(GetNode("%ErrorLabel"), nameof(Label.Text), nameof(ErrorMessage), BindingMode.OneWay);
bindingContext.ControlValidationChanged += OnControlValidationChanged;
}
private void OnControlValidationChanged(control, propertyName, message, isValid)
{
control.Modulate = isValid ? Colors.White : Colors.Red;
control.TooltipText = message;
// set properties to bind to
ErrorMessage = message;
ValidationSummary = GetValidationMessages();
};
}