Skip to content

Commit 35e0e7a

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 35e0e7a

7 files changed

Lines changed: 231 additions & 148 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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, 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, timeout, new SimpleBooleanProperty(), condition );
61+
}
62+
63+
private PausableSuccessionStream(
64+
EventStream<O> input,
65+
java.time.Duration timeout,
66+
BooleanProperty realTime,
67+
Predicate<O> condition) {
68+
69+
this.input = input;
70+
this.initial = Function.identity();
71+
this.reduction = (a,b) -> b;
72+
this.successionOff = realTime;
73+
this.successionOffCond = condition;
74+
75+
this.timer = FxTimer.create(timeout, this::handleTimeout);
76+
}
77+
78+
@Override
79+
public ObservableBooleanValue pendingProperty() {
80+
if(pending == null) {
81+
pending = new BooleanBinding() {
82+
@Override
83+
protected boolean computeValue() {
84+
return hasEvent;
85+
}
86+
};
87+
}
88+
return pending;
89+
}
90+
91+
@Override
92+
public boolean isPending() {
93+
return pending != null ? pending.get() : hasEvent;
94+
}
95+
96+
@Override
97+
protected final Subscription observeInputs() {
98+
return input.subscribe(this::handleEvent);
99+
}
100+
101+
private void handleEvent(O i) {
102+
timer.stop();
103+
if(successionOffCond.test(i))
104+
{
105+
hasEvent = false;
106+
event = null;
107+
emit(i);
108+
successionOff.setValue(false);
109+
}
110+
else
111+
{
112+
if(hasEvent) {
113+
event = reduction.apply(event, i);
114+
} else {
115+
event = initial.apply(i);
116+
hasEvent = true;
117+
invalidatePending();
118+
}
119+
timer.restart();
120+
}
121+
}
122+
123+
private void handleTimeout() {
124+
hasEvent = false;
125+
O toEmit = event;
126+
event = null;
127+
emit(toEmit);
128+
invalidatePending();
129+
}
130+
131+
private void invalidatePending() {
132+
if(pending != null) {
133+
pending.invalidate();
134+
}
135+
}
136+
}

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

0 commit comments

Comments
 (0)