dotnet / winforms

Windows Forms is a .NET UI framework for building Windows desktop applications.
MIT License
4.35k stars 964 forks source link

[API Proposal]: Allow `Control.Invoke` on `Binding` #8582

Open zeh-almeida opened 1 year ago

zeh-almeida commented 1 year ago

Background and motivation

Bindings allow for Form Controls and models to be updated when their values are changed by the system in any way. This would allow the form to be reactive, with processes running in the background performing all the necessary actions and having them reflected by the bindings automatically.

This already works with MAUI, specially if you use the CommunityToolkit.Mvvm and its generators. However, while Winforms support bindings as well, it does not support asynchronous updates, because doing so would raise a InvalidOperationException.

This happens because the Binding class updates the value of the property directly, without checking if the control is in the UI Thread or not.

I then propose a new constructor to the Binding class in which you can set a flag to prefer using Control.Invoke when updating the bindings. Control.Invoke forces the Action to run at the UI Thread, solving the problem.

I have already opened an issue about this as well as a PR.

API Proposal

namespace System.Windows.Forms;

public class Binding
{
    public Binding(string propertyName, object dataSource, string dataMember, bool formattingEnabled, DataSourceUpdateMode dataSourceUpdateMode, object nullValue, string formatString, IFormatProvider formatInfo, bool invokeControl);
}
namespace System.Windows.Forms;

public class ControlBindingsCollection : BindingsCollection
{
    public Binding Add(string propertyName, object dataSource, string dataMember, bool formattingEnabled, DataSourceUpdateMode updateMode, object nullValue, string formatString, IFormatProvider formatInfo, bool invokeControl);
}

API Usage

// Create a new form and a text box to bind values to
Form form = new Form();
TextBox textBox = new TextBox();
textBox.Parent = form;

// Create a binding to handle the invoke method
Binding binding = new Binding("Text", mainObject, "Text", false, 0, null, string.Empty, null, true);
textBox.DataBindings.Add(binding);

form.Show();

// Perform the binding update in a separate thread to escape the UI Thread on purpose
var thread = new Thread(() =>
{
    textBox.Text = "Updated test text";

    Assert.Equal("Updated test text", textBox.Text);
    Assert.Equal("Updated test text", mainObject.Text);
});

thread.Start();

Alternative Designs

I have added a new constructor to the System.Windows.Forms.Binding class and a new public method to System.Windows.Forms.ControlBindingsCollection without changing previous APIs.

Risks

In my limited study of the APIs, I didn't see any breaking risk associated with this proposal. There may be performance repercussions in the Control.Invoke mechanic on heavy load but I was unable to test it.

The PR I mentioned has some tests which could be useful in analyzing those scenarios.

dotnet-issue-labeler[bot] commented 1 year ago

I couldn't figure out the best area label to add to this issue. If you have write-permissions please help me learn by adding exactly one area label.

KlausLoeffelmann commented 1 year ago

One concern that I have here is that 98% of the binding scenarios in WinForms are not done via code but via the WinForms Designer UI. We would need to figure out a way in the Designer to allow the user to use the new feature interactively. That would also mean changes to the CodeDOM serialization.

One alternative way to address this could be an option in the Designer for a Form/UserControl to use Invoke when necessary. We could implement that on the Designer-side as a Design-Time property for the Form or UserControl. (UseAutoBindingInvoke).

That said, if we would need to change the signature of Binding and in the view of the new Command Binding feature in WinForms, we should also take feedback into consideration, where people want a simplified way to handle Converters, preferably based on an Interface we'd need to add. Converters in WinForms binding are possible right now alright, but very awkwardly to hook up (namely via events for each binding item). I could see something like IBindingConverter similar to IValueConverter in WPF but with its naming adapted to WinForms to avoid confusion.

To simplify this, we could pass a Converter to the Binding class in addition, which would lead to internally wiring up the required events, running the passed converters when needed.

If we need to change Binding in more regards, we should do it in one go.

@merriemcgaw FYI.

KlausLoeffelmann commented 1 year ago

And of course, FYI @russkie, if time allows! 😸

RussKie commented 1 year ago

Thank you for tagging me.

My first reaction was that you'd likely want pretty much all bindings to go via the UI thread. With that in mind, the API signature change isn't required - you only would need to update the implementation (breaking docs notifications and all that entails). May be provide an opt-out mechanism, if necessary - through a feature switch or an API...

zeh-almeida commented 1 year ago

@RussKie so instead of having a new constructor like the one I made, are you suggesting I add a property with public get and set?

If that is correct, the behavior could be controlled at any time after instantiation, which sounds easier to manage on direct code but I don't know if that translates well to generator use.

Of course, we could have both options as well, I just want to make it clear because I can adjust my PR

kirsan31 commented 1 year ago

Guys, may be I missed something, but I can't get core point here :( The code from 1 post is wrong even without binding:

var thread = new Thread(() =>
{
    textBox.Text = "Updated test text"; // System.InvalidOperationException
});

thread.Start();

🤷‍♂️

zeh-almeida commented 1 year ago

@kirsan31 the binding goes both ways, as you can see by the Assert calls made. This code actually came from the PR I opened, so I am sure it works. Of course, I can always adjust it, no problem.

the System.InvalidOperationException happens on the binding because it does not use Control.Invoke and with the adjustments I propose, you may be able to avoid it entirely.

I hope I answered your questions, Please let me know if you have more

kirsan31 commented 1 year ago

the binding goes both ways

Yea and from 1 post it's unclear about which way we are talking here. From the text I made assumption that from object to control. And it's ok in principle, but in common case you can change your bind object as your want in any thread and will see no exceptions and no changes in the correspond control property too. Or we talk here only about INotifyPropertyChanged derived objects?

But from API Usage section we clearly see that you test control to object scenario, which doesn't make sense in any thread other then UI.

zeh-almeida commented 1 year ago

I am in the process of building a better example as I only have my code to show for, which wasn't crafted for this case specifically.

Yes, we are talking about INotifyPropertyChanged derived objects, specifically when you have a asynchronous operation on said object which triggers the event.

I have a previous example here from my project, which illustrates how I was creating the binding.

However, because of my message is asynchronous, some of the message handlers changed their properties and the binding raised the System.InvalidOperationException.

Hope this clarifies the issue better.

kirsan31 commented 1 year ago

Thank you, now are all clear.

About your proposal. Personally, I think this is not good approach (pass param to constructor). I will try to argue my position.

Overall I think approach suggested by @RussKie is more suitable here...

zeh-almeida commented 1 year ago

I agree, my proposal is probably not the best approach, I only tested using the scenario I found, without analyzing possibilites of deadlocks or other adverse situations.

I used Control.Invoke instead of Control.BeginInvoke in this case because the existing code calls the property.SetValue directly and I wanted to avoid making changes to existing behavior, as you can see here.

I have no problem in changing the proposal to add a property toggle for this behavior instead of changing the constructor, as I now understand that changing any signature could be a problem for the designer and other tools.

I just want to make sure I understand the suggestion correctly: I should add a public bool UseControlInvoke {get; set;} property to the Binding class and, if true, it should execute the property.SetValue method in the Control.Invoke action, removing the need to change the constructor.

Is this correct?

kirsan31 commented 1 year ago

I used Control.Invoke instead of Control.BeginInvoke in this case because the existing code calls the property.SetValue directly and I wanted to avoid making changes to existing behavior, as you can see here.

Yea BeginInvoke will require more investment. But in case of cross thread operation, I think in 90% cases, logically, you will want to use BeginInvoke instead of Invoke.

I just want to make sure I understand the suggestion correctly: I should add a public bool UseControlInvoke {get; set;} property to the Binding class and, if true, it should execute the property.SetValue method in the Control.Invoke action, removing the need to change the constructor.

It seems to me that here we are stepping on thin ice of cross thread UI control🙄 Imho ideal solution will be something like BindingCT or even BindingAsync 😯 Lets wait for response from maintainers...

RussKie commented 1 year ago

Since the current behaviour is sync the fix would be to use Invoke. BeginInvoke or adding an async variant would be a new behaviour.

KlausLoeffelmann commented 1 year ago

Just a quick update: We're still very much considering this but need to address a couple of higher priority issues, first.

elachlan commented 10 months ago

@KlausLoeffelmann did you want to re-prioritize this?

elachlan commented 10 months ago

Related: #8532

KlausLoeffelmann commented 10 months ago

Well, I want to get it done for 9. I am not sure exactly when I get to it.