Skip to content

Commit a31bb21

Browse files
committed
[core] ✨ Implement a new utility for bidirectional bindings
While openjdk/jfx#675 improved things quite a lot, there's still a gap in the JavaFX's binding system. As far as I know, there is no easy way (or no way at all) to make a bidirectional binding between properties of different types. So, just like my old custom bindings system, this utility fills that gap, but it's much more easier and convenient to use. Signed-off-by: palexdev <alessandro.parisi406@gmail.com>
1 parent ee5eca5 commit a31bb21

File tree

2 files changed

+266
-3
lines changed

2 files changed

+266
-3
lines changed
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/*
2+
* Copyright (C) 2025 Parisi Alessandro - alessandro.parisi406@gmail.com
3+
* This file is part of MaterialFX (https://github.com/palexdev/MaterialFX)
4+
*
5+
* MaterialFX is free software: you can redistribute it and/or
6+
* modify it under the terms of the GNU Lesser General Public License
7+
* as published by the Free Software Foundation; either version 3 of the License,
8+
* or (at your option) any later version.
9+
*
10+
* MaterialFX is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13+
* See the GNU Lesser General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Lesser General Public License
16+
* along with MaterialFX. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
package io.github.palexdev.mfxcore.base.bindings;
20+
21+
import java.util.*;
22+
import java.util.function.Function;
23+
24+
import io.github.palexdev.mfxcore.observables.When;
25+
import javafx.beans.Observable;
26+
import javafx.beans.value.ObservableValue;
27+
import javafx.beans.value.WritableValue;
28+
29+
/// This utility provides bidirectional binding between two [ObservableValues][ObservableValue] of different types,
30+
/// filling a gap in JavaFX's binding system by implementing type conversion through functions for this kind of bindings.
31+
///
32+
/// **Usage Example:**
33+
/// ```java
34+
/// StringProperty first = new SimpleStringProperty();
35+
/// IntegerProperty second = new SimpleIntegerProperty();
36+
/// MappedBidirectionalBinding.bind(first, second)\
37+
/// .setFirstToSecondMapper(Integer::parseInt)
38+
/// .setSecondToFirstMapper(String::valueOf)
39+
/// .bind();
40+
///```
41+
///
42+
/// @param <A> the type of the first observable value
43+
/// @param <B> the type of the second observable value
44+
public class MappedBidirectionalBinding<A, B> {
45+
//================================================================================
46+
// Properties
47+
//================================================================================
48+
private ObservableValue<A> first;
49+
private Mapper<A, B> firstToSecondMapper = Mapper.of(_ -> null);
50+
private ObservableValue<B> second;
51+
private Mapper<B, A> secondToFirstMapper = Mapper.of(_ -> null);
52+
53+
private When<?> firstWhen;
54+
private When<?> secondWhen;
55+
private final Map<Target, Set<Observable>> dependencies = new HashMap<>();
56+
private boolean locked = false;
57+
58+
//================================================================================
59+
// Constructors
60+
//================================================================================
61+
62+
public MappedBidirectionalBinding(ObservableValue<A> first, ObservableValue<B> second) {
63+
this.first = Objects.requireNonNull(first);
64+
this.second = Objects.requireNonNull(second);
65+
}
66+
67+
public static <A, B> MappedBidirectionalBinding<A, B> bind(ObservableValue<A> first, ObservableValue<B> second) {
68+
return new MappedBidirectionalBinding<>(first, second);
69+
}
70+
71+
//================================================================================
72+
// Methods
73+
//================================================================================
74+
75+
/// Delegates to [#bind(boolean)] with `false` as the parameter.
76+
public MappedBidirectionalBinding<A, B> bind() {
77+
return bind(false);
78+
}
79+
80+
/// Activates the bidirectional binding.
81+
///
82+
/// This method sets up the listeners on both observable values and thus establishing the bidirectional relationship.<br >
83+
/// Once active, changes to either observable will trigger the appropriate mapper and update the other observable.
84+
///
85+
/// @param lazyInit if `false`, immediately updates the second observable with the mapped value from the first;
86+
/// if `true`, waits for the first change to trigger initialization
87+
/// @throws IllegalStateException if the binding is already active
88+
/// @see Target
89+
public MappedBidirectionalBinding<A, B> bind(boolean lazyInit) {
90+
if (isActive()) throw new IllegalStateException("The binding is already active");
91+
92+
firstWhen = When.onInvalidated(first)
93+
.condition(_ -> !locked)
94+
.then(_ -> Target.SECOND.update(this))
95+
.listen();
96+
secondWhen = When.onInvalidated(second)
97+
.condition(_ -> !locked)
98+
.then(_ -> Target.FIRST.update(this))
99+
.listen();
100+
if (!lazyInit) Target.SECOND.update(this);
101+
102+
// Register dependencies now that listeners have been created
103+
dependencies.forEach((t, deps) -> t.registerDependencies(this, deps));
104+
105+
return this;
106+
}
107+
108+
/// Adds additional dependencies that allow the binding to react to changes in other observables beyond
109+
/// the two primary bound values.<br >
110+
/// For example, if your mapper function depends on external configuration properties,
111+
/// you can register those as dependencies.
112+
///
113+
/// Dependencies can be added before or after calling [#bind()].
114+
///
115+
/// @param target determines which of the two observables depend on the given dependencies
116+
/// @see Target
117+
public MappedBidirectionalBinding<A, B> addDependenciesFor(Target target, Observable... dependencies) {
118+
Set<Observable> set = this.dependencies.computeIfAbsent(target, _ -> new LinkedHashSet<>());
119+
Collections.addAll(set, dependencies);
120+
if (isActive()) target.registerDependencies(this, Arrays.asList(dependencies));
121+
return this;
122+
}
123+
124+
/// Checks whether this binding is currently active.
125+
///
126+
/// A binding is considered active if it has been bound via [#bind()] and has not been subsequently unbound.
127+
public boolean isActive() {
128+
return firstWhen != null || secondWhen != null;
129+
}
130+
131+
/// Deactivates the binding.
132+
///
133+
/// This removes the change listeners and stops the automatic synchronization between the observable values.<br >
134+
/// The binding can be reactivated by calling [#bind()] again.
135+
///
136+
/// @param clearDependencies if `true`, clears all registered dependencies;
137+
/// if `false`, preserves dependencies for potential rebinding
138+
public void unbind(boolean clearDependencies) {
139+
if (firstWhen != null) firstWhen.dispose();
140+
if (secondWhen != null) secondWhen.dispose();
141+
if (clearDependencies) dependencies.clear();
142+
firstWhen = null;
143+
secondWhen = null;
144+
}
145+
146+
/// Completely disposes of this binding. After calling this method, the binding cannot be reused.<br >
147+
/// This should be called when the binding is no longer needed to prevent memory leaks.
148+
///
149+
/// Partly delegates to [#unbind(boolean)] with `true` as the parameter.
150+
public void dispose() {
151+
unbind(true);
152+
first = null;
153+
second = null;
154+
}
155+
156+
//================================================================================
157+
// Getters/Setters
158+
//================================================================================
159+
160+
/// @return the mapper function for converting values from the first observable to the second.
161+
public Mapper<A, B> getFirstToSecondMapper() {
162+
return firstToSecondMapper;
163+
}
164+
165+
/// Sets the mapper function for converting values from the first observable to the second.
166+
///
167+
/// This mapper is used when the first observable changes, and its value needs to be
168+
/// converted and applied to the second observable.
169+
public MappedBidirectionalBinding<A, B> setFirstToSecondMapper(Function<A, B> firstToSecondMapper) {
170+
this.firstToSecondMapper = Mapper.of(firstToSecondMapper);
171+
return this;
172+
}
173+
174+
/// Same as [#setFirstToSecondMapper(Function)] but more flexible because a [Mapper] also allows to specify an 'orElse' value.
175+
public MappedBidirectionalBinding<A, B> setFirstToSecondMapper(Mapper<A, B> firstToSecondMapper) {
176+
this.firstToSecondMapper = Objects.requireNonNull(firstToSecondMapper);
177+
return this;
178+
}
179+
180+
/// @return the mapper function for converting values from the second observable to the first.
181+
public Mapper<B, A> getSecondToFirstMapper() {
182+
return secondToFirstMapper;
183+
}
184+
185+
/// Sets the mapper function for converting values from the second observable to the first.
186+
///
187+
/// This mapper is used when the second observable changes, and its value needs to be
188+
/// converted and applied to the first observable.
189+
public MappedBidirectionalBinding<A, B> setSecondToFirstMapper(Function<B, A> secondToFirstMapper) {
190+
this.secondToFirstMapper = Mapper.of(secondToFirstMapper);
191+
return this;
192+
}
193+
194+
/// Same as [#setSecondToFirstMapper(Function)] but more flexible because a [Mapper] also allows to specify an 'orElse' value.
195+
public MappedBidirectionalBinding<A, B> setSecondToFirstMapper(Mapper<B, A> secondToFirstMapper) {
196+
this.secondToFirstMapper = Objects.requireNonNull(secondToFirstMapper);
197+
return this;
198+
}
199+
200+
//================================================================================
201+
// Inner Classes
202+
//================================================================================
203+
204+
/// Enum representing the two directions of the bidirectional binding.<br >
205+
/// This is used internally to manage updates and dependencies while avoiding code duplication.
206+
///
207+
/// While dependencies are added by [MappedBidirectionalBinding#addDependenciesFor(Target, Observable...)], they
208+
/// are effectively registered by the target constant, [#registerDependencies(MappedBidirectionalBinding, Collection)].
209+
public enum Target {
210+
/// Represents the direction from the second observable to the first.
211+
///
212+
/// When the second observable changes, the `FIRST` target is updated using the second-to-first mapper.
213+
FIRST {
214+
@SuppressWarnings({"rawtypes", "unchecked"})
215+
@Override
216+
<A, B> void update(MappedBidirectionalBinding<A, B> binding) {
217+
try {
218+
binding.locked = true;
219+
A newVal = binding.secondToFirstMapper.apply(binding.second.getValue());
220+
if (binding.first instanceof WritableValue wv) wv.setValue(newVal);
221+
} finally {
222+
binding.locked = false;
223+
}
224+
}
225+
226+
@Override
227+
<A, B> void registerDependencies(MappedBidirectionalBinding<A, B> binding, Collection<Observable> dependencies) {
228+
for (Observable dependency : dependencies) {
229+
binding.secondWhen.invalidating(dependency);
230+
}
231+
}
232+
},
233+
234+
/// Represents the direction from the first observable to the second.
235+
///
236+
/// When the first observable changes, the `SECOND` target is updated using the first-to-second mapper.
237+
SECOND {
238+
@SuppressWarnings({"rawtypes", "unchecked"})
239+
@Override
240+
<A, B> void update(MappedBidirectionalBinding<A, B> binding) {
241+
try {
242+
binding.locked = true;
243+
B newVal = binding.firstToSecondMapper.apply(binding.first.getValue());
244+
if (binding.second instanceof WritableValue wv) wv.setValue(newVal);
245+
} finally {
246+
binding.locked = false;
247+
}
248+
}
249+
250+
@Override
251+
<A, B> void registerDependencies(MappedBidirectionalBinding<A, B> binding, Collection<Observable> dependencies) {
252+
for (Observable dependency : dependencies) {
253+
binding.firstWhen.invalidating(dependency);
254+
}
255+
}
256+
};
257+
258+
abstract <A, B> void update(MappedBidirectionalBinding<A, B> binding);
259+
260+
abstract <A, B> void registerDependencies(MappedBidirectionalBinding<A, B> binding, Collection<Observable> dependencies);
261+
}
262+
}

modules/core/src/main/java/io/github/palexdev/mfxcore/base/bindings/Mapper.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@
1818

1919
package io.github.palexdev.mfxcore.base.bindings;
2020

21+
import java.util.Objects;
2122
import java.util.function.Function;
2223
import java.util.function.Supplier;
2324

2425
/// Specialization of [Function] to also add the support for "orElse".
2526
///
2627
/// @param <T> – the type of the input to the function
27-
/// @param <R> – the type of the result of the function, as well as the type of the orElse supplier
28+
/// @param <R> – the function's result type, as well as the type of the orElse supplier
2829
public class Mapper<T, R> implements Function<T, R> {
2930
//================================================================================
3031
// Properties
@@ -39,7 +40,7 @@ protected Mapper() {
3940
}
4041

4142
public Mapper(Function<T, R> fn) {
42-
this.fn = fn;
43+
this.fn = Objects.requireNonNull(fn);
4344
}
4445

4546
public static <T, R> Mapper<T, R> of(Function<T, R> fn) {
@@ -80,7 +81,7 @@ public Supplier<R> getOrElse() {
8081

8182
/// Sets the "orElse" [Supplier] of this mapper.
8283
public Mapper<T, R> orElse(Supplier<R> orElse) {
83-
this.orElse = orElse;
84+
this.orElse = Objects.requireNonNull(orElse);
8485
return this;
8586
}
8687
}

0 commit comments

Comments
 (0)