Skip to content

Commit 39a585a

Browse files
committed
Fix wrapped text scrollbar flicker
Changed averageLengthEstimate and totalLengthEstimate calculation process to avoid intermediary updates during cell refresh. Fixed ScaledVirtualized rounding.
1 parent b5ef653 commit 39a585a

6 files changed

Lines changed: 233 additions & 53 deletions

File tree

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

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
* VirtualizedScrollPane<ScaledVirtualized> vsPane = new VirtualizedScrollPane(wrapper);
1818
*
1919
* // To scale actualContent without also scaling vsPane's scrollbars:
20-
* wrapper.scaleProperty().setY(3);
21-
* wrapper.scaleProperty().setX(2);
20+
* wrapper.getZoom().setY(3);
21+
* wrapper.getZoom().setX(2);
2222
* }
2323
* </pre>
2424
*
@@ -51,13 +51,13 @@ public ScaledVirtualized(V content) {
5151
);
5252
estScrollX = Var.mapBidirectional(
5353
content.estimatedScrollXProperty(),
54-
scrollX -> scrollX * zoom.getX(),
55-
scrollX -> scrollX / zoom.getX()
54+
scrollX -> (double) Math.round( scrollX * zoom.getX() ),
55+
scrollX -> (double) Math.round( scrollX / zoom.getX() )
5656
);
5757
estScrollY = Var.mapBidirectional(
5858
content.estimatedScrollYProperty(),
59-
scrollY -> scrollY * zoom.getY(),
60-
scrollY -> scrollY / zoom.getY()
59+
scrollY -> (double) Math.round( scrollY * zoom.getY() ),
60+
scrollY -> (double) Math.round( scrollY / zoom.getY() )
6161
);
6262

6363
zoom.xProperty() .addListener((obs, ov, nv) -> requestLayout());

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

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@
33
import java.time.Duration;
44
import java.util.Optional;
55
import java.util.function.Function;
6+
import java.util.function.Supplier;
67

8+
import javafx.beans.property.SimpleBooleanProperty;
79
import javafx.beans.value.ObservableObjectValue;
810
import javafx.geometry.Bounds;
911
import javafx.scene.control.IndexRange;
1012

13+
import org.reactfx.EventStream;
1114
import org.reactfx.EventStreams;
1215
import org.reactfx.Subscription;
1316
import org.reactfx.collection.LiveList;
1417
import org.reactfx.collection.MemoizationList;
18+
import org.reactfx.util.Tuple3;
1519
import org.reactfx.value.Val;
1620
import org.reactfx.value.ValBase;
1721

