Skip to content

Conversation

@hjohn
Copy link
Collaborator

@hjohn hjohn commented Mar 20, 2021

This is a proof of concept of how fluent bindings could be introduced to JavaFX. The main benefit of fluent bindings are ease of use, type safety and less surprises. Features:

Flexible Mappings
Map the contents of a property any way you like with map, or map nested properties with flatMap.

Lazy
The bindings created are lazy, which means they are always invalid when not themselves observed. This allows for easier garbage collection (once the last observer is removed, a chain of bindings will stop observing their parents) and less listener management when dealing with nested properties. Furthermore, this allows inclusion of such bindings in classes such as Node without listeners being created when the binding itself is not used (this would allow for the inclusion of a treeShowingProperty in Node without creating excessive listeners, see this fix I did in an earlier PR: #185)

Null Safe
The map and flatMap methods are skipped, similar to java.util.Optional when the value they would be mapping is null. This makes mapping nested properties with flatMap trivial as the null case does not need to be taken into account in a chain like this: node.sceneProperty().flatMap(Scene::windowProperty).flatMap(Window::showingProperty). Instead a default can be provided with orElse or orElseGet.

Conditional Bindings
Bindings can be made conditional using the conditionOn method. A conditional binding retains its last value when its condition is false. Conditional bindings donot observe their source when the condition is false, allowing developers to automatically stop listening to properties when a certain condition is met. A major use of this feature is to have UI components that need to keep models updated which may outlive the UI conditionally update the long lived model only when the UI is showing.

Some examples:

void mapProperty() {
  // Standard JavaFX:
  label.textProperty().bind(Bindings.createStringBinding(() -> text.getValueSafe().toUpperCase(), text));

  // Fluent: much more compact, no need to handle null
  label.textProperty().bind(text.map(String::toUpperCase));
}

void calculateCharactersLeft() {
  // Standard JavaFX:
  label.textProperty().bind(text.length().negate().add(100).asString().concat(" characters left"));

  // Fluent: slightly more compact and more clear (no negate needed)
  label.textProperty().bind(text.orElse("").map(v -> 100 - v.length() + " characters left"));
}

void mapNestedValue() {
  // Standard JavaFX:
  label.textProperty().bind(Bindings.createStringBinding(
    () -> employee.get() == null ? ""
        : employee.get().getCompany() == null ? ""
        : employee.get().getCompany().getName(),
    employee
  ));

  // Fluent: no need to handle nulls everywhere
  label.textProperty().bind(
    employee.map(Employee::getCompany)
            .map(Company::getName)
            .orElse("")
  );
}

void mapNestedProperty() {
  // Standard JavaFX:
  label.textProperty().bind(
    Bindings.when(Bindings.selectBoolean(label.sceneProperty(), "window", "showing"))
      .then("Visible")
      .otherwise("Not Visible")
  );

  // Fluent: type safe
  label.textProperty().bind(label.sceneProperty()
    .flatMap(Scene::windowProperty)
    .flatMap(Window::showingProperty)
    .orElse(false)
    .map(showing -> showing ? "Visible" : "Not Visible")
  );
}

void updateLongLivedModelWhileAvoidingMemoryLeaks() {
  // Standard JavaFX: naive, memory leak; UI won't get garbage collected
  listView.getSelectionModel().selectedItemProperty().addListener(
    (obs, old, current) -> longLivedModel.lastSelectedProperty().set(current)
  );

  // Standard JavaFX: no leak, but stops updating after a while
  listView.getSelectionModel().selectedItemProperty().addListener(
    new WeakChangeListener<>(
      (obs, old, current) -> longLivedModel.lastSelectedProperty().set(current)
    )
  );

  // Standard JavaFX: fixed version
  listenerReference = (obs, old, current) -> longLivedModel.lastSelectedProperty().set(current);

  listView.getSelectionModel().selectedItemProperty().addListener(
    new WeakChangeListener<>(listenerReference)
  );

  // Fluent: naive, memory leak... fluent won't solve this...
  listView.getSelectionModel().selectedItemProperty()
      .subscribe(longLivedModel.lastSelectedProperty()::set);

  // Fluent: conditional update when control visible

  // Create a property which is only true when the UI is visible:
  ObservableValue<Boolean> showing = listView.sceneProperty()
      .flatMap(Scene::windowProperty)
      .flatMap(Window::showingProperty)
      .orElse(false);

  // Use showing property to automatically disconnect long lived model
  // allowing garbage collection of the UI:
  listView.getSelectionModel().selectedItemProperty()
    .conditionOn(showing)
    .subscribe(longLivedModel.lastSelectedProperty()::set);

  // Note that the 'showing' property can be provided in multiple ways:
  // - create manually (can be re-used for multiple bindings though)
  // - create with a helper: Nodes.showing(Node node) -> ObservableValue<Boolean>
  // - make it part of the Node class; as the fluent bindings only bind themselves
  //   to their source when needed (lazy binding), this won't create overhead
  //   for each node in the scene
}

Note that this is based on ideas in ReactFX and my own experiments in https://github.com/hjohn/hs.jfx.eventstream. I've come to the conclusion that this is much better directly integrated into JavaFX, and I'm hoping this proof of concept will be able to move such an effort forward.

I've created tests for this as well, but they're written in JUnit 5, which I noticed is not yet part of OpenJFX. A screenshot:

image


Progress

  • Change must not contain extraneous whitespace
  • Commit message must refer to an issue
  • Change must be properly reviewed

Download

To checkout this PR locally:
$ git fetch https://git.openjdk.java.net/jfx pull/434/head:pull/434
$ git checkout pull/434

To update a local copy of the PR:
$ git checkout pull/434
$ git pull https://git.openjdk.java.net/jfx pull/434/head

@bridgekeeper
Copy link

bridgekeeper bot commented Mar 20, 2021

👋 Welcome back jhendrikx! A progress list of the required criteria for merging this PR into master will be added to the body of your pull request. There are additional pull request commands available for use with this pull request.

@hjohn hjohn force-pushed the feature/observable-value-fluent-bindings branch 3 times, most recently from e204c63 to f45f3de Compare March 21, 2021 21:48
@hjohn hjohn force-pushed the feature/observable-value-fluent-bindings branch from f45f3de to 5182697 Compare March 22, 2021 21:59
@tbee
Copy link

tbee commented Mar 25, 2021

I like this!

About the memory leaks; in Angular it is common that (the equivalent of) an addListener returns a subscription object, which can be closed when the enclosing service or component closes. This will break the strong reference and thus solve the memory leak. Is it an idea to do something similar here? AddListener is void, so it should be possible to have it return a Subscription object without breaking the API. The subscription object knows the exact listener, so it only removes that one.

Of course that solution has the drawback in that you need to maintain an administration so you know what to close. Either a bunch of instance variables or a list, in Angular there is some other trickery but that is technology specific. It all comes down to that you can mark something to need closing at the point of subscribing. The best would be to bind that to an onClose event of the containing object, maybe a little helper method would be good, something like.

this.cascadeClose( listView.getSelectionModel().selectedItemProperty().addListener(
(obs, old, current) -> longLivedModel.lastSelectedProperty().set(current)
));

Or:
listView.getSelectionModel().selectedItemProperty().addListener(
(obs, old, current) -> longLivedModel.lastSelectedProperty().set(current)
).closeWith(this);

@hjohn
Copy link
Collaborator Author

hjohn commented Mar 26, 2021 via email

@tomsontom
Copy link
Collaborator

Changing addListener from void to something different would be binary incompatible change!

@tbee
Copy link

tbee commented Mar 26, 2021

Changing addListener from void to something different would be binary incompatible change!

I know, but adding a new method seems like an even worse idea, polluting the API more.

@mstr2
Copy link
Collaborator

mstr2 commented Apr 7, 2021

I really like this idea. But even after a few days of looking at it, I still struggle with the "flatMap" operator. While I understand why you named it like that, it looks a bit foreign to me when it's used to select nested properties. Have you considered using "select" instead, which already has precedence in JavaFX?

Is it possible to have something like an "orDefault" operator, which would be equivalent to "orElse" but with an implicit default value.

@hjohn
Copy link
Collaborator Author

hjohn commented Apr 7, 2021

I really like this idea. But even after a few days of looking at it, I still struggle with the "flatMap" operator. While I understand why you named it like that, it looks a bit foreign to me when it's used to select nested properties. Have you considered using "select" instead, which already has precedence in JavaFX?

The name is basically taken from ReactFX, Optional and Stream and is basically short-hand syntax for map(x -> x.someProperty()).map(ObservableValue::getValue). Using select is a valid option to consider. If map and flatMap weren't null-safe, I would definitely have liked to add another method (perhaps select) which saves you the hassle of having to deal with null when selecting properties.

Is it possible to have something like an "orDefault" operator, which would be equivalent to "orElse" but with an implicit default value.

Perhaps yes, but as the PoC doesn't have specific implementations for primitive types it may be a bit tricky to determine what the default should be as I think there is no type information to base this on. I assume you'd want it to be 0 or false for primitive types, and null for everything else.

@tbee
Copy link

tbee commented Apr 7, 2021

To me orDefault doesn't give me warm feelings, because it is a bit magical. I'd rather type orElse(0), then know for sure what its value is. I personally also never rely on defaults for any variable, even primitives, I always initialize them.

@mstr2
Copy link
Collaborator

mstr2 commented Apr 7, 2021

To me orDefault doesn't give me warm feelings, because it is a bit magical. I'd rather type orElse(0), then know for sure what its value is. I personally also never rely on defaults for any variable, even primitives, I always initialize them.

That seems like a particular personal preference. Having sensible defaults is a large part of what makes Java as great a language as it is.

@tbee
Copy link

tbee commented Apr 7, 2021

Given my preference on initializing everything, it is obvious I have a different opinion on that matter :-)

