Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Controlled Components #148

Open
wants to merge 5 commits into
base: master
Choose a base branch
from

Conversation

B-Reif
Copy link

@B-Reif B-Reif commented Jan 23, 2021

This pull request contains a proof-of-concept for 'controlled components', a feature where FuncUI is the single source of truth for a component's value.

What is this

In Avalonia, controls usually maintain their own internal state via AvaloniaProperty getters and setters. This internal state gets updated by either user input or setters in code. In FuncUI, the value of a control is dictated by state. We can combine these two by making the FuncUI state 'the single source of truth', so that the content of the Avalonia control always reflects the provided state value.

Motivation

When Avalonia controls have their own internal state, they can easily become de-synced with the state provided by the MVU framework. Consider the existing CounterApp example:

image

This app only dispatches a change event if the control's value successfully parses as a number. If not, the Avalonia control's state becomes out-of-sync with the Counter state. With a controlled component, the input field's text will always reflect state as given by FuncUI. This has immediate benefits:

  • The app behaves more predictably. Instead of querying the underlying control to get its de-synced value, the author can be confident that it will be a pure reflection of state.
  • The app dispatches fewer messages. Currently, FuncUI will dispatch change events for both user input and for changes made by code setters. By controlling the component directly, we can dispatch on user input without dispatching redundant messages for changes made by the framework itself.
  • Other state transformations become much easier to implement. For example, masking an input (see How can I make a TextBox input mask? #80) can be done purely through the MVU structure without any further customization. This can apply to any kind of arbitrary state projection. This PR contains some examples of this.

Implementation

By default, Avalonia controls will mutate their own values in response to user input. To implement controlled components, we need to defer mutation until the value comes back via the framework. As a proof-of-concept, this PR contains a ControlledTextBox control which overrides the destructive methods of Avalonia's existing TextBox control.

All mutation happens in a single method, which is invoked by the framework only after it receives a new value from the view function. The app author receives a synthesized event with text set to a kind of 'staged' change. Then, the app author can decide if they want to accept, reject, or modify this value before putting it into state.

I would like to implement Controlled variants for all user-input components. I implemented this proof-of-concept with the TextBox, which has a lot of different kinds of user input, as well as other kinds of state (the selection and the caret position remain 'uncontrolled'). Other controls probably involve less code.

Next steps

  • Need to implement Undo/Redo mutations still. This PR contains a small helper for action history which I was considering (Avalonia's internal UndoRedoHelper is private) but I wanted to leave this up for discussion some before pursuing it further.
  • Should consider the names of the APIs. Prepending 'Controlled' to the front of things seems like a lot of characters. Currently the new properties are called 'value' and 'onChange'.
  • This implementation has a fair amount of overriding the basic control. We could consider reaching out to Avalonia maintainers to see if their implementation could become more friendly to this kind of 'deferred mutation'.

Prior art

Controlled components in React

@AngelMunoz
Copy link
Contributor

I like the idea indeed I have a couple of questions

  1. does this kind of controlled componen require an implementation (e.g. src/Avalonia.FuncUI.DSL/Controls/ControlledTextBox.fs)
  2. The current DSL components are quite simple to maintain in those terms how do these controlled components differ (directly related to the above question I guess)?
  3. should these components go in a separate package?

@B-Reif
Copy link
Author

B-Reif commented Jan 23, 2021

  1. does this kind of controlled componen require an implementation (e.g. src/Avalonia.FuncUI.DSL/Controls/ControlledTextBox.fs)

Because the native control mutates the value of this.Text on all of its operations, it's necessary to replace its functionality with similar non-destructive methods. I would plan on doing this for other similar controls.

We could re-mutate the value of this.Text after each change (this is how react-dom works), but that approach requires more special intervention by the framework. Custom controls work without any changes to the VirtualDom implementation.

  1. The current DSL components are quite simple to maintain in those terms how do these controlled components differ (directly related to the above question I guess)?

The actual DSL wouldn't change much. These would only expose a value and a change handler. There is more code to maintain because of the custom controls. For a few reasons, I don't view this as a problem:

  • The interactions we need to override are not likely to change in the future. User input operations like text input, pasting, etc are likely to remain stable in Avalonia.
  • We only need to override methods that mutate the controlled value. Everything else, including styles and other properties, are inherited or invoke the base implementation.
  • In this example, this ControlledTextBox only overrides the public API of the TextBox (no use of reflection, etc to hack internals). As long as the Avalonia controls have the same public API, no changes are required on our part.
  • We don't have to modify the existing DSL implementation of the uncontrolled components. For example, no changes are required to TextBox.
  • If we don't implement these controls in FuncUI, then app authors will have to implement ad-hoc solutions themselves. Currently with uncontrolled components, authors have to implement a lot of boilerplate to sync the Avalonia controls with their app state. For example, the masked input in How can I make a TextBox input mask? #80 is easily covered by a controlled component. The Gitter chat has a lot of discussion about infinite re-renders on changes, and even temporary backing fields. It will be better for everyone if FuncUI has a canonical solution.
  1. should these components go in a separate package?

In general I would expect an MVU framework to control values like this, so I would expect this to go in the main package.

@jl0pd
Copy link
Contributor

jl0pd commented Jan 23, 2021

User input operations like text input, pasting, etc are likely to remain stable in Avalonia

Text input is one of areas that will change before 1.0 release AvaloniaUI/Avalonia#3538. As pointed by AvaloniaUI/Avalonia#2076 (comment) it's still "work in progress"

@B-Reif
Copy link
Author

B-Reif commented Jan 23, 2021

Good catch. It doesn't look like they've figured out which public API changes are slated yet.

@B-Reif
Copy link
Author

B-Reif commented Jan 24, 2021

How can we implement this when controls are nested? For example, controlling the IsExpanded state of an Expander involves messing with a nested ToggleButton somewhere in the Expander's VisualChildren. By the time OnIsExpandedChange is raised, the property is already mutated. In this case I would be looking to override the Expander's ToggleButton? Or re-writing its Template?

Edit: Here's a gist with a more generalizable pattern for controlling properties. This class intercepts uncontrolled change events, resets the value to the old value, and raises the new value with the user's onChange handler. (In this case the ToggleButton's IsOpen property doesn't get reset. I'm not sure how to access the binding property here.)

If we wanted to put this in the framework (instead of extending individual controls) we could explore adding a 'ControlledProperty' concept to the AttrBuilder. These 'ControlledProperty' functions would take a value and a change handler, and then the framework would be responsible for controlling the value (in a way similar to the above gist).

@B-Reif
Copy link
Author

B-Reif commented Jan 29, 2021

I added a new kind of Attr, ControlledProperty, that can be used to control any property in a generalized way by resetting the controlled value on changes. Unfortunately, this doesn't work with the TextBox because it explicitly disallows further changes to Text in response to Text changes! This also seems to have some issues with controlling bound properties.

@JaggerJo
Copy link
Member

Thanks for the patience! This seems like an interesting addition to FuncUI.

Let me rephrase the goal of controlled views/properties, please correct me if I'm missing something.

The current value of a control should always come from state. If the user (for example) enters text in a text box its value does not change. Instead the onChanged handler is fired. This handler can either:

  • discard the value by doing nothing.
  • take the value by doing [x].

There are a few things

What properties should be controllable?

From on top of my head Checkbox.IsChecked is a good candidate. It probably is one of the clearest examples.

What is with TextBox.Text?

  • How do we deal with intermediate values. Say we only allow numbers in our state. The user enters a number.
    4 -> take (state is now 4)
    . -> skip ("4. is not a number" - state is still 4)
    ?

What is with ComboBox.SelectedIndex and ComboBox.SelectedItem ?

  • How do we deal with values that can't be set? (if only 3 items are in the checkbox we can't set an index that's out of range.)
  • How do we handle duplicate properties like SelectedIndex and SelectedItem?

Custom Controllable Controls

Creating custom controls seems fine to me. Might be a lot of work to get it properly working form more complex controls but this should definitely be doable.

Is there a simpler alternative?

Maybe this is something we should ask the Avalonia maintainers. I could imagine there is already something like this for "OneWay" bindings.

As you probably noticed it took me quite a while to take a look at this PR and it really bothers me that I don't have more time to maintain FuncUI more actively. This seems like additional work that I currently don't have time for (and honestly no benefit from as I don't use FuncUI in my current projects) BUT if anyone if committed to push and maintain this I am happy to accept contributions and am willing to help along the way.

@B-Reif
Copy link
Author

B-Reif commented Feb 26, 2021

Sorry I didn't see this reply for a bit! Thanks for checking it out.

Change handlers

How do we deal with intermediate values.

The intermediate value is passed to onChange, and the question of "how to deal with it" is handled by the app author and not the framework. A few examples of how an app can use this:

Take the value unaltered

This is how most components will work most of the time:

ControlledTextBox.controlledOnChange (fun e -> e.Text |> SetTextMsg |> dispatch)

Map/filter the value

This is how a component can implement a filter/tunnel pattern:

// Filter: remove all vowels
let withoutVowels = String.filter (fun char -> Regex.IsMatch(string char, @"[^aeiouAEIOU]"))           
ControlledTextBox.controlledOnChange (fun e -> e.Text |> withoutVowels |> SetTextMsg |> dispatch)

Ignore the value and do something else, or nothing

This is how a component can implement other arbitrary responses:

ControlledTextBox.controlledOnChange (fun _ -> IncrementCounterMsg |> dispatch)

ComboBox (and other 'selected item' controls)

How do we deal with values that can't be set? (if only 3 items are in the checkbox we can't set an index that's out of range.)

This seems like a good time to throw an exception.

How do we handle duplicate properties like SelectedIndex and SelectedItem?

These should only have one or the other being controlled at any given time. We could expose either or both as controlled variants and throw an exception when both are present? This is one of several problems with MVVM, which is why I like this framework 😄

Alternatives

I think it's absolutely worth asking Avalonia maintainers about this feature. It's very important to making a functional UI approach work. It's similar in some ways to OneWay bindings. The difference is that establishing an Avalonia binding doesn't prevent other changes to a given property.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants