diff --git a/src/main/java/org/fxmisc/flowless/Cell.java b/src/main/java/org/fxmisc/flowless/Cell.java index 662cd3b..e1db7de 100644 --- a/src/main/java/org/fxmisc/flowless/Cell.java +++ b/src/main/java/org/fxmisc/flowless/Cell.java @@ -33,6 +33,20 @@ default boolean isReusable() { return false; } + /** + * If this cell is reusable (as indicated by {@link #isReusable()}), + * this method is called to display a different item. {@link #reset()} + * will have been called before a call to this method. + * + *

The default implementation calls {@link #updateItem(Object)} + * + * @param index the item's position in the VirtualFlow list + * @param item the new item to display + */ + default void update(Integer index, T item) { + updateItem(item); + } + /** * If this cell is reusable (as indicated by {@link #isReusable()}), * this method is called to display a different item. {@link #reset()} diff --git a/src/main/java/org/fxmisc/flowless/CellListManager.java b/src/main/java/org/fxmisc/flowless/CellListManager.java index 5394466..681f117 100644 --- a/src/main/java/org/fxmisc/flowless/CellListManager.java +++ b/src/main/java/org/fxmisc/flowless/CellListManager.java @@ -1,7 +1,7 @@ package org.fxmisc.flowless; import java.util.Optional; -import java.util.function.Function; +import java.util.function.BiFunction; import javafx.collections.ObservableList; import javafx.scene.Node; @@ -31,10 +31,10 @@ final class CellListManager> { public CellListManager( Node owner, ObservableList items, - Function cellFactory) { + BiFunction cellFactory) { this.owner = owner; this.cellPool = new CellPool<>(cellFactory); - this.cells = LiveList.map(items, this::cellForItem).memoize(); + this.cells = new IndexedMappedList<>(items, this::cellForItem).memoize(); this.presentCells = cells.memoizedItems(); this.cellNodes = presentCells.map(Cell::getNode); this.presentCellsSubscription = presentCells.observeQuasiModifications(this::presentCellsChanged); @@ -95,8 +95,8 @@ public void cropTo(int fromItem, int toItem) { } } - private C cellForItem(T item) { - C cell = cellPool.getCell(item); + private C cellForItem(Integer index, T item) { + C cell = cellPool.getCell(index, item); // apply CSS when the cell is first added to the scene Node node = cell.getNode(); @@ -132,7 +132,7 @@ private C cellForItem(T item) { * has moved out of the viewport and is thus removed from * the Navigator's children list. This breaks expected trackpad * scrolling behaviour, at least on macOS. - * + * * So here we take over event-bubbling duties for ScrollEvent * and push them ourselves directly to the given owner. */ diff --git a/src/main/java/org/fxmisc/flowless/CellPool.java b/src/main/java/org/fxmisc/flowless/CellPool.java index d3cddf1..499b27c 100644 --- a/src/main/java/org/fxmisc/flowless/CellPool.java +++ b/src/main/java/org/fxmisc/flowless/CellPool.java @@ -2,17 +2,17 @@ import java.util.LinkedList; import java.util.Queue; -import java.util.function.Function; +import java.util.function.BiFunction; /** * Helper class that stores a pool of reusable cells that can be updated via {@link Cell#updateItem(Object)} or * creates new ones via its {@link #cellFactory} if the pool is empty. */ final class CellPool> { - private final Function cellFactory; + private final BiFunction cellFactory; private final Queue pool = new LinkedList<>(); - public CellPool(Function cellFactory) { + public CellPool(BiFunction cellFactory) { this.cellFactory = cellFactory; } @@ -20,12 +20,17 @@ public CellPool(Function cellFactory) { * Returns a reusable cell that has been updated with the current item if the pool has one, or returns a * newly-created one via its {@link #cellFactory}. */ - public C getCell(T item) { + public C getCell(Integer index, T item) { C cell = pool.poll(); if(cell != null) { - cell.updateItem(item); + // Note that update(index,item) invokes + // cell.updateItem(item) by default + cell.update(index, item); } else { - cell = cellFactory.apply(item); + // Note that cellFactory may just be a wrapper: + // (index, item) -> wrappedFactory.apply(item) + // See the various VirtualFlow creation methods + cell = cellFactory.apply(index, item); } return cell; } diff --git a/src/main/java/org/fxmisc/flowless/IndexedMappedList.java b/src/main/java/org/fxmisc/flowless/IndexedMappedList.java new file mode 100644 index 0000000..90e5718 --- /dev/null +++ b/src/main/java/org/fxmisc/flowless/IndexedMappedList.java @@ -0,0 +1,68 @@ +package org.fxmisc.flowless; + +import java.util.List; +import java.util.function.BiFunction; + +import org.reactfx.Subscription; +import org.reactfx.collection.LiveList; +import org.reactfx.collection.LiveListBase; +import org.reactfx.collection.QuasiListChange; +import org.reactfx.collection.QuasiListModification; +import org.reactfx.collection.UnmodifiableByDefaultLiveList; +import org.reactfx.util.Lists; + +import javafx.collections.ObservableList; + +public class IndexedMappedList extends LiveListBase implements UnmodifiableByDefaultLiveList +{ + private final ObservableList source; + private final BiFunction mapper; + + public IndexedMappedList(ObservableList source, + BiFunction mapper) { + this.source = source; + this.mapper = mapper; + } + + @Override + public F get(int index) { + return mapper.apply(index, source.get(index)); + } + + @Override + public int size() { + return source.size(); + } + + @Override + protected Subscription observeInputs() { + return LiveList.observeQuasiChanges(source, this::sourceChanged); + } + + protected void sourceChanged(QuasiListChange change) { + notifyObservers(mappedChangeView(change)); + } + + private QuasiListChange mappedChangeView(QuasiListChange change) { + return () -> { + List> mods = change.getModifications(); + return Lists., QuasiListModification>mappedView(mods, mod -> new QuasiListModification<>() { + + @Override + public int getFrom() { + return mod.getFrom(); + } + + @Override + public int getAddedSize() { + return mod.getAddedSize(); + } + + @Override + public List getRemoved() { + return Lists.mappedView(mod.getRemoved(), elem -> mapper.apply(mod.getFrom(), elem)); + } + }); + }; + } +} diff --git a/src/main/java/org/fxmisc/flowless/VirtualFlow.java b/src/main/java/org/fxmisc/flowless/VirtualFlow.java index 3de50c9..8f6e3dc 100644 --- a/src/main/java/org/fxmisc/flowless/VirtualFlow.java +++ b/src/main/java/org/fxmisc/flowless/VirtualFlow.java @@ -4,6 +4,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.function.BiFunction; import java.util.function.Function; import javafx.beans.property.ObjectProperty; @@ -94,7 +95,17 @@ public static enum Gravity { ObservableList items, Function cellFactory, Gravity gravity) { - return new VirtualFlow<>(items, cellFactory, new HorizontalHelper(), gravity); + return new VirtualFlow<>(items, (ndx,item) -> cellFactory.apply(item), new HorizontalHelper(), gravity); + } + + /** + * Creates a viewport that lays out content horizontally from left to right + * but with a cellFactory that also receives the item index. + */ + public static > VirtualFlow createHorizontal( + ObservableList items, + BiFunction cellFactory) { + return new VirtualFlow<>(items, cellFactory, new HorizontalHelper(), Gravity.FRONT); } /** @@ -113,7 +124,17 @@ public static enum Gravity { ObservableList items, Function cellFactory, Gravity gravity) { - return new VirtualFlow<>(items, cellFactory, new VerticalHelper(), gravity); + return new VirtualFlow<>(items, (ndx,item) -> cellFactory.apply(item), new VerticalHelper(), gravity); + } + + /** + * Creates a viewport that lays out content vertically from top to bottom + * but with a cellFactory that also receives the item index. + */ + public static > VirtualFlow createVertical( + ObservableList items, + BiFunction cellFactory) { + return new VirtualFlow<>(items, cellFactory, new VerticalHelper(), Gravity.FRONT); } private final ObservableList items; @@ -162,13 +183,13 @@ public Var lengthOffsetEstimateProperty() { private VirtualFlow( ObservableList items, - Function cellFactory, + BiFunction cellFactory, OrientationHelper orientation, Gravity gravity) { this.getStyleClass().add("virtual-flow"); this.items = items; this.orientation = orientation; - this.cellListManager = new CellListManager<>(this, items, cellFactory); + this.cellListManager = new CellListManager(this, items, cellFactory); this.gravity.set(gravity); MemoizationList cells = cellListManager.getLazyCellList(); this.sizeTracker = new SizeTracker(orientation, layoutBoundsProperty(), cells); diff --git a/src/test/java/org/fxmisc/flowless/CellCreationWithIndexTest.java b/src/test/java/org/fxmisc/flowless/CellCreationWithIndexTest.java new file mode 100644 index 0000000..157e098 --- /dev/null +++ b/src/test/java/org/fxmisc/flowless/CellCreationWithIndexTest.java @@ -0,0 +1,86 @@ +package org.fxmisc.flowless; + +import static org.junit.Assert.assertEquals; + +import org.junit.Before; +import org.junit.Test; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +public class CellCreationWithIndexTest extends FlowlessTestBase { + + private ObservableList items; + private Counter cellCreations = new Counter(); + private VirtualFlow flow; + + @Override + public void start(Stage stage) { + // set up items + items = FXCollections.observableArrayList(); + for(int i = 0; i < 20; ++i) { + items.addAll("red", "green", "blue", "purple"); + } + + // set up virtual flow + flow = VirtualFlow.createVertical( + items, + (index, color) -> { + cellCreations.inc(); + Region reg = new Label( " "+ index +"\t"+ color ); + reg.setPrefHeight(16.0); + reg.setStyle("-fx-background-color: " + color); + return Cell.wrapNode(reg); + }); + + StackPane stackPane = new StackPane(); + // 25 cells (each 16px high) fit into the viewport + stackPane.getChildren().add(flow); + stage.setScene(new Scene(stackPane, 200, 400)); + stage.show(); + } + + @Before + public void setup() { + cellCreations.reset(); + } + + @Test + public void updating_an_item_in_viewport_only_creates_cell_once() { + // update an item in the viewport + interact(() -> items.set(10, "yellow")); + assertEquals(1, cellCreations.getAndReset()); + assertEquals("10", getCellText(10)); + } + + private String getCellText(int index) { + return ((Label) flow.getCell(index).getNode()).getText().substring(2,4); + } + + @Test + public void updating_an_item_outside_viewport_does_not_create_cell() { + // update an item outside the viewport + interact(() -> items.set(30, "yellow")); + assertEquals(0, cellCreations.getAndReset()); + } + + @Test + public void deleting_an_item_in_viewport_only_creates_cell_once() { + // delete an item in the middle of the viewport + interact(() -> items.remove(12)); + assertEquals(1, cellCreations.getAndReset()); + } + + @Test + public void adding_an_item_in_viewport_only_creates_cell_once() { + // add an item in the middle of the viewport + interact(() -> items.add(12, "yellow")); + assertEquals(1, cellCreations.getAndReset()); + assertEquals("12", getCellText(12)); + } +} \ No newline at end of file diff --git a/src/test/java/org/fxmisc/flowless/IndexedMappedListTest.java b/src/test/java/org/fxmisc/flowless/IndexedMappedListTest.java new file mode 100644 index 0000000..2f58319 --- /dev/null +++ b/src/test/java/org/fxmisc/flowless/IndexedMappedListTest.java @@ -0,0 +1,83 @@ +package org.fxmisc.flowless; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; +import org.reactfx.collection.ListModification; +import org.reactfx.collection.LiveList; + +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +public class IndexedMappedListTest +{ + @Test + public void testIndexedList() { + ObservableList strings = FXCollections.observableArrayList("1", "22", "333"); + // Live map receives index,item and returns %d-%d index item + LiveList lengths = new IndexedMappedList<>(strings, (index, item) -> String.format("%d-%d", index, item.length())); + assertArrayEquals(new String[] {"0-1", "1-2", "2-3"}, lengths.stream().toArray()); + + List removed = new ArrayList<>(); + List added = new ArrayList<>(); + lengths.observeChanges(ch -> { + for(ListModification mod: ch.getModifications()) { + removed.addAll(mod.getRemoved()); + added.addAll(mod.getAddedSubList()); + } + }); + + // Set a value in the list and check changes + strings.set(1, "4444"); + assertArrayEquals(new String[] {"0-1", "1-4", "2-3"}, lengths.stream().toArray()); + assertEquals(Collections.singletonList("1-4"), added); + assertEquals(Collections.singletonList("1-2"), removed); + + // Add an entry to the list and check changes + strings.add("7777777"); + assertArrayEquals(new String[] {"0-1", "1-4", "2-3", "3-7"}, lengths.stream().toArray()); + assertEquals(Arrays.asList("1-4", "3-7"), added); + assertEquals(Collections.singletonList("1-2"), removed); + + // Remove an entry to the list and check changes (note that 3-7 becomes 2-7) + strings.remove(2); + assertArrayEquals(new String[] {"0-1", "1-4", "2-7"}, lengths.stream().toArray()); + assertEquals(Arrays.asList("1-4", "3-7"), added); + assertEquals(Arrays.asList("1-2", "2-3"), removed); + } + + @Test + public void testLazyIndexedList() { + ObservableList strings = FXCollections.observableArrayList("1", "22", "333"); + IntegerProperty evaluationsCounter = new SimpleIntegerProperty(0); + LiveList lengths = new IndexedMappedList<>(strings, (index, elem) -> { + evaluationsCounter.set(evaluationsCounter.get() + 1); + return String.format("%d-%d", index, elem.length()); + }); + + lengths.observeChanges(ch -> {}); + strings.remove(1); + + assertEquals(0, evaluationsCounter.get()); + + // Get the first element and the counter has increased + assertEquals("0-1", lengths.get(0)); + assertEquals(1, evaluationsCounter.get()); + + // Get the second element, it will evaluate one item + assertEquals("1-3", lengths.get(1)); + assertEquals(2, evaluationsCounter.get()); + + // Get again the first, it will reevaluate it + assertEquals("0-1", lengths.get(0)); + assertEquals(3, evaluationsCounter.get()); + } +}