Skip to content

Commit 2f06595

Browse files
committed
Fix wrapped text scrollbar flicker
When averageLengthEstimate is updating it briefly has spurious values which causes jitter. Also reduced horizontal scrollbar noise.
1 parent b5ef653 commit 2f06595

5 files changed

Lines changed: 171 additions & 26 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package org.fxmisc.flowless;
2+
3+
import java.time.Duration;
4+
import java.util.function.BiFunction;
5+
import java.util.function.Function;
6+
7+
import org.reactfx.AwaitingEventStream;
8+
import org.reactfx.EventStream;
9+
import org.reactfx.EventStreamBase;
10+
import org.reactfx.Subscription;
11+
import org.reactfx.util.FxTimer;
12+
import org.reactfx.util.Timer;
13+
14+
import javafx.beans.binding.BooleanBinding;
15+
import javafx.beans.property.BooleanProperty;
16+
import javafx.beans.value.ObservableBooleanValue;
17+
18+
class PausableSuccessionStream<O> extends EventStreamBase<O> implements AwaitingEventStream<O> {
19+
private final EventStream<O> input;
20+
private final Function<? super O, ? extends O> initial;
21+
private final BiFunction<? super O, ? super O, ? extends O> reduction;
22+
private final Timer timer;
23+
24+
private boolean hasEvent = false;
25+
private BooleanBinding pending = null;
26+
private BooleanProperty successionOff;
27+
private O event = null;
28+
29+
/**
30+
* Returns an event stream that, when events are emitted from this stream
31+
* in close temporal succession, emits only the last event of the
32+
* succession. What is considered a <i>close temporal succession</i> is
33+
* defined by {@code timeout}: time gap between two successive events must
34+
* be at most {@code timeout}.
35+
*
36+
* <p><b>Note:</b> This function can be used only when this stream and
37+
* the returned stream are used from the JavaFX application thread.</p>
38+
*
39+
* @param timeout the maximum time difference between two subsequent events
40+
* in a <em>close</em> succession.
41+
* @param realTime when true immediately emits the next event and sets
42+
* realTime back to <em>false</em>.
43+
*/
44+
public PausableSuccessionStream( EventStream<O> input, Duration timeout, BooleanProperty realTime )
45+
{
46+
this( input, (Function<? super O, ? extends O>) Function.identity(), (a,b) -> b, timeout, realTime );
47+
}
48+
49+
public PausableSuccessionStream(
50+
EventStream<O> input,
51+
Function<? super O, ? extends O> initial,
52+
BiFunction<? super O, ? super O, ? extends O> reduction,
53+
Duration timeout,
54+
BooleanProperty realTime) {
55+
56+
this.input = input;
57+
this.initial = initial;
58+
this.reduction = reduction;
59+
this.successionOff = realTime;
60+
61+
this.timer = FxTimer.create(timeout, this::handleTimeout);
62+
}
63+
64+
@Override
65+
public ObservableBooleanValue pendingProperty() {
66+
if(pending == null) {
67+
pending = new BooleanBinding() {
68+
@Override
69+
protected boolean computeValue() {
70+
return hasEvent;
71+
}
72+
};
73+
}
74+
return pending;
75+
}
76+
77+
@Override
78+
public boolean isPending() {
79+
return pending != null ? pending.get() : hasEvent;
80+
}
81+
82+
@Override
83+
protected final Subscription observeInputs() {
84+
return input.subscribe(this::handleEvent);
85+
}
86+
87+
private void handleEvent(O i) {
88+
if(successionOff.get())
89+
{
90+
timer.stop();
91+
hasEvent = false;
92+
event = null;
93+
emit(i);
94+
successionOff.setValue(false);
95+
}
96+
else
97+
{
98+
if(hasEvent) {
99+
event = reduction.apply(event, i);
100+
} else {
101+
event = initial.apply(i);
102+
hasEvent = true;
103+
invalidatePending();
104+
}
105+
timer.restart();
106+
}
107+
}
108+
109+
private void handleTimeout() {
110+
hasEvent = false;
111+
O toEmit = event;
112+
event = null;
113+
emit(toEmit);
114+
invalidatePending();
115+
}
116+
117+
private void invalidatePending() {
118+
if(pending != null) {
119+
pending.invalidate();
120+
}
121+
}
122+
}

src/main/java/org/fxmisc/flowless/SizeTracker.java

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
import java.util.Optional;
55
import java.util.function.Function;
66

7+
import javafx.beans.property.SimpleBooleanProperty;
78
import javafx.beans.value.ObservableObjectValue;
89
import javafx.geometry.Bounds;
910
import javafx.scene.control.IndexRange;
1011