@@ -56,9 +60,14 @@ public SizeTracker(
5660
this.viewportBounds = viewportBounds;
5761
this.cells = lazyCells;
5862
this.breadths = lazyCells.map(orientation::minBreadth).memoize();
59-
this.maxKnownMinBreadth = breadths.memoizedItems()
60-
.reduce(Math::max)
61-
.orElseConst(0.0);
63+
LiveList<Double> knownBreadths = this.breadths.memoizedItems();
64+
65+
this.maxKnownMinBreadth = Val.create(
66+
() -> knownBreadths.stream().mapToDouble( Double::doubleValue ).max().orElse(0.0),
67+
// skips spurious events resulting from cell replacement (delete then add again)
68+
knownBreadths.changes().successionEnds( Duration.ofMillis( 15 ) )
69+
);
70+
6271
this.breadthForCells = Val.combine(
6372
maxKnownMinBreadth,
6473
viewportBounds,
@@ -69,38 +78,53 @@ public SizeTracker(
6978
.map(breadth -> cell -> orientation.prefLength(cell, breadth));
7079

7180
this.lengths = cells.mapDynamic(lengthFn).memoize();
72-
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();
76-
77-
this.averageLengthEstimate = Val.create(
78-
() -> {
79-
// make sure to use pref lengths of all present cells
80-
for(int i = 0; i < cells.getMemoizedCount(); ++i) {
81-
int j = cells.indexOfMemoizedItem(i);
82-
lengths.force(j, j + 1);
83-
}
84-
85-
int count = knownLengthCount.getValue();
86-
return count == 0
87-
? null
88-
: sumOfKnownLengths.getValue() / count;
89-
},
90-
sumOfKnownLengths, knownLengthCount);
91-
92-
this.totalLengthEstimate = Val.combine(
93-
averageLengthEstimate, cells.sizeProperty(),
94-
(avg, n) -> n * avg);
82+
83+
Supplier<Double> averageKnownLengths = () -> {
84+
// make sure to use pref lengths of all present cells
85+
for(int i = 0; i < cells.getMemoizedCount(); ++i) {
86+
int j = cells.indexOfMemoizedItem(i);
87+
lengths.force(j, j + 1);
88+
}
89+
90+
return knownLengths.stream()
91+
.mapToDouble( Double::doubleValue )
92+
.sorted().average()
93+
.orElse( 0.0 );
94+
};
95+
96+
final int AVERAGE_LENGTH = 0, TOTAL_LENGTH = 1;
97+
Val<double[/*average,total*/]> lengthStats = Val.wrap(
98+
knownLengths.changes().or( cells.sizeProperty().values() )
99+
.successionEnds( Duration.ofMillis( 15 ) ) // reduce noise
100+
.map( e -> {
101+
double averageLength = averageKnownLengths.get();
102+
int cellCount = e.isRight() ? e.getRight() : cells.size();
103+
return new double[] { averageLength, cellCount * averageLength };
104+
} ).toBinding( new double[] { 0.0, 0.0 } )
105+
);
106+
107+
EventStream<double[/*average,total*/]> filteredLengthStats;
108+
// briefly hold back changes that may be from spurious events coming from cell refreshes, these
109+
// are identified as those where the estimated total length is less than the previous event.
110+
filteredLengthStats = new PausableSuccessionStream<>( lengthStats.changes(), Duration.ofMillis(1000), chg -> {
111+
double[/*average,total*/] oldStats = chg.getOldValue();
112+
double[/*average,total*/] newStats = chg.getNewValue();
113+
if ( newStats[TOTAL_LENGTH] < oldStats[TOTAL_LENGTH] ) {
114+
return false; // don't emit yet, first wait & prefer newer values
115+
}
116+
return true;
117+
} )
118+
.map( chg -> chg.getNewValue() );
119+
120+
this.averageLengthEstimate = Val.wrap( filteredLengthStats.map( stats -> stats[AVERAGE_LENGTH] ).toBinding( 0.0 ) );
121+
this.totalLengthEstimate = Val.wrap( filteredLengthStats.map( stats -> stats[TOTAL_LENGTH] ).toBinding( 0.0 ) );
95122

96123
Val<Integer> firstVisibleIndex = Val.create(
97124
() -> cells.getMemoizedCount() == 0 ? null : cells.indexOfMemoizedItem(0),
98125
cells, cells.memoizedItems()); // need to observe cells.memoizedItems()
99126
// as well, because they may change without a change in cells.
100127

101-
Val<? extends Cell<?, ?>> firstVisibleCell = cells.memoizedItems()
102-
.collapse(visCells -> visCells.isEmpty() ? null : visCells.get(0));
103-
104128
Val<Integer> knownLengthCountBeforeFirstVisibleCell = Val.create(() -> {
105129
return firstVisibleIndex.getOpt()
106130
.map(i -> lengths.getMemoizedCountBefore(Math.min(i, lengths.size())))
@@ -117,17 +141,23 @@ public SizeTracker(
117141
averageLengthEstimate,
118142
(firstIdx, knownCnt, avgLen) -> (firstIdx - knownCnt) * avgLen);
119143

120-
Val<Double> firstCellMinY = firstVisibleCell.flatMap(orientation::minYProperty);
144+
Val<Double> firstCellMinY = cells.memoizedItems()
145+
.collapse(visCells -> visCells.isEmpty() ? null : visCells.get(0))
146+
.flatMap(orientation::minYProperty);
121147

122-
lengthOffsetEstimate = Val.wrap( EventStreams.combine(
148+
EventStream<Tuple3<Double, Double, Double>> lengthOffsetStream = EventStreams.combine(
123149
totalKnownLengthBeforeFirstVisibleCell.values(),
124150
unknownLengthEstimateBeforeFirstVisibleCell.values(),
125151
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 ) );
152+
);
153+
154+
lengthOffsetEstimate = Val.wrap(
155+
// skip spurious events resulting from cell replacement (delete then add again), except
156+
// when immediateUpdate is true: activated via updateNextLengthOffsetEstimateImmediately()
157+
new PausableSuccessionStream<>( lengthOffsetStream, Duration.ofMillis(15), immediateUpdate )
158+
.filter( t3 -> t3.test( (a,b,minY) -> a != null && b != null && minY != null ) )
159+
.map( t3 -> t3.map( (a,b,minY) -> Double.valueOf( Math.round( a + b - minY ) ) ) )
160+
.toBinding( 0.0 ) );
131161

132162
// pinning totalLengthEstimate and lengthOffsetEstimate
133163
// binds it all together and enables memoization
@@ -136,6 +166,9 @@ public SizeTracker(
136166
lengthOffsetEstimate.pin());
137167
}
138168

169+
private SimpleBooleanProperty immediateUpdate = new SimpleBooleanProperty();
170+
void updateNextLengthOffsetEstimateImmediately() { immediateUpdate.set( true ); }
171+
139172
private static <T> Val<T> avoidFalseInvalidations(Val<T> src) {
140173
return new ValBase<T>() {
141174
@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)