Dangles91 / Godot.Community.ControlBinding

A WPF-style control binding implementation for Godot 4.0 implemented using INotifyPropertyChanged

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

ControlBinding

A WPF-style control binding implementation for Godot Mono

📦 Packages

Demo

🎬 Movie
demo.mp4

🚋 Further development

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

Overview

Godot.Community.ControlBinding implements control bindings using Microsofts System.ComponentModel INotifyPropertyChanged and INotifyCollectionChanged interfaces.

🎯 Features

Property binding

Simple property binding from Godot controls to C# properties

List binding

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.

Enum list binding

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");

Two-way binding

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

Automatic type conversion for most common types. Eg, binding string value "123" to int

Custom formatters

Specify a custom IValueFormatter to format/convert values to and from the bound control and target property

Custom list item formatters

List items can be further customised during binding by implementing a custom IValueFormatter that returns a ListItem with the desired properties

Deep binding

Binding to target properties is implemented using a path syntax. eg. MyClass.MyClassName will bind to the MyClassName property on the MyClass object.

Automatic rebinding

If any objects along the path are updated, the binding will be refreshed. Objects along the path must inherit from ObservableObject or implement INotifyPropertyChanged.

Scene list binding

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.

scenelist

🧰 Usage

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 example project for some bindings in action!

Property binding

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);
}

Deep property binding

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); }
}

Formatters

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 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();
}

Scene List Binding

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 input validation

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 the BindingContext 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();
    };
}

About

A WPF-style control binding implementation for Godot 4.0 implemented using INotifyPropertyChanged

License:MIT License


Languages

Language:C# 100.0%