io.github.palexdev.virtualizedfx.table.VFXTableSkin Maven / Gradle / Ivy
Show all versions of virtualizedfx Show documentation
/*
* Copyright (C) 2024 Parisi Alessandro - [email protected]
* 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.range.IntegerRange;
import io.github.palexdev.mfxcore.controls.SkinBase;
import io.github.palexdev.virtualizedfx.cells.base.VFXTableCell;
import io.github.palexdev.virtualizedfx.enums.ColumnsLayoutMode;
import io.github.palexdev.virtualizedfx.enums.GeometryChangeType;
import javafx.beans.InvalidationListener;
import javafx.beans.value.ChangeListener;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.geometry.Orientation;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Rectangle;
import static io.github.palexdev.mfxcore.observables.OnInvalidated.withListener;
import static io.github.palexdev.mfxcore.observables.When.onChanged;
import static io.github.palexdev.mfxcore.observables.When.onInvalidated;
/**
* Default skin implementation for {@link VFXTable}, extends {@link SkinBase} and expects behaviors of type
* {@link VFXTableManager}.
*
* The table is organized in columns, rows and cells. This architecture leads to more complex layout compared to other
* containers because it comprises many more nodes. The 'viewport' node wraps two {@link Pane}s:
*
1) one contains the table's columns, can be selected in CSS as '.columns'
*
2) the other contains the rows, can be selected in CSS as '.rows'
*
* The table's height and the viewport height are different here. The latter is given by the table's height minus
* the columns pane height, specified by {@link VFXTable#columnsSizeProperty()}. The rows are given by the
* {@link VFXTable#stateProperty()} and depends on the viewport's height. Each column produces one cell per row.
* Columns and cells are kept aligned by the layout methods defined in {@link VFXTableHelper}.
*
* Q: Why so many nodes?
*
* A: scrolling in a table is a bit peculiar because: vertical scrolling should affect only the rows,
* while horizontal scrolling should affect both rows and columns.
* For such reason, we also need two clip nodes to avoid content overflowing the table's bounds. One clip is set on the
* table itself and avoids columns overflow. The other is set on the rows container and avoids rows overflow on vertical
* scroll.
*
* As all skins typically do, this is also responsible for catching any change in the component's properties.
* The computation that leads to a new state is delegated to the controller/behavior, which is the {@link VFXTableManager}.
* Read this {@link #addListeners()} to check which changes are handled.
*
* Last but not least, by design, this skin makes the component always be at least 100px tall and wide. You can change this
* by overriding the {@link #DEFAULT_SIZE} variable.
*/
public class VFXTableSkin extends SkinBase, VFXTableManager> {
//================================================================================
// Properties
//================================================================================
private final Pane viewport;
private final Rectangle clip;
private final Pane cContainer;
private final Pane rContainer;
private final Rectangle rClip;
private ListChangeListener super VFXTableColumn> columnsListener;
protected double DEFAULT_SIZE = 100.0;
//================================================================================
// Constructors
//================================================================================
public VFXTableSkin(VFXTable table) {
super(table);
// Init containers
cContainer = new Pane() {
@Override
protected void layoutChildren() {
layoutColumns();
}
};
cContainer.visibleProperty().bind(table.columnsSizeProperty().map(s -> s.getHeight() > 0));
cContainer.getStyleClass().add("columns");
rContainer = new Pane() {
@Override
protected void layoutChildren() {
//layoutRows();
}
};
rContainer.getStyleClass().add("rows");
viewport = new Pane(cContainer, rContainer) {
@Override
protected void layoutChildren() {
VFXTableSkin.this.layout();
}
};
viewport.getStyleClass().add("viewport");
// Init clips
clip = new Rectangle();
clip.widthProperty().bind(table.widthProperty());
clip.heightProperty().bind(table.heightProperty());
clip.arcWidthProperty().bind(table.clipBorderRadiusProperty());
clip.arcHeightProperty().bind(table.clipBorderRadiusProperty());
table.setClip(clip);
rClip = new Rectangle();
rClip.widthProperty().bind(rContainer.widthProperty());
rClip.heightProperty().bind(rContainer.heightProperty());
rClip.translateYProperty().bind(rContainer.translateYProperty().multiply(-1));
rContainer.setClip(rClip);
// End initialization
addListeners();
getChildren().add(viewport);
}
//================================================================================
// Methods
//================================================================================
/**
* Adds listeners on the component's properties which need to produce a new {@link VFXTableState} upon changing.
*
* Here's the list:
*
- Listener on {@link VFXTable#getColumns()}, will invoke {@link VFXTableManager#onColumnsChanged(ListChangeListener.Change)}.
* The method is also invoked the first time here with {@code null} as parameter to ensure that columns are initialized
* for the first time.
*
- Listener on {@link VFXTable#stateProperty()}, this is crucial to update the columns and rows containers'
* children, invoke {@link VFXTable#requestViewportLayout()} if {@link VFXTableState#isLayoutNeeded()} is {@code true}.
* Skips everything if the current state was cloned, {@link VFXTableState#isClone()}
*
- Listener on {@link VFXTable#needsViewportLayoutProperty()}, this is crucial because invokes both
* {@link #layoutColumns()} and {@link #layoutRows()}. The layout is performed only if {@link ViewportLayoutRequest#isValid()}
* returns {@code true}. Also, if the request carries a specific column ({@link ViewportLayoutRequest#column()}),
* the layout will be computed only partially by invoking {@link #partialLayout()} instead.
*
- Listener on {@link VFXTable#helperProperty()}, this is crucial because it's responsible for binding the
* viewport's translateX and rows container's translateY properties to the {@link VFXTableHelper#viewportPositionProperty()}.
* By translating the viewport, we give the illusion of scrolling (virtual scrolling)
*
- Listener on {@link VFXTable#widthProperty()}, will invoke {@link VFXTableManager#onGeometryChanged(GeometryChangeType)}
*
- Listener on {@link VFXTable#heightProperty()}, will invoke {@link VFXTableManager#onGeometryChanged(GeometryChangeType)}
*
- Listener on {@link VFXTable#columnsBufferSizeProperty()}, will invoke {@link VFXTableManager#onGeometryChanged(GeometryChangeType)}
*
- Listener on {@link VFXTable#rowsBufferSizeProperty()}, will invoke {@link VFXTableManager#onGeometryChanged(GeometryChangeType)}
*
- Listener on {@link VFXTable#vPosProperty()}, will invoke {@link VFXTableManager#onPositionChanged(Orientation)}
*
- Listener on {@link VFXTable#hPosProperty()}, will invoke {@link VFXTableManager#onPositionChanged(Orientation)}
*
- Listener on {@link VFXTable#itemsProperty()}, will invoke {@link VFXTableManager#onItemsChanged()}
*
- Listener on {@link VFXTable#rowFactoryProperty()}, will invoke {@link VFXTableManager#onRowFactoryChanged()}
*
- Listener on {@link VFXTable#rowsHeightProperty()}, will invoke {@link VFXTableManager#onRowHeightChanged()}
*
- Listener on {@link VFXTable#columnsSizeProperty()}, will invoke {@link VFXTableManager#onColumnsSizeChanged()}
*
- Listener on {@link VFXTable#columnsLayoutModeProperty()}, will invoke {@link VFXTableManager#onColumnsLayoutModeChanged()}
*
* Note: in JavaFX there is no way to prioritize a listener over another, rather, the priority is given by
* which is added first (behind the scenes there must be a plain for loop running to call all the listeners).
* This causes a nasty bug regarding the table's width when using the {@link ColumnsLayoutMode#VARIABLE}. In that mode
* we cannot proceed with the {@link VFXTableManager#onGeometryChanged(GeometryChangeType)} method before the
* {@link ColumnsLayoutCache} is invalidated. A simple workaround for this, is to use a {@link ChangeListener}
* instead of a plain {@link InvalidationListener} for the {@link VFXTable#widthProperty()}, because the latter type
* will ALWAYS be invoked BEFORE the other listeners type. In my opinion, this mechanism is stupid and broken, bindings
* invalidation should ALWAYS happen before anything else!
*/
protected void addListeners() {
VFXTable table = getSkinnable();
// This needs to be a classical listener
columnsListener = getBehavior()::onColumnsChanged;
table.getColumns().addListener(columnsListener);
getBehavior().onColumnsChanged(null); // This is needed since the skin is created afterward.
InvalidationListener gcl = i -> getBehavior().onGeometryChanged(GeometryChangeType.OTHER);
listeners(
// Core changes
onInvalidated(table.stateProperty())
.then(s -> {
if (s.isClone()) return;
if (s == VFXTableState.INVALID) {
cContainer.getChildren().clear();
rContainer.getChildren().clear();
return;
}
if (s.isEmpty()) {
rContainer.getChildren().clear();
} else if (s.haveRowsChanged()) {
rContainer.getChildren().setAll(s.getRowsByIndex().values());
}
if (s.haveColumnsChanged()) {
cContainer.getChildren().setAll(
table.getColumns().subList(
s.getColumnsRange().getMin(),
s.getColumnsRange().getMax() + 1
)
);
}
if (s.isLayoutNeeded()) table.requestViewportLayout();
}),
onInvalidated(table.needsViewportLayoutProperty())
.condition(ViewportLayoutRequest::isValid)
.then(v -> {
if (v.isPartial()) {
partialLayout();
} else {
layoutColumns();
layoutRows();
}
}),
onInvalidated(table.helperProperty())
.then(h -> {
viewport.translateXProperty().bind(h.viewportPositionProperty().map(Position::getX));
rContainer.translateYProperty().bind(h.viewportPositionProperty().map(Position::getY));
})
.executeNow(),
// Geometry changes
/*
* BUG: unfortunately we must use a ChangeListener here because JavaFX is stupid.
* You see, for the VARIABLE_MODE layout, we rely on a cache to compute the columns' width only when needed.
* The last column is a special case because it's the only one for which the value becomes invalid if the
* table's width changes. Since JavaFX bindings use some sort of InvalidationListeners on the dependencies to
* invalidate the bindings itself, there's a huge pain in the ass problem: priority.
* Under the hood, these things are simple; there is a for loop somewhere that calls the listeners
* (or at least you can think at the mechanism like this), which means that if a listener is added before
* another one, it's executed first.
* This is a huge problem here, because we can't proceed with the onGeometryChanged() computation before the
* cache is invalidated.
* A simple workaround is to use ChangeListeners which are always invoked AFTER InvalidationListeners.
*
* In my opinion, this mechanism is stupid and broken, an InvalidationListener whose purpose is to invalidate
* a binding should ALWAYS be called BEFORE any other InvalidationListener
*/
onChanged(table.widthProperty())
.then((ow, nw) -> getBehavior().onGeometryChanged(GeometryChangeType.WIDTH)),
onInvalidated(table.heightProperty())
.then(h -> getBehavior().onGeometryChanged(GeometryChangeType.HEIGHT)),
withListener(table.columnsBufferSizeProperty(), gcl),
withListener(table.rowsBufferSizeProperty(), gcl),
// Position changes
onInvalidated(table.vPosProperty())
.then(v -> getBehavior().onPositionChanged(Orientation.VERTICAL)),
onInvalidated(table.hPosProperty())
.then(h -> getBehavior().onPositionChanged(Orientation.HORIZONTAL)),
// Others
onInvalidated(table.itemsProperty())
.then(it -> getBehavior().onItemsChanged()),
onInvalidated(table.rowFactoryProperty())
.then(rf -> getBehavior().onRowFactoryChanged()),
onInvalidated(table.rowsHeightProperty())
.then(h -> getBehavior().onRowHeightChanged()),
onInvalidated(table.columnsSizeProperty())
.then(s -> getBehavior().onColumnsSizeChanged()),
onInvalidated(table.columnsLayoutModeProperty())
.then(m -> getBehavior().onColumnsLayoutModeChanged())
);
}
/**
* This method redefines the viewport node layout. It's responsible for positioning and sizing both the
* columns and rows containers.
*
* @see #layoutColumns()
* @see #layoutRows()
* @see #partialLayout()
*/
protected void layout() {
VFXTable table = getSkinnable();
double w = table.getVirtualMaxX();
double h = table.getHeight();
double cH = table.getColumnsSize().getHeight();
double rH = h - cH;
cContainer.resizeRelocate(0, 0, w, cH);
rContainer.resizeRelocate(0, cH, w, rH);
}
/**
* This is responsible for sizing and positioning the columns specified by the current
* {@link VFXTableState#getColumnsRange()}.
*
* If the state is {@link VFXTableState#INVALID} exits immediately.
*
* The columns are actually laid out by using {@link VFXTableHelper#layoutColumn(int, VFXTableColumn)}.
* The layout index is given by an external 'i' counter which starts at 0 and is incremented at each loop iteration.
*
* This is also responsible for updating the {@link VFXTableColumn#indexProperty()} by calling
* {@link #updateColumnIndex(VFXTableColumn, int)}. Why here? Because this core method will ensure all columns will
* always have the correct index set.
*
* @see #layoutRows()
* @see #partialLayout()
*/
protected void layoutColumns() {
VFXTable table = getSkinnable();
VFXTableState state = table.getState();
if (state == VFXTableState.INVALID) return;
VFXTableHelper helper = table.getHelper();
IntegerRange columnsRange = state.getColumnsRange();
int i = 0;
ObservableList> columns = table.getColumns();
for (Integer idx : columnsRange) {
VFXTableColumn column = columns.get(idx);
updateColumnIndex(column, idx); // Updating the columns' index here should ensure to always have a correct index
helper.layoutColumn(i, column);
i++;
}
}
/**
* This is responsible for sizing and positioning both the rows and their cells.
*
* If the current {@link VFXTableState} is either {@link VFXTableState#INVALID} or {@link VFXTableState#isEmpty()} then
* exits and calls {@link #onLayoutCompleted(boolean)} with {@code false} as parameter.
*
* The layout is computed by iterating over the rows given by {@link VFXTableState#getRowsByIndex()}.
* Each row is laid out by {@link VFXTableHelper#layoutRow(int, VFXTableRow)}, and on each row
* {@link VFXTableRow#layoutCells()} is called (this is actually responsible for the cells' layout).
* The layout index is given by an external 'i' counter which starts at 0 and is incremented at each loop iteration.
*
* If the loop completes successfully, {@link #onLayoutCompleted(boolean)} is invoked with {@code true} as parameter.
*/
protected void layoutRows() {
VFXTable table = getSkinnable();
VFXTableHelper helper = table.getHelper();
VFXTableState state = table.getState();
if (state != VFXTableState.INVALID && !state.isEmpty()) {
int i = 0;
for (VFXTableRow row : state.getRowsByIndex().values()) {
helper.layoutRow(i, row);
row.layoutCells();
i++;
}
onLayoutCompleted(true);
return;
}
onLayoutCompleted(false);
}
/**
* There are certain situations in which it's not necessary to re-compute the whole table layout, but it's enough to
* only compute it partially, starting from a specific column. This is indeed a good optimization, especially when
* using the {@link ColumnsLayoutMode#VARIABLE} mode.
*
* Examples of when this may happen: 1) when in {@code FIXED} mode, the table's width exceeds the {@code virtualMaxX},
* which means that only the last column (and all its related cells) needs to be resized to fill the space;
* 2) when in {@code VARIABLE} mode, for a column that changes its width, we need to recompute the layout only for
* the column itself and the others that come after.
*
* So, how does this work?
*
* If using the {@link ColumnsLayoutMode#FIXED}, we simply call {@link VFXTableHelper#layoutColumn(int, VFXTableColumn)}
* on the column given by {@link ViewportLayoutRequest#column()} (which is expected to be the last column in the table).
* Then iterates on all the rows in the state, {@link VFXTableState#getRowsByIndex()}, resize each of them because the
* {@code virtualMaxX} has probably changed, then from each row retrieves the column's related cell and call
* {@link VFXTableHelper#layoutRow(int, VFXTableRow)}. Note: the layout index is given by {@link IntegerRange#diff()}
* on {@link VFXTableState#getColumnsRange()}.
*
* If using {@link ColumnsLayoutMode#VARIABLE} two things can happen:
* 1) if the column carried by {@link ViewportLayoutRequest#column()} is the last one in the table, then we
* re-compute the whole layout. The issue is that there are some edge cases that may not be easy to manage, so
* the strategy here is to go for stability rather than performance (also because handling all the edge cases may
* actually harm it).
*
2) for any other column we can actually optimize. First, it loops over the columns starting from the index
* of the changed column. Each column is resized and repositioned by {@link VFXTableHelper#layoutColumn(int, VFXTableColumn)}.
* Then iterates over the rows given by {@link VFXTableState#getRowsByIndex()}, iterates over then and resizes all
* of them by using {@link VFXTableHelper#layoutRow(int, VFXTableRow)}. In a nested loop, for each row, it updates
* only the cells from the aforementioned start index, uses {@link VFXTableHelper#layoutCell(int, VFXTableCell)}.
*
* Finally calls {@link #onLayoutCompleted(boolean)} with {@code true} as parameter.
*/
protected void partialLayout() {
VFXTable table = getSkinnable();
VFXTableState state = table.getState();
if (state == VFXTableState.INVALID) return;
VFXTableHelper helper = table.getHelper();
ColumnsLayoutMode layoutMode = table.getColumnsLayoutMode();
VFXTableColumn column = table.getViewportLayoutRequest().column();
int cIndex = table.indexOf(column);
if (layoutMode == ColumnsLayoutMode.FIXED) {
int layoutIndex = state.getColumnsRange().diff();
helper.layoutColumn(layoutIndex, column);
state.getRowsByIndex().values().forEach(r -> {
r.resize(table.getVirtualMaxX(), r.getHeight());
VFXTableCell cell = r.getCells().get(cIndex);
helper.layoutCell(layoutIndex, cell);
});
onLayoutCompleted(true);
return;
}
// There are too many edge cases, taking into account all of them may degrade performance rather than improving it.
// Leave the rest to the layout methods, columns and cells which are not visible will not be laid out (by default).
if (helper.isLastColumn(column)) {
layoutColumns();
layoutRows();
return;
}
// If it's VARIABLE mode, and it's not the last column, then it means we can actually do some optimization.
// Rather than looping over all the columns, we just need to update those starting from the index that changed.
ObservableList>> columns = table.getColumns();
IntegerRange range = IntegerRange.of(cIndex, columns.size() - 1);
range.forEach(i -> helper.layoutColumn(i, columns.get(i)));
state.getRowsByIndex().values().forEach(r -> {
r.resize(table.getVirtualMaxX(), r.getHeight());
range.forEach(i -> helper.layoutCell(i, r.getCells().get(i)));
});
onLayoutCompleted(true);
}
/**
* This must be called after processing a {@link ViewportLayoutRequest} to reset the {@link VFXTable#needsViewportLayoutProperty()}
* to {@link ViewportLayoutRequest#NULL}.
*/
protected void onLayoutCompleted(boolean done) {
VFXTable table = getSkinnable();
table.setNeedsViewportLayout(ViewportLayoutRequest.NULL.setWasDone(done));
}
/**
* This can be called during layout or other operations to update the given column's {@link VFXTableColumn#indexProperty()}
* to the given index. This is indeed a strange place to do so, but as it turns out, layout methods are the most
* reliable to ensure columns will always have the correct index.
*/
protected void updateColumnIndex(VFXTableColumn column, int index) {
column.setIndex(index);
}
//================================================================================
// Overridden Methods
//================================================================================
@Override
protected void initBehavior(VFXTableManager behavior) {
behavior.init();
}
@Override
protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return leftInset + DEFAULT_SIZE + rightInset;
}
@Override
protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
return topInset + DEFAULT_SIZE + bottomInset;
}
@Override
public void dispose() {
VFXTable table = getSkinnable();
if (columnsListener != null) {
table.getColumns().removeListener(columnsListener);
columnsListener = null;
}
super.dispose();
}
}