12+
import org.reactfx.EventStream;
1113
import org.reactfx.EventStreams;
1214
import org.reactfx.Subscription;
1315
import org.reactfx.collection.LiveList;
1416
import org.reactfx.collection.MemoizationList;
17+
import org.reactfx.util.Tuple3;
1518
import org.reactfx.value.Val;
1619
import org.reactfx.value.ValBase;
1720

@@ -56,9 +59,15 @@ public SizeTracker(
5659
this.viewportBounds = viewportBounds;
5760
this.cells = lazyCells;
5861
this.breadths = lazyCells.map(orientation::minBreadth).memoize();
59-
this.maxKnownMinBreadth = breadths.memoizedItems()
60-
.reduce(Math::max)
61-
.orElseConst(0.0);
62+
LiveList<Double> knownBreadths = this.breadths.memoizedItems();
63+
64+
this.maxKnownMinBreadth = Val.create(
65+
() -> knownBreadths.stream().mapToDouble( Double::doubleValue ).max().orElse(0.0),
66+
EventStreams.changesOf( knownBreadths ).filter( c -> c.getList().size() > 0 )
67+
// skips spurious events resulting from cell replacement (delete then add again)
68+
.successionEnds( Duration.ofMillis( 15 ) )
69+
);
70+
6271
this.breadthForCells = Val.combine(
6372
maxKnownMinBreadth,
6473
viewportBounds,
@@ -71,8 +80,6 @@ public SizeTracker(
7180
this.lengths = cells.mapDynamic(lengthFn).memoize();
7281

7382
LiveList<Double> knownLengths = this.lengths.memoizedItems();
74-
Val<Double> sumOfKnownLengths = knownLengths.reduce((a, b) -> a + b).orElseConst(0.0);
75-
Val<Integer> knownLengthCount = knownLengths.sizeProperty();
7683

7784
this.averageLengthEstimate = Val.create(
7885
() -> {
@@ -82,12 +89,14 @@ public SizeTracker(
8289
lengths.force(j, j + 1);
8390
}
8491

85-
int count = knownLengthCount.getValue();
86-
return count == 0
87-
? null
88-
: sumOfKnownLengths.getValue() / count;
92+
return knownLengths.stream()
93+
.mapToDouble( Double::doubleValue )
94+
.average().orElse(0.0);
8995
},
90-
sumOfKnownLengths, knownLengthCount);
96+
EventStreams.changesOf( knownLengths ).filter( c -> c.getList().size() > 0 )
97+
// skips spurious events resulting from cell replacement (delete then add again)
98+
.successionEnds( Duration.ofMillis( 15 ) )
99+
);
91100

92101
this.totalLengthEstimate = Val.combine(
93102
averageLengthEstimate, cells.sizeProperty(),
@@ -98,9 +107,6 @@ public SizeTracker(
98107
cells, cells.memoizedItems()); // need to observe cells.memoizedItems()
99108
// as well, because they may change without a change in cells.
100109

101-
Val<? extends Cell<?, ?>> firstVisibleCell = cells.memoizedItems()
102-
.collapse(visCells -> visCells.isEmpty() ? null : visCells.get(0));
103-
104110
Val<Integer> knownLengthCountBeforeFirstVisibleCell = Val.create(() -> {
105111
return firstVisibleIndex.getOpt()
106112
.map(i -> lengths.getMemoizedCountBefore(Math.min(i, lengths.size())))
@@ -117,17 +123,23 @@ public SizeTracker(
117123
averageLengthEstimate,
118124
(firstIdx, knownCnt, avgLen) -> (firstIdx - knownCnt) * avgLen);
119125

120-
Val<Double> firstCellMinY = firstVisibleCell.flatMap(orientation::minYProperty);
126+
Val<Double> firstCellMinY = cells.memoizedItems()
127+
.collapse(visCells -> visCells.isEmpty() ? null : visCells.get(0))
128+
.flatMap(orientation::minYProperty);
121129

122-
lengthOffsetEstimate = Val.wrap( EventStreams.combine(
130+
EventStream<Tuple3<Double, Double, Double>> lengthOffsetStream = EventStreams.combine(
123131
totalKnownLengthBeforeFirstVisibleCell.values(),
124132
unknownLengthEstimateBeforeFirstVisibleCell.values(),
125133
firstCellMinY.values()
126-
)
127-
.filter( t3 -> t3.test( (a,b,minY) -> a != null && b != null && minY != null ) )
128-
.thenRetainLatestFor( Duration.ofMillis( 1 ) )
129-
.map( t3 -> t3.map( (a,b,minY) -> Double.valueOf( a + b - minY ) ) )
130-
.toBinding( 0.0 ) );
134+
);
135+
136+
lengthOffsetEstimate = Val.wrap(
137+
// skip spurious events resulting from cell replacement (delete then add again), except
138+
// when immediateUpdate is true: activated via updateNextLengthOffsetEstimateImmediately()
139+
new PausableSuccessionStream<>( lengthOffsetStream, Duration.ofMillis(15), immediateUpdate )
140+
.filter( t3 -> t3.test( (a,b,minY) -> a != null && b != null && minY != null ) )
141+
.map( t3 -> t3.map( (a,b,minY) -> Double.valueOf( Math.round( a + b - minY ) ) ) )
142+
.toBinding( 0.0 ) );
131143

132144
// pinning totalLengthEstimate and lengthOffsetEstimate
133145
// binds it all together and enables memoization
@@ -136,6 +148,9 @@ public SizeTracker(
136148
lengthOffsetEstimate.pin());
137149
}
138150

151+
private SimpleBooleanProperty immediateUpdate = new SimpleBooleanProperty();
152+
void updateNextLengthOffsetEstimateImmediately() { immediateUpdate.set( true ); }
153+
139154
private static <T> Val<T> avoidFalseInvalidations(Val<T> src) {
140155
return new ValBase<T>() {
141156
@Override

src/main/java/org/fxmisc/flowless/VirtualFlow.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@ void setLengthOffset(double pixels) {
532532
if(diff == 0) {
533533
// do nothing
534534
} else if(Math.abs(diff) <= length) { // distance less than one screen
535+
sizeTracker.updateNextLengthOffsetEstimateImmediately();
535536
navigator.scrollCurrentPositionBy(diff);
536537
} else {
537538
jumpToAbsolutePosition(pixels);

src/main/java/org/fxmisc/flowless/VirtualizedScrollPane.java

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,9 @@ private void setHPosition(double pos) {
327327
content.getLayoutBounds().getWidth(),
328328
padding.getLeft() + padding.getRight(),
329329
content.totalWidthEstimateProperty().getValue());
330-
content.estimatedScrollXProperty().setValue((double) Math.round(offset));
330+
if ( content.estimatedScrollXProperty().getValue() != offset ) {
331+
content.estimatedScrollXProperty().setValue(offset);
332+
}
331333
}
332334

333335
private void setVPosition(double pos) {
@@ -337,9 +339,9 @@ private void setVPosition(double pos) {
337339
content.getLayoutBounds().getHeight(),
338340
padding.getTop() + padding.getBottom(),
339341
content.totalHeightEstimateProperty().getValue());
340-
// offset needs rounding otherwise thin lines appear between cells,
341-
// usually only visible when cells have dark backgrounds/borders.
342-
content.estimatedScrollYProperty().setValue((double) Math.round(offset));
342+
if ( content.estimatedScrollYProperty().getValue() != offset ) {
343+
content.estimatedScrollYProperty().setValue(offset);
344+
}
343345
}
344346

345347
private static void setupUnitIncrement(ScrollBar bar) {
@@ -360,14 +362,16 @@ protected double computeValue() {
360362
private static double offsetToScrollbarPosition(
361363
double contentOffset, double viewportSize, double padding, double contentSize) {
362364
return contentSize > viewportSize
363-
? contentOffset / (contentSize - viewportSize + padding) * contentSize
365+
// rounding otherwise thin lines appear between cells, only visible with dark backgrounds/borders
366+
? (double) Math.round( contentOffset / (contentSize - viewportSize + padding) * contentSize )
364367
: 0;
365368
}
366369

367370
private static double scrollbarPositionToOffset(
368371
double scrollbarPos, double viewportSize, double padding, double contentSize) {
369372
return contentSize > viewportSize
370-
? scrollbarPos / contentSize * (contentSize - viewportSize + padding)
373+
// rounding otherwise thin lines appear between cells, only visible with dark backgrounds/borders
374+
? (double) Math.round( scrollbarPos / contentSize * (contentSize - viewportSize + padding) )
371375
: 0;
372376
}
373377
}

src/test/java/org/fxmisc/flowless/VirtualFlowTest.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import static org.junit.Assert.assertTrue;
66

77
import org.junit.Test;
8+
import org.testfx.util.WaitForAsyncUtils;
89

910
import javafx.collections.FXCollections;
1011
import javafx.collections.ObservableList;
@@ -36,9 +37,11 @@ public void idempotentSetLengthOffsetTest() {
3637
vf.resize(100, 100); // size of VirtualFlow less than that of the cell
3738
vf.layout();
3839

40+
WaitForAsyncUtils.waitForFxEvents();
3941
vf.setLengthOffset(10.0);
4042
vf.setLengthOffset(10.0);
4143
vf.layout();
44+
4245
assertEquals(-10.0, rect.getBoundsInParent().getMinY(), 0.01);
4346
}
4447

0 commit comments

Comments
 (0)