io.github.palexdev.virtualizedfx.table.TableHelper Maven / Gradle / Ivy
Show all versions of virtualizedfx Show documentation
/*
* Copyright (C) 2022 Parisi Alessandro
* This file is part of VirtualizedFX (https://github.com/palexdev/VirtualizedFX).
*
* VirtualizedFX is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* VirtualizedFX is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with VirtualizedFX. If not, see .
*/
package io.github.palexdev.virtualizedfx.table;
import io.github.palexdev.mfxcore.base.beans.Position;
import io.github.palexdev.mfxcore.base.beans.Size;
import io.github.palexdev.mfxcore.base.beans.range.IntegerRange;
import io.github.palexdev.mfxcore.base.properties.SizeProperty;
import io.github.palexdev.mfxcore.utils.NumberUtils;
import io.github.palexdev.mfxcore.utils.fx.LayoutUtils;
import io.github.palexdev.virtualizedfx.cell.TableCell;
import io.github.palexdev.virtualizedfx.enums.ColumnsLayoutMode;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.ObservableList;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.layout.Region;
import java.util.*;
import java.util.function.DoubleUnaryOperator;
import java.util.stream.Collectors;
import java.util.stream.DoubleStream;
/**
* The {@code TableHelper} is a utility interface which collects a series of common computations/operations used by
* {@link VirtualTable} and its subcomponents to manage the viewport. Has two concrete implementations: {@link FixedTableHelper}
* and {@link VariableTableHelper}.
*/
public interface TableHelper {
/**
* @return the first visible row index
*/
int firstRow();
/**
* @return the first visible column index
*/
int firstColumn();
/**
* @return the last visible row index
*/
int lastRow();
/**
* @return the last visible column index
*/
int lastColumn();
/**
* @return the number of rows the viewport can display at any time
*/
int maxRows();
/**
* @return the number of columns the viewport can display at any time
*/
int maxColumns();
/**
* @return the range of visible rows
*/
IntegerRange rowsRange();
/**
* @return the range of visible columns
*/
IntegerRange columnsRange();
/**
* @return the maximum amount of pixels the viewport can scroll vertically
*/
double maxVScroll();
/**
* @return the maximum amount of pixels the viewport can scroll horizontally
*/
double maxHScroll();
/**
* @return the total virtual size of the viewport
*/
Size computeEstimatedSize();
/**
* Keeps the results of {@link #computeEstimatedSize()}.
*/
ReadOnlyObjectProperty estimatedSizeProperty();
/**
* The table is a particular virtualized control because the actual viewport height is not the entire
* height of the table. The container for the columns is technically not part of the viewport, so it must be subtracted.
*/
double getViewportHeight();
/**
* This binding holds the horizontal position of the viewport.
*/
DoubleBinding xPosBinding();
/**
* Specifies a certain amount by which shift the horizontal position of columns and
* rows. Behavior may differ between implementations and depends also on {@link #layout()}
*/
double horizontalOffset();
/**
* This binding holds the vertical position of the viewport.
*/
DoubleBinding yPosBinding();
/**
* Specifies a certain amount by which shift the vertical position of rows.
* Behavior may differ between implementations and depends also on {@link #layout()}
*/
double verticalOffset();
/**
* Invalidates {@link VirtualTable#positionProperty()} in case changes
* occur in the viewport and the current position is no longer valid.
*
* @return true or false if the old position was invalid or not
*/
boolean invalidatedPos();
/**
* Scrolls the viewport to the given row index.
*/
void scrollToRow(int index);
/**
* Scrolls the viewport to the given column index.
*/
void scrollToColumn(int index);
/**
* Scrolls the viewport by the given amount of pixels.
* The direction is determined by the "orientation" parameter.
*/
void scrollBy(double pixels, Orientation orientation);
/**
* Scrolls the viewport to the given pixel value.
* The direction is determined by the "orientation" parameter.
*/
void scrollTo(double pixel, Orientation orientation);
/**
* Attempts at auto-sizing the given column to fit its content.
*/
void autosizeColumn(TableColumn, ? extends TableCell>> column);
/**
* Attempts at auto-sizing all the columns.
*/
void autosizeColumns();
/**
* Given a certain state, computes the positions of each column/row/cell as a map.
* The key is an {@link Orientation} value to differentiate between the vertical and horizontal positions.
*
* Most of the time this computation is not needed and the old positions can be reused, but if for whatever reason
* the computation is believed to be necessary then it's possible to force it by setting the desired parameter flags as true.
*
* @param forceXComputation forces the computation of the HORIZONTAL positions even if not needed
* @param forceYComputation forces the computation of the VERTICAL positions even if not needed
*/
Map> computePositions(TableState> state, boolean forceXComputation, boolean forceYComputation);
/**
* Entirely responsible for laying out columns/rows/cells.
*/
void layout();
/**
* Disposes bindings/listeners that are not required anymore.
*/
void dispose();
/**
* Abstract implementation of {@link TableHelper}, and base class for {@link FixedTableHelper}.
*/
abstract class AbstractHelper implements TableHelper {
protected final VirtualTable> table;
protected final TableManager> manager;
protected ChangeListener super Number> widthListener;
protected ChangeListener super Number> heightListener;
protected ChangeListener super Position> positionListener;
protected final SizeProperty estimatedSize = new SizeProperty(Size.of(0, 0));
protected DoubleBinding xPosBinding;
protected DoubleBinding yPosBinding;
public AbstractHelper(VirtualTable> table) {
this.table = table;
this.manager = table.getViewportManager();
widthListener = (o, ov, nv) -> onWidthChanged(ov, nv);
heightListener = (o, ov, nv) -> onHeightChanged(ov, nv);
positionListener = (o, ov, nv) -> onPositionChanged(ov, nv);
table.widthProperty().addListener(widthListener);
table.heightProperty().addListener(heightListener);
table.positionProperty().addListener(positionListener);
((SizeProperty) table.estimatedSizeProperty()).bind(estimatedSize);
}
/**
* Executed when the table's width changes. Re-initializes the viewport with {@link TableManager#init()}
*/
protected void onWidthChanged(Number ov, Number nv) {
double val = nv.doubleValue();
if (val > 0 && table.getHeight() > 0)
manager.init();
}
/**
* Executed when the table's height changes. Re-initializes the viewport with {@link TableManager#init()}.
*/
protected void onHeightChanged(Number ov, Number nv) {
double val = nv.doubleValue();
if (val > 0 && table.getWidth() > 0)
manager.init();
}
/**
* Executed when the {@link VirtualTable#positionProperty()} changes, responsible for invoking
* {@link TableManager#onHScroll()} (if x pos changed( and {@link TableManager#onVScroll()} (if y pos changed)
*/
protected void onPositionChanged(Position ov, Position nv) {
if (manager.isProcessingChange())
return;
if (ov.getX() != nv.getX()) {
manager.onHScroll();
}
if (ov.getY() != nv.getY()) {
manager.onVScroll();
}
}
public double getViewportHeight() {
double columnsHeight = table.getColumnSize().getHeight();
double tableHeight = table.getHeight();
return Math.max(0, tableHeight - columnsHeight);
}
/**
* {@inheritDoc}
*
* The {@link VirtualTable} always renders one extra column as overscan/buffer. This approach has a little
* issue for how scrolling works. When the end of the viewport is reached (horizontally) the extra column
* ends up overflowing the table, it's positioned outside of it.
*
* To make the layout work correctly this method returns an offset of {@code -table.getColumnSize().getWidth()}
* to ensure all columns are correctly visualized. Returns 0 if the extra offset is not needed.
*/
@Override
public double horizontalOffset() {
int columnsNum = table.getColumns().size();
int maxColumns = maxColumns();
int firstColumn = firstColumn();
int lastColumn = firstColumn + maxColumns - 1;
return (lastColumn > columnsNum - 1 && table.getState().columnsFilled()) ? -table.getColumnSize().getWidth() : 0;
}
/**
* {@inheritDoc}
*
* The {@link VirtualTable} always renders one extra row as overscan/buffer. This approach has a little
* issue for how scrolling works. When the end of the viewport is reached (vertically) the extra row
* ends up overflowing the table, it's positioned outside of it.
*
* To make the layout work correctly this method returns an offset of {@code -table.getCellHeight()}
* to ensure all rows are correctly visualized. Returns 0 if the extra offset is not needed.
*/
@Override
public double verticalOffset() {
int rowsNum = table.getItems().size();
int maxRows = maxRows();
int firstRow = firstRow();
int lastRow = firstRow + maxRows - 1;
return (lastRow > rowsNum - 1 && table.getState().rowsFilled()) ? -table.getCellHeight() : 0;
}
@Override
public boolean invalidatedPos() {
double eWidth = estimatedSize.getWidth();
double eHeight = estimatedSize.getHeight();
double x = Math.min(eWidth, table.getHPos());
double y = Math.min(eHeight, table.getVPos());
Position pos = Position.of(x, y);
boolean invalid = !table.getPosition().equals(pos);
table.setPosition(pos);
return invalid;
}
@Override
public ReadOnlyObjectProperty estimatedSizeProperty() {
return estimatedSize.getReadOnlyProperty();
}
@Override
public void dispose() {
table.widthProperty().removeListener(widthListener);
table.heightProperty().removeListener(heightListener);
table.positionProperty().removeListener(positionListener);
widthListener = null;
heightListener = null;
positionListener = null;
}
}
/**
* Concrete implementation of {@link AbstractHelper} made to work specifically
* with {@link ColumnsLayoutMode#FIXED}.
*
* As you can guess, this is the most efficient and fast helper since many computations are simplified by the
* fact that we know exactly the size of any column at any time.
*
* To be honest, this mode and helper has been developed for those who have tabular data with a lot and I mean a lot
* of columns... or maybe you just find fixed columns more attractive?
*/
class FixedTableHelper extends AbstractHelper {
protected final Map> positions = new HashMap<>();
public FixedTableHelper(VirtualTable> table) {
super(table);
}
@Override
public int firstRow() {
return NumberUtils.clamp(
(int) Math.floor(table.getVPos() / table.getCellHeight()),
0,
table.getItems().size() - 1
);
}
@Override
public int lastRow() {
return NumberUtils.clamp(
firstRow() + maxRows() - 1,
0,
table.getItems().size() - 1
);
}
@Override
public int firstColumn() {
return NumberUtils.clamp(
(int) Math.floor(table.getHPos() / table.getColumnSize().getWidth()),
0,
table.getColumns().size() - 1
);
}
@Override
public int lastColumn() {
return NumberUtils.clamp(
firstColumn() + maxColumns() - 1,
0,
table.getColumns().size() - 1
);
}
@Override
public int maxRows() {
return (int) (Math.ceil(getViewportHeight() / table.getCellHeight()) + 1);
}
@Override
public int maxColumns() {
return (int) (Math.ceil(table.getWidth() / table.getColumnSize().getWidth()) + 1);
}
/**
* {@inheritDoc}
*
* Note that the result given by {@link #maxRows()} to compute the index of the first row,
* is clamped so that it will never be greater then the number of items in the data structure.
*/
@Override
public IntegerRange rowsRange() {
int rNum = Math.min(maxRows(), table.getItems().size());
int last = lastRow();
int first = Math.max(0, last - rNum + 1);
return IntegerRange.of(first, last);
}
/**
* {@inheritDoc}
*
* Note that the result given by {@link #maxColumns()} to compute the index of the first column,
* is clamped so that it will never be greater then the number of columns in the table.
*/
@Override
public IntegerRange columnsRange() {
int cNum = Math.min(maxColumns(), table.getColumns().size());
int last = lastColumn();
int first = Math.max(0, last - cNum + 1);
return IntegerRange.of(first, last);
}
/**
* {@inheritDoc}
*
* The value is given by: {@cde estimatedSize.getHeight() - getViewportHeight()}
*/
@Override
public double maxVScroll() {
return estimatedSize.getHeight() - getViewportHeight();
}
/**
* {@inheritDoc}
*
* The value is given by: {@cde estimatedSize.getWidth() - table.getWidth()}
*/
@Override
public double maxHScroll() {
return estimatedSize.getWidth() - table.getWidth();
}
@Override
public Size computeEstimatedSize() {
double cellHeight = table.getCellHeight();
double columnWidth = table.getColumnSize().getWidth();
double length = table.getItems().size() * cellHeight;
double breadth = table.getColumns().size() * columnWidth;
Size size = Size.of(breadth, length);
estimatedSize.set(size);
return size;
}
/**
* {@inheritDoc}
*
* This is the direction along the estimated breath. However, the implementation
* makes it so that the position of the viewport is virtual. This binding which depends on both {@link VirtualTable#positionProperty()}
* and {@link VirtualTable#columnSizeProperty()} will always return a value that is greater or equal to 0 and lesser
* than the columns width. (the value is made negative as this is how scrolling works)
*
* This is the formula: {@code -table.getHPos() % table.getColumnSize().getWidth()}.
*
* Think about this. We have columns of width 64. and we scroll 15px on each gesture. When we reach 60px, we can still
* see the column for 4px, but once we scroll again it makes no sense to go to 75px because the first column won't be
* visible anymore, so we go back at 11px. Now the other column will be visible for 53px.
*
* Long story short, scrolling is just an illusion, the viewport just scroll by a little to give this illusion and
* when needed the columns are just repositioned. This is important because the estimated length
* could, in theory, reach very high values, so we don't want the viewport to scroll by thousands of pixels.
*/
@Override
public DoubleBinding xPosBinding() {
if (xPosBinding == null) {
xPosBinding = Bindings.createDoubleBinding(
() -> -table.getHPos() % table.getColumnSize().getWidth(),
table.positionProperty(), table.columnSizeProperty()
);
}
return xPosBinding;
}
/**
* {@inheritDoc}
*
* This is the direction along the estimated length. However, the implementation
* makes it so that the position of the viewport is virtual. This binding which depends on both {@link VirtualTable#positionProperty()}
* and {@link VirtualTable#cellHeightProperty()} will always return a value that is greater or equal to 0 and lesser
* than the cell height. (the value is made negative as this is how scrolling works)
*
* This is the formula: {@code -table.getVPos() % table.getCellHeight()}.
*
* Think about this. We have cells of width 64. and we scroll 15px on each gesture. When we reach 60px, we can still
* see the cell for 4px, but once we scroll again it makes no sense to go to 75px because the first cell won't be
* visible anymore, so we go back at 11px. Now the other cell will be visible for 53px.
*
* Long story short, scrolling is just an illusion, the viewport just scroll by a little to give this illusion and
* when needed the cells are just repositioned. This is important because the estimated length
* could, in theory, reach very high values, so we don't want the viewport to scroll by thousands of pixels.
*/
@Override
public DoubleBinding yPosBinding() {
if (yPosBinding == null) {
yPosBinding = Bindings.createDoubleBinding(
() -> -table.getVPos() % table.getCellHeight(),
table.positionProperty(), table.cellHeightProperty()
);
}
return yPosBinding;
}
@Override
public void scrollToRow(int index) {
double val = index * table.getCellHeight();
double clampedVal = NumberUtils.clamp(val, 0, maxVScroll());
table.setVPos(clampedVal);
}
@Override
public void scrollToColumn(int index) {
double val = index * table.getColumnSize().getWidth();
double clampedVal = NumberUtils.clamp(val, 0, maxHScroll());
table.setHPos(clampedVal);
}
@Override
public void scrollBy(double pixels, Orientation orientation) {
if (orientation == Orientation.VERTICAL) {
double newVal = NumberUtils.clamp(table.getVPos() + pixels, 0, maxVScroll());
table.setVPos(newVal);
} else {
double newVal = NumberUtils.clamp(table.getHPos() + pixels, 0, maxHScroll());
table.setHPos(newVal);
}
}
@Override
public void scrollTo(double pixel, Orientation orientation) {
if (orientation == Orientation.VERTICAL) {
double clampedVal = NumberUtils.clamp(pixel, 0, maxVScroll());
table.setVPos(clampedVal);
} else {
double clampedVal = NumberUtils.clamp(pixel, 0, maxHScroll());
table.setHPos(clampedVal);
}
}
/**
* @throws UnsupportedOperationException {@link ColumnsLayoutMode#FIXED} doesn't allow columns to be resized.
*/
@Override
public void autosizeColumn(TableColumn, ? extends TableCell>> column) {
throw new UnsupportedOperationException("Fixed Layout Mode for columns doesn't support columns auto-sizing");
}
/**
* @throws UnsupportedOperationException {@link ColumnsLayoutMode#FIXED} doesn't allow columns to be resized.
*/
@Override
public void autosizeColumns() {
throw new UnsupportedOperationException("Fixed Layout Mode for columns doesn't support columns auto-sizing");
}
/**
* {@inheritDoc}
*
* X Positions Computation
*
* The horizontal positions are computed by using a {@link DoubleStream#iterate(double, DoubleUnaryOperator)}
* with {@code columnsNum * columnsW} as the seed and {@code x -> x - columnW} as the operator.
* The stream is also limited, {@link DoubleStream#limit(long)}, to the number of columns we need, the results
* are collected in a list and put in the positions map with {@link Orientation#HORIZONTAL} as the key.
*
* Horizontal positions are not computed unless at least one of these conditions is true:
*
- forceXComputation flag is true
*
- the positions have not been computed before
*
- the number of positions previously computed is not equal to the number of columns we need
*
* Y Positions Computation
*
* The vertical positions are computed by using a {@link DoubleStream#iterate(double, DoubleUnaryOperator)}
* with {@code rowsNum * cellHeight} as the seed and {@code x -> x - cellHeight} as the operator.
* The stream is also limited, {@link DoubleStream#limit(long)}, to the number of rows we need, the results
* are stored in a list and put in the positions map with {@link Orientation#VERTICAL} as the key.
*
* Vertical positions are not computed unless at least one of these conditions is true:
*
- forceYComputation flag is true
*
- the positions have not been computed before
*
- the number of positions previously computed is not equal to the number of rows we need
*/
@Override
public Map> computePositions(TableState> state, boolean forceXComputation, boolean forceYComputation) {
IntegerRange rowsRange = state.getRowsRange();
IntegerRange columnsRange = state.getColumnsRange();
double colW = table.getColumnSize().getWidth();
double cellH = table.getCellHeight();
List xPositions = positions.computeIfAbsent(Orientation.HORIZONTAL, o -> new ArrayList<>());
Integer cRangeDiff = columnsRange.diff();
if (forceXComputation || xPositions.isEmpty() || xPositions.size() != cRangeDiff + 1) {
xPositions.clear();
xPositions.addAll(DoubleStream.iterate(cRangeDiff * colW, x -> x - colW)
.limit(cRangeDiff + 1)
.boxed()
.collect(Collectors.toList())
);
}
List yPositions = positions.computeIfAbsent(Orientation.VERTICAL, o -> new ArrayList<>());
Integer rRangeDiff = rowsRange.diff();
if (forceYComputation || yPositions.isEmpty() || yPositions.size() != rRangeDiff + 1) {
yPositions.clear();
yPositions.addAll(DoubleStream.iterate(rRangeDiff * cellH, x -> x - cellH)
.limit(rRangeDiff + 1)
.boxed()
.collect(Collectors.toList())
);
}
return positions;
}
/**
* {@inheritDoc}
*
* The layout makes use of the current table' state, {@link VirtualTable#stateProperty()}, and the positions
* computed by {@link #computePositions(TableState, boolean, boolean)}, this is invoked without forcing the re-computation.
*
* Exits immediately if the state is {@link TableState#EMPTY}, if {@link #invalidatedPos()} returns true,
* or if {@link VirtualTable#needsViewportLayoutProperty()} is false.
*
* Before proceeding with layout retrieves the following parameters:
* - the columns width
*
- the columns height
*
- the columns range
*
- the X positions
*
- the X offset with {@link #horizontalOffset()}
*
- the cells height
*
- the Y positions
*
- the Y offset with {@link #verticalOffset()}
*
* Columns are laid out from left to right, relocated at the extracted X position (+ the X offset) and at Y 0;
* and resized with the previously gathered sizes. The last column is an exception because if not all the space
* of the table was occupied by laying out the previous columns than it width will be set to the entire remaining
* space.
*
* Rows are laid out from top to bottom, relocated at the previously computed X offset and at the extracted Y position
* (+ the Y offset); and resized with the previously gathered height. The width is given by the maximum between
* the table width and the row size (given by the number of cells in the row multiplied by the columns width)
*
* For each row in the loop it also lays out the their cells. Each cell is relocated at the extracted X position
* and at Y 0; and resized to the previously gathered cell height. The width is the same of the corresponding column.
*/
@Override
public void layout() {
TableState> state = table.getState();
if (state == TableState.EMPTY) return;
if (!table.isNeedsViewportLayout()) return;
if (invalidatedPos()) return;
Map> positions = computePositions(state, false, false);
double colW = table.getColumnSize().getWidth();
double colH = table.getColumnSize().getHeight();
IntegerRange columnsRange = state.getColumnsRange();
double xOffset = horizontalOffset();
List xPositions = positions.get(Orientation.HORIZONTAL);
int xI = xPositions.size() - 1;
double totalW = 0.0;
for (Integer cIndex : columnsRange) {
totalW += colW;
TableColumn, ? extends TableCell>> column = table.getColumn(cIndex);
Region region = column.getRegion();
Double xPos = xPositions.get(xI);
if (cIndex.equals(columnsRange.getMax()) && totalW < table.getWidth()) {
region.resizeRelocate(xPos + xOffset, 0, table.getWidth() - totalW + colW, colH);
} else {
region.resizeRelocate(xPos + xOffset, 0, colW, colH);
}
xI--;
}
double cellH = table.getCellHeight();
double yOffset = verticalOffset();
List yPositions = positions.get(Orientation.VERTICAL);
int yI = yPositions.size() - 1;
for (TableRow> row : state.getRows().values()) {
xI = xPositions.size() - 1;
Double yPos = yPositions.get(yI);
double rowW = Math.max(row.size() * colW, table.getWidth());
row.resizeRelocate(xOffset, yPos + yOffset, rowW, cellH);
List> cells = new ArrayList<>(row.getCells().values());
for (int i = 0; i < cells.size(); i++) {
TableCell> cell = cells.get(i);
TableColumn, ? extends TableCell>> column = table.getColumn(i);
Node node = cell.getNode();
cell.beforeLayout();
node.resizeRelocate(xPositions.get(xI), 0, column.getRegion().getWidth(), cellH);
cell.afterLayout();
xI--;
}
yI--;
}
}
@Override
public void dispose() {
super.dispose();
if (xPosBinding != null) xPosBinding.dispose();
if (yPosBinding != null) yPosBinding.dispose();
xPosBinding = null;
yPosBinding = null;
}
}
/**
* Extension of {@link FixedTableHelper} made to work specifically
* with {@link ColumnsLayoutMode#VARIABLE}.
*
* Many of the methods of the other mode can be reused for this, just a few need their behavior to be redefined.
*
* As you can guess, this is not the most efficient. All columns are laid out in the viewport which directly translates
* to "there are more cells in the viewport". You can also consider this mode as "partially virtualized", rows will still
* be virtualized but since columns widths are variable we have no way to easily and consistently virtualize them too.
*
* The advantage of this mode is of course having columns that can be resized programmatically or by a gesture at runtime.
*/
class VariableTableHelper extends FixedTableHelper {
public VariableTableHelper(VirtualTable> table) {
super(table);
}
/**
* @return 0 or -1 if the columns list is empty
*/
@Override
public int firstColumn() {
return Math.min(0, table.getColumns().size() - 1);
}
/**
* @return the columns list size -1
*/
@Override
public int lastColumn() {
return table.getColumns().size() - 1;
}
/**
* @return the size of the columns list
*/
@Override
public int maxColumns() {
return table.getColumns().size();
}
/**
* @return an {@link IntegerRange} made from the values of {@link #firstColumn()} and {@link #lastColumn()}
*/
@Override
public IntegerRange columnsRange() {
return IntegerRange.of(firstColumn(), lastColumn());
}
/**
* {@inheritDoc}
*
* The breadth is computed by iterating over all the columns and getting their width.
* To be precise the width used by the computation is given by the maximum between the actual width of the
* column's region and the size specified by {@link VirtualTable#columnSizeProperty()}.
*/
@Override
public Size computeEstimatedSize() {
double cellHeight = table.getCellHeight();
double length = table.getItems().size() * cellHeight;
double breadth = table.getColumns().stream()
.mapToDouble(c -> Math.max(c.getRegion().getWidth(), table.getColumnSize().getWidth()))
.sum();
Size size = Size.of(breadth, length);
estimatedSize.set(size);
return size;
}
/**
* This binding holds the horizontal position of the viewport.
* This is the direction along the estimated breath.
*
* This is not virtualized as columns have variable widths and the value is simply given by
* {@code -table.getHPos()}.
*/
@Override
public DoubleBinding xPosBinding() {
if (xPosBinding == null) {
xPosBinding = Bindings.createDoubleBinding(
() -> -table.getHPos(),
table.positionProperty()
);
}
return xPosBinding;
}
/**
* {@inheritDoc}
*
* This is actually more complicated for this layout mode as we can't always get the position at which a column
* will be in the viewport. In fact, if the table has not been laid out yet, meaning that its skin is null, or
* if the interested column has not been laid out yet there's no way to know where it is in the viewport.
*
* For this reason this distinguish between two cases:
*
1) Everything has been laid out at least once: the column position is given by
* {@code column.getRegion().getBoundsInParent().getMinX()}
*
2) We cannot rely on the aforementioned method, we resort on the "super" method.
* Since {@link VirtualTable} specifies the minimum width every column must have at the initialization we can
* easily predict were it will be at the start.
*/
@Override
public void scrollToColumn(int index) {
TableColumn, ? extends TableCell>> column = table.getColumn(index);
Region region = column.getRegion();
if (table.getSkin() == null ||
region.getScene() == null ||
region.getWidth() <= 0) {
// If any of these conditions happen it probably means that this method
// has been called before the first layout (as with variable mode all
// columns are laid out always). This means that we can use the specified
// width (see property in table) as a guarantee that all columns will have
// the same initial width and as a consequence use the "super" method
super.scrollToColumn(index);
}
double minX = region.getBoundsInParent().getMinX();
double clampedVal = NumberUtils.clamp(minX, 0, maxHScroll());
table.setHPos(clampedVal);
}
/**
* Attempts at auto-sizing the given column to fit its content.
*
* To accomplish this we need the current state of the table, {@link VirtualTable#stateProperty()},
* and the index of the given column, {@link VirtualTable#getColumnIndex(TableColumn)}.
* (if the state is {@link TableState#EMPTY} exits immediately).
*
* We get the rows from the state and then use {@link TableRow#getWidthOf(int)} to get the preferred width
* of the cell at index (same index of column). From these results we get the maximum value and this will be
* the new width of the column.
*
* The last column is handled differently though. First we compute the total width of all the columns before.
* If this is lesser than the table width than the column will be resized to make it occupy all the available space.
* If this is not the case, then we fall back to the normal handling.
*/
@Override
public void autosizeColumn(TableColumn, ? extends TableCell>> column) {
TableState> state = table.getState();
if (state == TableState.EMPTY) return;
Region region = column.getRegion();
ObservableList extends TableColumn, ? extends TableCell>>> columns = table.getColumns();
int cIndex = table.getColumnIndex(((TableColumn) column));
double targetW;
// If it's last index, special handling to always use all the available remaining space
if (cIndex == columns.size() - 1) {
// First compute total width for all previous columns
double totalW = columns.stream()
.map(TableColumn::getRegion)
.mapToDouble(Region::getWidth)
.sum() - region.getWidth();
// If less than table width then targetW becomes `table.getWidth() - totalW + colW`
if (totalW < table.getWidth()) {
targetW = table.getWidth() - totalW + region.getWidth();
// Here we terminate the auto-sizing, if the condition is false
// then the other "method" is used
region.setPrefWidth(targetW);
computeEstimatedSize();
computePositions(state, true, false);
layout();
return;
}
}
Collection extends TableRow>> rows = state.getRows().values();
targetW = rows.stream()
.mapToDouble(r -> r.getWidthOf(cIndex))
.max()
.orElseGet(region::getWidth);
region.setPrefWidth(targetW);
computeEstimatedSize();
computePositions(state, true, false);
layout();
}
/**
* Calls {@link #autosizeColumn(TableColumn)} on all the columns in the table.
*/
@Override
public void autosizeColumns() {
table.getColumns().forEach(this::autosizeColumn);
}
/**
* Given a certain state, computes the positions of each column/row/cell as a map.
* The key is an {@link Orientation} value to differentiate between the vertical and horizontal positions.
*
* Most of the time this computation is not needed and the old positions can be reused, but if for whatever reason
* the computation is believed to be necessary then it's possible to force it by setting the desired parameter flags as true.
*
* X Positions Computation
*
* The horizontal positions are computed by iterating over all the columns and getting their width as the maximum
* between its current width and the minimum width specified by {@link VirtualTable#columnSizeProperty()}.
* The positions are actually computed with a simple accumulator.
*
* Horizontal positions are not computed unless at least one of these conditions is true:
*
- forceXComputation flag is true
*
- the positions have not been computed before
*
* Y Positions Computation
*
* The vertical positions are computed by using a {@link DoubleStream#iterate(double, DoubleUnaryOperator)}
* with {@code rowsNum * cellHeight} as the seed and {@code x -> x - cellHeight} as the operator.
* The stream is also limited, {@link DoubleStream#limit(long)}, to the number of rows we need, the results
* are stored in a list and put in the positions map with {@link Orientation#VERTICAL} as the key.
*
* Vertical positions are not computed unless at least one of these conditions is true:
*
- forceYComputation flag is true
*
- the positions have not been computed before
*
- the number of positions previously computed is not equal to the number of rows we need
*
* @param forceXComputation forces the computation of the HORIZONTAL positions even if not needed
* @param forceYComputation forces the computation of the VERTICAL positions even if not needed
*/
@Override
public Map> computePositions(TableState> state, boolean forceXComputation, boolean forceYComputation) {
if (state == TableState.EMPTY || state.isEmpty()) return positions;
IntegerRange rowsRange = state.getRowsRange();
IntegerRange columnsRange = state.getColumnsRange();
double cellH = table.getCellHeight();
List xPositions = positions.computeIfAbsent(Orientation.HORIZONTAL, o -> new ArrayList<>());
if (forceXComputation || xPositions.isEmpty()) {
xPositions.clear();
double pos = 0;
for (Integer cIndex : columnsRange) {
TableColumn, ? extends TableCell>> column = table.getColumn(cIndex);
Region region = column.getRegion();
xPositions.add(pos);
double colW = Math.max(LayoutUtils.boundWidth(region), table.getColumnSize().getWidth());
pos += colW;
}
}
List yPositions = positions.computeIfAbsent(Orientation.VERTICAL, o -> new ArrayList<>());
Integer rRangeDiff = rowsRange.diff();
if (forceYComputation || yPositions.isEmpty() || yPositions.size() != rRangeDiff + 1) {
yPositions.clear();
yPositions.addAll(DoubleStream.iterate(rRangeDiff * cellH, x -> x - cellH)
.limit(rRangeDiff + 1)
.boxed()
.collect(Collectors.toList())
);
}
return positions;
}
/**
* Entirely responsible for laying out columns/rows/cells.
*
* The layout makes use of the current table' state, {@link VirtualTable#stateProperty()}, and the positions
* computed by {@link #computePositions(TableState, boolean, boolean)}, this is invoked without forcing the re-computation.
*
* Exits immediately if the state is {@link TableState#EMPTY}, if {@link #invalidatedPos()} returns true,
* or if {@link VirtualTable#needsViewportLayoutProperty()} is false.
*
* Before proceeding with layout retrieves the following parameters:
* - the columns height
*
- the columns range
*
- the X positions
*
- the cells height
*
- the Y positions
*
- the Y offset with {@link #verticalOffset()}
*
* Columns are laid out from left to right, relocated at the extracted X position and at Y 0;
* and resized to the previously gathered height. The width is computed as the maximum between the column's region
* width and the minimum width specified by {@link VirtualTable#columnSizeProperty()}.
* The last column is an exception because if not all the space of the table was occupied by
* laying out the previous columns than its width will be set to the entire remaining
* space.
*
* Rows are laid out from top to bottom, relocated at X 0 and at the extracted Y position (+ the Y offset);
* and resized with the previously gathered height. The width is given by the maximum between
* the table width and the row size (the row's region width plus the minimum columns width given by {@link VirtualTable#columnSizeProperty()}).
*
* For each row in the loop it also lays out their cells. Each cell is relocated at the extracted X position
* and at Y 0; and resized to the previously gathered cell height. The width is the same of the corresponding column.
*/
@Override
public void layout() {
TableState> state = table.getState();
if (state == TableState.EMPTY) return;
if (!table.isNeedsViewportLayout()) return;
if (invalidatedPos()) return;
Map> positions = computePositions(state, false, false);
double colH = table.getColumnSize().getHeight();
IntegerRange columnsRange = state.getColumnsRange();
List xPositions = positions.get(Orientation.HORIZONTAL);
int xI = 0;
double totalW = 0.0;
for (Integer cIndex : columnsRange) {
TableColumn, ? extends TableCell>> column = table.getColumn(cIndex);
Region region = column.getRegion();
double colW = Math.max(LayoutUtils.boundWidth(region), table.getColumnSize().getWidth());
Double xPos = xPositions.get(xI);
totalW += colW;
if (cIndex.equals(columnsRange.getMax()) && totalW < table.getWidth()) {
region.resizeRelocate(xPos, 0, table.getWidth() - totalW + colW, colH);
} else {
region.resizeRelocate(xPos, 0, colW, colH);
}
xI++;
}
double cellH = table.getCellHeight();
double yOffset = verticalOffset();
List yPositions = positions.get(Orientation.VERTICAL);
int yI = yPositions.size() - 1;
for (TableRow> row : state.getRows().values()) {
xI = 0;
Double yPos = yPositions.get(yI);
double rowW = Math.max(LayoutUtils.boundWidth(row) + table.getColumnSize().getWidth(), table.getWidth());
row.resizeRelocate(0, yPos + yOffset, rowW, cellH);
List> cells = new ArrayList<>(row.getCells().values());
for (int i = 0; i < cells.size(); i++) {
TableCell> cell = cells.get(i);
TableColumn, ? extends TableCell>> column = table.getColumn(i);
Node node = cell.getNode();
cell.beforeLayout();
node.resizeRelocate(xPositions.get(xI), 0, column.getRegion().getWidth(), cellH);
cell.afterLayout();
xI++;
}
yI--;
}
}
}
}