diff --git a/modules/javafx.base/src/main/java/com/sun/javafx/binding/ConditionalBinding.java b/modules/javafx.base/src/main/java/com/sun/javafx/binding/ConditionalBinding.java new file mode 100644 index 00000000000..c77cd8bac7f --- /dev/null +++ b/modules/javafx.base/src/main/java/com/sun/javafx/binding/ConditionalBinding.java @@ -0,0 +1,67 @@ +package com.sun.javafx.binding; + +import java.util.Objects; + +import javafx.beans.value.ObservableValue; + +public class ConditionalBinding extends LazyObjectBinding { + + private final ObservableValue source; + private final ObservableValue nonNullCondition; + + private Subscription subscription; + + public ConditionalBinding(ObservableValue source, ObservableValue condition) { + this.source = Objects.requireNonNull(source, "source cannot be null"); + this.nonNullCondition = Objects.requireNonNull(condition, "condition cannot be null").orElse(false); + + // condition is always observed and never unsubscribed + Subscription.subscribe(nonNullCondition, current -> { + invalidate(); + + if (!current) { + getValue(); + } + }); + } + + /** + * This binding is valid whenever it is observed, or it is currently inactive. + * When inactive, the binding has the value of its source at the time it became + * inactive. + */ + @Override + protected boolean allowValidation() { + return super.allowValidation() || !isActive(); + } + + @Override + protected T computeValue() { + if (isObserved() && isActive()) { + if (subscription == null) { + subscription = Subscription.subscribeInvalidations(source, this::invalidate); + } + } + else { + unsubscribe(); + } + + return source.getValue(); + } + + @Override + protected Subscription observeSources() { + return this::unsubscribe; + } + + private boolean isActive() { + return nonNullCondition.getValue(); + } + + private void unsubscribe() { + if (subscription != null) { + subscription.unsubscribe(); + subscription = null; + } + } +} diff --git a/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java b/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java index 664a27a3839..ddaf4acf1e8 100644 --- a/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java +++ b/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java @@ -27,6 +27,7 @@ import java.util.function.Function; +import com.sun.javafx.binding.ConditionalBinding; import com.sun.javafx.binding.FlatMappedBinding; import com.sun.javafx.binding.MappedBinding; import com.sun.javafx.binding.OrElseBinding; @@ -251,4 +252,49 @@ default ObservableValue orElse(T constant) { default ObservableValue flatMap(Function> mapper) { return new FlatMappedBinding<>(this, mapper); } + + /** + * Returns an {@code ObservableValue} that holds this value and is updated only + * when {@code condition} holds {@code true}. + *

+ * The returned {@code ObservableValue} only observes this value when + * {@code condition} holds {@code true}. This allows this {@code ObservableValue} + * and the conditional {@code ObservableValue} to be garbage collected if neither is + * otherwise strongly referenced when {@code condition} holds {@code false}. + * This is in contrast to the general behavior of bindings, where the binding is + * only eligible for garbage collection when not observed itself. + *

+ * A {@code condition} holding {@code null} is treated as holding {@code false}. + *

+ * For example: + *

{@code
+     * ObservableValue condition = new SimpleBooleanProperty(true);
+     * ObservableValue longLivedProperty = new SimpleStringProperty("A");
+     * ObservableValue whenProperty = longLivedProperty.when(condition);
+     *
+     * // observe whenProperty, which will in turn observe longLivedProperty
+     * whenProperty.addListener((ov, old, current) -> System.out.println(current));
+     *
+     * longLivedProperty.setValue("B");  // "B" is printed
+     *
+     * condition.setValue(false);
+     *
+     * // After condition becomes false, whenProperty stops observing longLivedProperty; condition
+     * // and whenProperty may now be eligible for GC despite being observed by the ChangeListener
+     *
+     * longLivedProperty.setValue("C");  // nothing is printed
+     * longLivedProperty.setValue("D");  // nothing is printed
+     *
+     * condition.setValue(true);  // longLivedProperty is observed again, and "D" is printed
+     * }
+ * + * @param condition a boolean {@code ObservableValue}, cannot be {@code null} + * @return an {@code ObservableValue} that holds this value whenever the given + * condition evaluates to {@code true}, otherwise holds the last seen value; + * never returns {@code null} + * @since 20 + */ + default ObservableValue when(ObservableValue condition) { + return new ConditionalBinding<>(this, condition); + } } diff --git a/modules/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java b/modules/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java index 433dffd0e35..2ab9504e4b5 100644 --- a/modules/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java +++ b/modules/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java @@ -39,19 +39,25 @@ import org.junit.jupiter.api.Test; import javafx.beans.InvalidationListener; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; public class ObservableValueFluentBindingsTest { + private int invalidations; - private final StringProperty property = new SimpleStringProperty("Initial"); private final List values = new ArrayList<>(); private final ChangeListener changeListener = (obs, old, current) -> values.add(current); private final InvalidationListener invalidationListener = obs -> invalidations++; + private StringProperty property = new SimpleStringProperty("Initial"); + @Nested class When_map_Called { @@ -878,6 +884,285 @@ void shouldNoLongerBeStronglyReferenced() { } } + @Nested + class When_when_Called { + + @Nested + class WithNull { + + @Test + void shouldThrowNullPointerException() { + assertThrows(NullPointerException.class, () -> property.when(null)); + } + } + + @Nested + class WithNotNullAndInitiallyFalseConditionReturns_ObservableValue_Which { + private BooleanProperty condition = new SimpleBooleanProperty(false); + private ObservableValue observableValue = property.when(condition); + + @Test + void shouldNotBeNull() { + assertNotNull(observableValue); + } + + @Test + void shouldNotBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> { + observableValue = null; + condition = null; + }); + } + + @Nested + class When_getValue_Called { + + @Test + void shouldReturnInitialValueAtTimeOfCreation() { + property.set("Not Initial"); + + assertEquals("Initial", observableValue.getValue()); + } + } + } + + @Nested + class WithNotNullReturns_ObservableValue_Which { + // using object property here so it can be set to null for testing + private ObjectProperty condition = new SimpleObjectProperty<>(true); + private ObservableValue observableValue = property.when(condition); + + @Test + void shouldNotBeNull() { + assertNotNull(observableValue); + } + + @Test + void shouldNotBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> { + observableValue = null; + condition = null; + }); + } + + @Nested + class When_getValue_Called { + + @Test + void shouldReturnCurrentPropertyValuesWhileConditionIsTrue() { + assertEquals("Initial", observableValue.getValue()); + + property.set(null); + + assertNull(observableValue.getValue()); + + property.set("Left"); + + assertEquals("Left", observableValue.getValue()); + + condition.set(false); + + property.set("Right"); + + assertEquals("Left", observableValue.getValue()); + + property.set("Middle"); + + assertEquals("Left", observableValue.getValue()); + + condition.set(true); + + assertEquals("Middle", observableValue.getValue()); + } + } + + @Nested + class WhenObservedForInvalidations { + { + startObservingInvalidations(observableValue); + } + + @Test + void shouldOnlyInvalidateOnce() { + assertNotInvalidated(); + + property.set("Left"); + + assertInvalidated(); + + property.set("Right"); + + assertNotInvalidated(); + } + + @Test + void shouldOnlyInvalidateWhileConditionIsTrue() { + assertNotInvalidated(); + + property.set("Left"); // trigger invalidation + + assertInvalidated(); + + condition.set(false); + + assertNotInvalidated(); // already invalid, changing condition won't change that + + observableValue.getValue(); // this would normally make the property valid, but not when condition is false + + property.set("Right"); // trigger invalidation + + assertNotInvalidated(); // nothing happened + + condition.setValue(null); // null is false as well, should not change result + + assertNotInvalidated(); // nothing happened + + condition.set(true); + + assertInvalidated(); + + observableValue.getValue(); // make property valid + + assertNotInvalidated(); + + property.set("Middle"); // trigger invalidation + + assertInvalidated(); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> { + observableValue = null; + condition = null; + }); + } + + @Test + void shouldNotBeStronglyReferencedWhenConditionIsFalse() { + condition.set(false); + + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> { + observableValue = null; + condition = null; + }); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingInvalidations(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNotInvalidated(); + + property.set("Left"); + property.set("Right"); + + assertNotInvalidated(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> { + observableValue = null; + condition = null; + }); + } + } + } + + @Nested + class WhenObservedForChanges { + { + startObservingChanges(observableValue); + } + + @Test + void shouldReceiveCurrentPropertyValues() { + assertNothingIsObserved(); + + property.set("Right"); + + assertObserved("Right"); + } + + @Test + void shouldOnlyReceiveCurrentPropertyValuesWhileConditionIsTrue() { + assertNothingIsObserved(); + + property.set("Right"); + + assertObserved("Right"); + + condition.set(false); + + assertNothingIsObserved(); + + property.set("Left"); + + assertNothingIsObserved(); + + property.set("Middle"); + + assertNothingIsObserved(); + + condition.setValue(null); // null is false as well, should not change result + + assertNothingIsObserved(); + + condition.set(true); + + assertObserved("Middle"); + } + + @Test + void shouldBeStronglyReferenced() { + ReferenceAsserts.testIfStronglyReferenced(observableValue, () -> { + observableValue = null; + condition = null; + }); + } + + @Test + void shouldNotBeStronglyReferencedWhenConditionIsFalse() { + condition.set(false); + + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> { + observableValue = null; + condition = null; + }); + } + + @Nested + class AndWhenUnobserved { + { + stopObservingChanges(observableValue); + } + + @Test + void shouldNoLongerBeCalled() { + assertNothingIsObserved(); + + property.set("Right"); + + assertNothingIsObserved(); + } + + @Test + void shouldNoLongerBeStronglyReferenced() { + ReferenceAsserts.testIfNotStronglyReferenced(observableValue, () -> { + observableValue = null; + condition = null; + }); + } + } + } + } + } + /** * Ensures nothing has been observed since the last check. */ @@ -891,7 +1176,7 @@ private void assertNothingIsObserved() { * @param expectedValues an array of expected values */ private void assertObserved(String... expectedValues) { - assertEquals(values, Arrays.asList(expectedValues)); + assertEquals(Arrays.asList(expectedValues), values); values.clear(); }