To me orDefault doesn't give me warm feelings, because it is a bit magical. I'd rather type orElse(0), then know for sure what its value is. I personally also never rely on defaults for any variable, even primitives, I always initialize them.

That seems like a particular personal preference. Having sensible defaults is a large part of what makes Java as great a language as it is.

And given my preference on initializing everything, it is obvious I have a different opinion :-)
But no one is forced to use orDefault, so if it makes people happy...

@tobiasdiez
Copy link
Contributor

I really like this PR and the concepts it proposes. In fact, I'm maintaining a small library (originally due to @TomasMikula) that implements many of these features: https://github.com/tobiasdiez/EasyBind. Please use it as a source of inspiration and code as you see fit.

There are a few notable differences in EasyBind:

  • Handling of null values: Sometimes you do want to have explicit control of null values in your mapper. Thus, with EasyBind, we simple apply the mapper to the current value, even if that's null. On the other hand, we introduced ObservableOptionalValue as a convenient wrapper around ObservableValue<Optional<T>> to handle null values similar to Optional.
  • For mapping, there are two use cases: Either you have a simple mapper as in text.map(String:toUppercase), or sometimes the mapper returns an observable value itself, which needs to be unpackaged.
  • For selection / flatmap chain, you often want to get a bona-fide property back for which you can also set values (and not only use it as the source for a binding).

Maybe it's a good idea to split this PR into smaller ones that only introduce a particular concept (i.e. separate mapper, listener, selection etc).

@hjohn
Copy link
Collaborator Author

hjohn commented Aug 20, 2021 via email

@tbee
Copy link

tbee commented Aug 21, 2021 via email

@nlisker
Copy link
Collaborator

nlisker commented Jul 10, 2022

@hjohn I think that this can be closed now :)

@hjohn
Copy link
Collaborator Author

hjohn commented Jul 10, 2022

Right :)

@hjohn hjohn closed this Jul 10, 2022
@hjohn hjohn deleted the feature/observable-value-fluent-bindings branch July 10, 2022 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

6 participants