Skip to content

Commit 39a8d37

Browse files
committed
Fix wrapped text scrollbar flicker
Changed averageLengthEstimate and totalLengthEstimate calculation process to avoid intermediary updates during cell refresh.
1 parent 211f981 commit 39a8d37

3 files changed

Lines changed: 71 additions & 32 deletions

File tree

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.time.Duration;
44
import java.util.function.BiFunction;
55
import java.util.function.Function;
6+
import java.util.function.Predicate;
67

78
import org.reactfx.AwaitingEventStream;
89
import org.reactfx.EventStream;
@@ -13,6 +14,7 @@
1314

1415
import javafx.beans.binding.BooleanBinding;
1516
import javafx.beans.property.BooleanProperty;
17+
import javafx.beans.property.SimpleBooleanProperty;
1618
import javafx.beans.value.ObservableBooleanValue;
1719

1820
class PausableSuccessionStream<O> extends EventStreamBase<O> implements AwaitingEventStream<O> {
@@ -24,6 +26,7 @@ class PausableSuccessionStream<O> extends EventStreamBase<O> implements Awaiting
2426
private boolean hasEvent = false;
2527
private BooleanBinding pending = null;
2628
private BooleanProperty successionOff;
29+
private Predicate<O> successionOffCond;
2730
private O event = null;
2831

2932
/**
@@ -37,26 +40,39 @@ class PausableSuccessionStream<O> extends EventStreamBase<O> implements Awaiting
3740
* the returned stream are used from the JavaFX application thread.</p>
3841
*
3942
* @param timeout the maximum time difference between two subsequent events
40-
* in a <em>close</em> succession.
43+
* in <em>close</em> succession.
4144
* @param realTime when true immediately emits the next event and sets
4245
* realTime back to <em>false</em>.
4346
*/
4447
public PausableSuccessionStream( EventStream<O> input, Duration timeout, BooleanProperty realTime )
4548
{
46-
this( input, (Function<? super O, ? extends O>) Function.identity(), (a,b) -> b, timeout, realTime );
49+
this( input, (Function<? super O, ? extends O>) Function.identity(), (a,b) -> b, timeout, realTime, a -> realTime.get() );
4750
}
4851

49-
public PausableSuccessionStream(
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(
5064
EventStream<O> input,
5165
Function<? super O, ? extends O> initial,
5266
BiFunction<? super O, ? super O, ? extends O> reduction,
5367
Duration timeout,
54-
BooleanProperty realTime) {
68+
BooleanProperty realTime,
69+
Predicate<O> condition) {
5570

5671
this.input = input;
5772
this.initial = initial;
5873
this.reduction = reduction;
5974
this.successionOff = realTime;
75+
this.successionOffCond = condition;
6076

6177
this.timer = FxTimer.create(timeout, this::handleTimeout);
6278
}
@@ -85,9 +101,9 @@ protected final Subscription observeInputs() {
85101
}
86102

87103
private void handleEvent(O i) {
88-
if(successionOff.get())
104+
timer.stop();
105+
if(successionOffCond.test(i))
89106
{
90-
timer.stop();
91107
hasEvent = false;
92108
event = null;
93109
emit(i);

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

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

78
import javafx.beans.property.SimpleBooleanProperty;
89
import javafx.beans.value.ObservableObjectValue;
@@ -63,8 +64,8 @@ public SizeTracker(
6364

6465
this.maxKnownMinBreadth = Val.create(
6566
() -> 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
67+
// skips spurious events resulting from cell replacement (delete then add again)
68+
knownBreadths.changes().successionEnds( Duration.ofMillis( 15 ) )
6869
);
6970

7071
this.breadthForCells = Val.combine(
@@ -77,29 +78,47 @@ public SizeTracker(
7778
.map(breadth -> cell -> orientation.prefLength(cell, breadth));
7879

7980
this.lengths = cells.mapDynamic(lengthFn).memoize();
80-
8181
LiveList<Double> knownLengths = this.lengths.memoizedItems();
8282

83-
this.averageLengthEstimate = Val.create(
84-
() -> {
85-
// make sure to use pref lengths of all present cells
86-
for(int i = 0; i < cells.getMemoizedCount(); ++i) {
87-
int j = cells.indexOfMemoizedItem(i);
88-
lengths.force(j, j + 1);
89-
}
90-
91-
return knownLengths.stream()
92-
.mapToDouble( Double::doubleValue )
93-
.average().orElse(0.0);
94-
},
95-
knownLengths.changes().filter( c -> knownLengths.size() > 0 )
96-
// skip zero cell events to prevent scrollbar flicker
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 } )
97105
);
98106

99-
this.totalLengthEstimate = Val.combine(
100-
averageLengthEstimate,
101-
cells.sizeProperty().filter( s -> s > 0 ),
102-
(avg, n) -> n * avg);
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 ) );
103122

104123
Val<Integer> firstVisibleIndex = Val.create(
105124
() -> cells.getMemoizedCount() == 0 ? null : cells.indexOfMemoizedItem(0),

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

Lines changed: 9 additions & 5 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;
@@ -21,9 +22,9 @@ public void idempotentShowTest() {
2122
vf.resize(100, 100); // size of VirtualFlow less than that of the cell
2223
vf.layout();
2324

24-
vf.show(110.0);
2525
vf.show(110.0);
2626
vf.layout();
27+
2728
assertEquals(-10.0, rect.getBoundsInParent().getMinY(), 0.01);
2829
}
2930

@@ -36,9 +37,10 @@ public void idempotentSetLengthOffsetTest() {
3637
vf.resize(100, 100); // size of VirtualFlow less than that of the cell
3738
vf.layout();
3839

39-
vf.setLengthOffset(10.0);
40-
vf.setLengthOffset(10.0);
40+
WaitForAsyncUtils.waitForFxEvents();
41+
vf.setLengthOffset( 10 );
4142
vf.layout();
43+
4244
assertEquals(-10.0, rect.getBoundsInParent().getMinY(), 0.01);
4345
}
4446

@@ -65,12 +67,14 @@ public void fastVisibleIndexTest() {
6567
vf.layout();
6668
assertSame(visibleCells.get(0), vf.getCell(vf.getFirstVisibleIndex()));
6769
assertSame(visibleCells.get(visibleCells.size() - 1), vf.getCell(vf.getLastVisibleIndex()));
68-
assertTrue(vf.getFirstVisibleIndex() <= 50 && 50 <= vf.getLastVisibleIndex());
70+
assertTrue(vf.getFirstVisibleIndex() > 40 && vf.getFirstVisibleIndex() <= 50);
71+
assertTrue(vf.getLastVisibleIndex() >= 50);
6972

7073
vf.show(99);
7174
vf.layout();
7275
assertSame(visibleCells.get(0), vf.getCell(vf.getFirstVisibleIndex()));
7376
assertSame(visibleCells.get(visibleCells.size() - 1), vf.getCell(vf.getLastVisibleIndex()));
74-
assertTrue(vf.getFirstVisibleIndex() <= 99 && 99 <= vf.getLastVisibleIndex());
77+
assertTrue(vf.getFirstVisibleIndex() > 50 && vf.getFirstVisibleIndex() <= 99);
78+
assertTrue(vf.getLastVisibleIndex() >= 99);
7579
}
7680
}

0 commit comments

Comments
 (0)