Skip to content

Commit 211f981

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 211f981

4 files changed

Lines changed: 169 additions & 28 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 & 21 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,14 @@ 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+
knownBreadths.changes().filter( c -> knownBreadths.size() > 0 )
67+
// skip zero cell events to prevent scrollbar flicker
68+
);
69+
6270
this.breadthForCells = Val.combine(
6371
maxKnownMinBreadth,
6472
viewportBounds,
@@ -71,8 +79,6 @@ public SizeTracker(
7179
this.lengths = cells.mapDynamic(lengthFn).memoize();
7280

7381
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();
7682

7783
this.averageLengthEstimate = Val.create(
7884
() -> {
@@ -82,25 +88,24 @@ public SizeTracker(
8288
lengths.force(j, j + 1);
8389
}
8490

85-
int count = knownLengthCount.getValue();
86-
return count == 0
87-
? null
88-
: sumOfKnownLengths.getValue() / count;
91+
return knownLengths.stream()
92+
.mapToDouble( Double::doubleValue )
93+
.average().orElse(0.0);
8994
},
90-
sumOfKnownLengths, knownLengthCount);
95+
knownLengths.changes().filter( c -> knownLengths.size() > 0 )
96+
// skip zero cell events to prevent scrollbar flicker
97+
);
9198

9299
this.totalLengthEstimate = Val.combine(
93-
averageLengthEstimate, cells.sizeProperty(),
100+
averageLengthEstimate,
101+
cells.sizeProperty().filter( s -> s > 0 ),
94102
(avg, n) -> n * avg);
95103

96104
Val<Integer> firstVisibleIndex = Val.create(
97105
() -> cells.getMemoizedCount() == 0 ? null : cells.indexOfMemoizedItem(0),
98106
cells, cells.memoizedItems()); // need to observe cells.memoizedItems()
99107
// as well, because they may change without a change in cells.
100108

101-
Val<? extends Cell<?, ?>> firstVisibleCell = cells.memoizedItems()
102-
.collapse(visCells -> visCells.isEmpty() ? null : visCells.get(0));
103-
104109
Val<Integer> knownLengthCountBeforeFirstVisibleCell = Val.create(() -> {
105110
return firstVisibleIndex.getOpt()
106111
.map(i -> lengths.getMemoizedCountBefore(Math.min(i, lengths.size())))
@@ -117,17 +122,23 @@ public SizeTracker(
117122
averageLengthEstimate,
118123
(firstIdx, knownCnt, avgLen) -> (firstIdx - knownCnt) * avgLen);
119124

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

122-
lengthOffsetEstimate = Val.wrap( EventStreams.combine(
129+
EventStream<Tuple3<Double, Double, Double>> lengthOffsetStream = EventStreams.combine(
123130
totalKnownLengthBeforeFirstVisibleCell.values(),
124131
unknownLengthEstimateBeforeFirstVisibleCell.values(),
125132
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 ) );
133+
);
134+
135+
lengthOffsetEstimate = Val.wrap(
136+
// skip spurious events resulting from cell replacement (delete then add again), except
137+
// when immediateUpdate is true: activated via updateNextLengthOffsetEstimateImmediately()
138+
new PausableSuccessionStream<>( lengthOffsetStream, Duration.ofMillis(15), immediateUpdate )
139+
.filter( t3 -> t3.test( (a,b,minY) -> a != null && b != null && minY != null ) )
140+
.map( t3 -> t3.map( (a,b,minY) -> Double.valueOf( Math.round( a + b - minY ) ) ) )
141+
.toBinding( 0.0 ) );
131142

132143
// pinning totalLengthEstimate and lengthOffsetEstimate
133144
// binds it all together and enables memoization
@@ -136,6 +147,9 @@ public SizeTracker(
136147
lengthOffsetEstimate.pin());
137148
}
138149

150+
private SimpleBooleanProperty immediateUpdate = new SimpleBooleanProperty();
151+
void updateNextLengthOffsetEstimateImmediately() { immediateUpdate.set( true ); }
152+
139153
private static <T> Val<T> avoidFalseInvalidations(Val<T> src) {
140154
return new ValBase<T>() {
141155
@Override

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ private VirtualFlow(
180180
layoutBoundsProperty(),
181181
b -> new Rectangle(b.getWidth(), b.getHeight())));
182182

183-
lengthOffsetEstimate = new StableBidirectionalVar<>( sizeTracker.lengthOffsetEstimateProperty(), this::setLengthOffset );
183+
lengthOffsetEstimate = sizeTracker.lengthOffsetEstimateProperty().asVar(this::setLengthOffset);
184184

185185
// scroll content by mouse scroll
186186
this.addEventHandler(ScrollEvent.ANY, se -> {
@@ -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
}

0 commit comments

Comments
 (0)