All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.github.palexdev.virtualizedfx.table.ColumnsLayoutCache Maven / Gradle / Ivy

/*
 * 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.virtualizedfx.cells.base.VFXTableCell;
import io.github.palexdev.virtualizedfx.enums.ColumnsLayoutMode;
import io.github.palexdev.virtualizedfx.table.VFXTableHelper.VariableTableHelper;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.collections.ListChangeListener;
import javafx.collections.ListChangeListener.Change;
import javafx.collections.ObservableList;

import java.util.*;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;

/**
 * Complex cache mechanism to simplify and vastly improve layout performance for {@link ColumnsLayoutMode#VARIABLE}.
 * This mode essentially disables virtualization along the x-axis which makes some computations way more expensive.
 * 

* Example 1: A columns width must be 'asked' to the column itself rather than using the value specified by * {@link VFXTable#columnsSizeProperty()} *

* Example 2: A columns position cannot be determined by a simple multiplication, but it's the sum of all previous * columns' widths (a loop) *

* Also, keep in mind that such computations are not needed only for the columns, but also for their corresponding cells * (can't rely on JavaFX bounds because sometimes they are messed up, garbage framework). *

* This cache implementation tries to mitigate this by caching columns' data such as their width, position and visibility * in the viewport. Listeners and bindings will automatically invalidate the data as needed, and re-compute it once * requested, which in other words means that the cache is 'lazy'. *

* Why this extends {@link DoubleBinding} *

* When I decided to create this special cache, it was mainly to improve the computation speed of the {@link VFXTable#virtualMaxXProperty()} * (in VARIABLE mode ofc), because it requires summing every column's width. So, I came up with a simple extension of * {@link DoubleBinding} which would invalidate the cached widths and thus re-compute upon request their sum. * It was then that I decided to expand the cache to also have positions and visibility checks, because the three pieces * of information are tightly coupled. Visibility depends on the width and the position, the latter depends on the width. * So, besides making such computations faster, this also still allows computing the {@code virtualMaxX} much faster. * There's even a special width value given by {@link #getPartialWidth()} which is the sum of all column's widths excluding * the last one. This is useful to compute the last column's width, as it may need to be bigger than expected to fill * the table (such value could be given by {@code tableWidth - partialWidth}). *

* How data is stored *

* The cache makes use of a {@link Map} and a wrapper class {@link LayoutInfo} to gather all the computations in one place. * Each table's column will have an entry in the map like this: [Column -> LayoutInfo]. When something needs to be * invalidated, setters are called on the appropriate {@link LayoutInfo} object. *

* Listeners *

* To manage invalidations and columns changes in the table, this uses a series of listeners. *

1) A {@link ListChangeListener} ensures the above-mentioned map stays always updated, more info here {@link #handleColumns(ListChangeListener.Change)} *

2) An {@link InvalidationListener} watches for {@link VFXTable#columnsSizeProperty()} changes and by iterating over * the {@link LayoutInfo} stored in the map, performs the following actions: a) resets both the positions and visibility * flags; b) invalidates the width if it's below the new value specified by the property; c) at the end it also invalidates * the width for the last column (if it wasn't done before). This is important to ensure that the last column takes all * the available space *

3) An {@link InvalidationListener} added on both the {@link VFXTable#widthProperty()} and {@link VFXTable#hPosProperty()}. * This listener is responsible for clearing, thus forcing the re-computation when requested, of the visibility cache *

4) Lastly, there an {@link InvalidationListener} for each column in the map to watch for {@link VFXTableColumn#prefWidthProperty()} * changes. This is managed by each {@link LayoutInfo}, more info there. *

* Computing functions and initialization *

* For the cache to work, the user must specify the three functions used to compute: *

1) the widths, {@link #setWidthFunction(BiFunction)} *

2) the positions, {@link #setPositionFunction(BiFunction)} *

3) the visibility, {@link #setVisibilityFunction(Function)} *

* To avoid cluttering the constructors, and for other reasons, the cache won't be active until you call the * {@link #init()} method. Both the setters and the init methods follow the fluent API pattern. Beware, if any * of the three functions is not set, it will throw an exception! * * @see LayoutInfoCache */ public class ColumnsLayoutCache extends DoubleBinding { //================================================================================ // Properties //================================================================================ private VFXTable table; private final LayoutInfoCache cache; private boolean init = false; public boolean sortToString = false; private VFXTableColumn lColumn; private final ReadOnlyBooleanWrapper anyChanged = new ReadOnlyBooleanWrapper(false) { @Override protected void invalidated() { if (get()) { invalidateLast(); invalidate(); } } }; private Consumer invalidatingAction = last -> { if (last) invalidate(); else anyChanged.set(true); }; // Layout functions private BiFunction, Boolean, Double> widthFn; private BiFunction xPosFn; private Function, Boolean> vFn; // Listeners private ListChangeListener> clListener; private InvalidationListener csListener; private InvalidationListener vListener; //================================================================================ // Constructors //================================================================================ public ColumnsLayoutCache(VFXTable table) { this.table = table; cache = new LayoutInfoCache(); clListener = this::handleColumns; csListener = i -> { for (LayoutInfo li : cache.values()) { // Resets all positions and visibility flags li.resetPos(); li.resetVisibility(); // Invalidate only the ones that are now below the minimum if (!li.isWidthValid()) continue; if (li.getWidth() < table.getColumnsSize().getWidth()) li.invalidateWidth(); } // Also invalidate last invalidateLast(); }; vListener = i -> { // Too unpredictable, better safe than sorry strategy, invalidate the whole cache cache.clearVisibilityCache(); invalidateLast(); }; } //================================================================================ // Methods //================================================================================ /** * If {@link #preInitCheck()} does not throw any exception, initializes the cache by adding the needed listeners * to the appropriate properties, as well as creating the cache mappings for each column in the table. *

* Further calls to this method won't do anything if the cache has already been initialized before. */ public ColumnsLayoutCache init() { if (!init) { preInitCheck(); ObservableList>> columns = table.getColumns(); if (!columns.isEmpty()) { lColumn = columns.getLast(); for (VFXTableColumn> c : columns) cache.put(c, new LayoutInfo(c)); } columns.addListener(clListener); table.columnsSizeProperty().addListener(csListener); table.widthProperty().addListener(vListener); table.hPosProperty().addListener(vListener); init = true; } return this; } /** * Checks that all the computing functions are set. * * @see #setWidthFunction(BiFunction) * @see #setPositionFunction(BiFunction) * @see #setVisibilityFunction(Function) */ private void preInitCheck() { if (widthFn == null) throw new IllegalStateException("Cannot initialize because: width function has not been set."); if (xPosFn == null) throw new IllegalStateException("Cannot initialize because: x position function has not been set."); if (vFn == null) throw new IllegalStateException("Cannot initialize because: visibility function has not been set."); } /** * Delegates to {@link LayoutInfoCache#getWidth(VFXTableColumn)}. * * @return either the cached or computed width for the given column */ public double getColumnWidth(VFXTableColumn column) { return cache.getWidth(column); } /** * Delegates to {@link #getColumnWidth(VFXTableColumn)} by passing the last column in the table. */ public double getLastColumnWidth() { return getColumnWidth(lColumn); } /** * @return the sum of all columns' widths excluding the last one */ public double getPartialWidth() { return cache.entrySet().stream() .filter(e -> e.getKey() != lColumn) .mapToDouble(e -> e.getValue().getWidth()) .sum(); } /** * The position of the column at the given index. This method is recursive! *

* Detailing the internals: *

	 * {@code
	 * // Let's suppose we want to compute the position of the column at index 2 (so third one)
	 * // First we convert the index to the corresponding column
	 * VFXTableColumn c = ...;
	 *
	 * // Then we query the map and get the known position for that column
	 * double pos = map.getPos(c);
	 * // Index 0 is a special case and we handle it as follows
	 * if (index == 0) {
	 *
	 *     map.setPos(c, 0); // Column 0 is always at x = 0
	 *     return 0;
	 * }
	 * // If 'pos' is lesser than 0, then it either means it was never been computed before or it was invalidated
	 * // We need to ask the position function to compute the value as follows...
	 * if (pos < 0) {
	 *     pos = posFn.apply(index -1, getColumnPos(index -1)); // Here's where the method calls itself
	 *     map.setPos(index, pos); // Store the found pos in the cache so we don't fall in this 'if' again until invalidated
	 * }
	 * return pos;
	 *
	 * // Why the recursion?
	 * // In general, to compute a column's position, we can simply get the position of the previous column + its width.
	 * // So, for the third one, we need the second one's position, and so on...
	 * // The recursion doesn't happen if the previous value is known, so the method acts almost like a simple getter
	 * // The recursion stops at column 0, because it's position is always 0.
	 * }
	 * 
*/ public double getColumnPos(int index) { VFXTableColumn> column = table.getColumns().get(index); LayoutInfo li = cache.get(column); double pos = li.getPos(); if (index == 0) { li.setPos(0.0); return 0.0; } if (pos < 0) { pos = xPosFn.apply(index - 1, getColumnPos(index - 1)); li.setPos(pos); } return pos; } /** * Queries the map to check whether the given column is visible. *

* If the {@link LayoutInfo} mapped to the column returns a {@code null} value, then it means that the visibility * check was either never done before or invalidated. In this case, the visibility function will compute it and the * {@link LayoutInfo} object updated. */ public boolean isInViewport(VFXTableColumn column) { LayoutInfo li = cache.get(column); if (li.isVisible() == null) li.setVisible(vFn.apply(column)); return li.isVisible(); } /** * @return the number of entries in the cache's map. This should always be equal to the size of {@link VFXTable#getColumns()} */ public int size() { return cache.size(); } /** * Invalidates the last column's width. */ private void invalidateLast() { cache.invalidateWidth(lColumn); } /** * This method is responsible for updating the cache map's entries when changes occur in {@link VFXTable#getColumns()}. *

* If there are no columns anymore, calls {@link #clear()} and {@link #invalidate()}, then exits. *

* Remember, the last column is always a special case in the table because it behaves a little different from the others. * So, before processing the {@link Change}, we must ensure that the last column is still the same as before. * If that's not the case, first we call {@link #invalidateLast()} to ensure that the 'now previously last' column * has the right width (the width computing function is likely to return a different value now). It does not matter * if the change is going to remove that column, we must do it anyway for consistency. Then we update the local * reference for the last column (yes, the cache stores it for fast access), and at this point two things can happen: *

1) the new last column already was present in the cache, we simply call {@link LayoutInfo#invalidateWidth()} *

2) the new last column has been added by the {@link Change} so we need to create a new entry in the cache's map *

* After the above checks we can start processing the {@link Change}. We just need to handle additions and removals. * For each removal, we remove the corresponding entry from the map and dispose it {@link LayoutInfo#dispose()}. * For each addition, we create a new entry in the map {@link LayoutInfo#LayoutInfo(VFXTableColumn)}. *

* Note: as you may know, JavaFX sucks. So, we actually need to manage this a bit differently. You see, * {@code setAll()} operations pose a big issue. As described in {@link Change}'s documentation, calling {@code set()} * on the list will be treated as both an addition and a removal, also the {@code replaced} flag will return {@code true}, * which makes sens right? When such change occurs, the added and removed lists carried by the {@link Change} * will respectively contain all the items in the list and all the previous items in the list. But what happens when * the new ones are pretty much the same as the old ones, maybe with just a few additions/removals? We simply can't * treat the change as suggested here {@link Change}, because we would first remove and dispose all the {@link LayoutInfo} * objects, and then create them again. What. A. Waste. Of. Performance! *

* So, how do we handle this? A temporary collection stores all the removed values. When processing the additions, * we remove any entry that is also present in that collection. This way we only keep the values that have actually * been removed, and for these we can remove the entry and call {@link LayoutInfo#dispose()}. *

* Finally, we invalidate all the positions and visibility flags. Re-computing them is far more convenient and stable * than trying to guess which one is still good and which not. We also call {@link #invalidate()} and {@link #invalidateLast()}. */ private void handleColumns(ListChangeListener.Change> change) { ObservableList>> columns = table.getColumns(); if (columns.isEmpty()) { clear(); invalidate(); return; } VFXTableColumn> last = columns.getLast(); if (last != lColumn) { // Invalidate the previous last's binding invalidateLast(); lColumn = last; /* * Since bindings are all the same whether it's the first column, in the middle or the last one... * There is no need to replace the binding. * What we want to do here is: if the binding is already present, we just invalidate it * (since the widthFn is likely to return a different value now), otherwise we just create it. */ cache.computeIfAbsent(last, LayoutInfo::new).invalidateWidth(); } Set> rm = new HashSet<>(); while (change.next()) { if (change.wasRemoved()) rm.addAll(change.getRemoved()); if (change.wasAdded()) { for (VFXTableColumn c : change.getAddedSubList()) { if (rm.contains(c)) { rm.remove(c); continue; } if (c == lColumn) continue; cache.put(c, new LayoutInfo(c)); } } } rm.forEach(c -> cache.remove(c).dispose()); cache.values().forEach(li -> { li.resetPos(); li.resetVisibility(); }); invalidate(); invalidateLast(); } /** * Clears the cache by removing all the entries from the map and setting the last column local reference to {@code null}. */ private void clear() { cache.clear(); anyChanged.set(false); lColumn = null; } //================================================================================ // Overridden Methods //================================================================================ /** * @return the sum of all columns' widths, each given by {@link LayoutInfo#getWidth()} */ @Override protected double computeValue() { anyChanged.set(false); return cache.values().stream() .mapToDouble(LayoutInfo::getWidth) .sum(); } /** * Disposes the cache making it not usable anymore. * * @see #clear() */ @Override public void dispose() { clear(); widthFn = null; invalidatingAction = null; table.getColumns().removeListener(clListener); table.columnsSizeProperty().removeListener(csListener); table.widthProperty().removeListener(vListener); table.hPosProperty().removeListener(vListener); clListener = null; csListener = null; vListener = null; table = null; } @Override public String toString() { Map, LayoutInfo> cache = this.cache; if (sortToString) cache = new TreeMap<>(this.cache); StringBuilder sb = new StringBuilder(); sb.append("ColumnsLayoutCache [%s][%d] {".formatted(isValid() ? "valid:[%f]".formatted(get()) : "invalid", size())); if (cache.isEmpty()) { sb.append("empty}"); return sb.toString(); } sb.append("\n"); // Pretty print int maxL = 0; for (VFXTableColumn c : cache.keySet()) { String text = Optional.ofNullable(c.getText()).orElse(""); maxL = Math.max(maxL, text.length()); } for (Iterator, LayoutInfo>> iterator = cache.entrySet().iterator(); iterator.hasNext(); ) { Map.Entry, LayoutInfo> entry = iterator.next(); VFXTableColumn c = entry.getKey(); LayoutInfo i = entry.getValue(); int index = i.getIndex(); String text = Optional.ofNullable(c.getText()).orElse(""); DoubleBinding b = i.wBinding; double pos = i.getPos(); Boolean visibility = i.isVisible(); sb.append(" ") .append("Column: ") .append(" ".repeat(maxL - "Column".length())) .append(text) .append("\n") .append(" ") .append("Index: ") .append(" ".repeat(maxL - "Index".length())) .append("[%d]".formatted(index)) .append("\n") .append(" ") .append("Width: ") .append(" ".repeat(maxL - "Width".length())) .append(b.isValid() ? "[valid:%.2f]".formatted(b.get()) : "[invalid]") .append("\n") .append(" ") .append("Position: ") .append(" ".repeat(maxL - "Position".length())) .append((pos == -1.0) ? "[valid:%.2f]".formatted(pos) : "[invalid]") .append("\n") .append(" ") .append("Visible: ") .append(" ".repeat(maxL - "Visible".length())) .append((visibility != null && visibility) ? "[valid:true]" : "[invalid]") .append("\n"); if (iterator.hasNext()) { sb.append(" "); sb.append("_".repeat(30)); sb.append("\n"); } } sb.append("}"); return sb.toString(); } //================================================================================ // Getters/Setters //================================================================================ /** * @return the {@link VFXTable} instance this cache is related to */ public VFXTable getTable() { return table; } /** * @return the map containing the columns' layout data as {@link LayoutInfo} objects */ protected Map, LayoutInfo> getCacheMap() { return cache; } /** * @return the local reference to the last column in the table */ protected VFXTableColumn getLastColumn() { return lColumn; } public boolean isAnyChanged() { return anyChanged.get(); } /** * Specifies whether any of the {@link LayoutInfo} objects in {@link #getCacheMap()} was invalidated. */ public ReadOnlyBooleanProperty anyChangedProperty() { return anyChanged.getReadOnlyProperty(); } /** * Sets the {@link BiFunction} responsible for computing a column's width. The function gives the following parameters: * 1) the column to compute the width for; 2) whether it is the last column in the table which may need special handling. *

* You can check {@link VariableTableHelper#computeColumnWidth(VFXTableColumn, boolean)} for an example. */ public ColumnsLayoutCache setWidthFunction(BiFunction, Boolean, Double> widthFn) { this.widthFn = widthFn; return this; } /** * Sets the {@link BiFunction} responsible for computing a column's position. The function gives the following parameters: * 1) the previous column's index; 2) the previous column's width. *

* To understand the why of those parameters, read {@link #getColumnPos(int)}. *

* You can check {@link VariableTableHelper#computeColumnPos(int, double)} for an example. */ public ColumnsLayoutCache setPositionFunction(BiFunction xPosFn) { this.xPosFn = xPosFn; return this; } /** * Sets the {@link Function} responsible for computing a column's width. The function gives the column for which * compute the visibility as the parameter. *

* You can check {@link VariableTableHelper#computeVisibility(VFXTableColumn)} for an example. */ public ColumnsLayoutCache setVisibilityFunction(Function, Boolean> vFn) { this.vFn = vFn; return this; } //================================================================================ // Internal Classes //================================================================================ /** * Nothing special, just an extension of {@link HashMap} to store data about columns' layout as {@link LayoutInfo} objects. *

* Makes the variable declaration shorted and offers a bunch of convenience methods, that's all. * * @see LayoutInfo */ public class LayoutInfoCache extends HashMap, LayoutInfo> { //================================================================================ // Width //================================================================================ public double getWidth(VFXTableColumn column) { return get(column).getWidth(); } public boolean isWidthValid(VFXTableColumn column) { return get(column).isWidthValid(); } private void invalidateWidth(VFXTableColumn column) { LayoutInfo li = get(column); if (li == null) return; li.invalidateWidth(); } //================================================================================ // Position //================================================================================ public double getPos(int index) { ObservableList>> columns = table.getColumns(); return get(columns.get(index)).getPos(); } private void setPos(int index, double pos) { ObservableList>> columns = table.getColumns(); if (index > columns.size() - 1) return; get(columns.get(index)).setPos(pos); } //================================================================================ // Visibility //================================================================================ public boolean isVisible(VFXTableColumn column) { return get(column).isVisible(); } private void setVisibility(VFXTableColumn column, Boolean visibility) { get(column).setVisible(visibility); } //================================================================================ // Misc //================================================================================ public void clearPositionCache() { values().forEach(LayoutInfo::resetPos); } public void clearVisibilityCache() { values().forEach(LayoutInfo::resetVisibility); } @Override public void clear() { values().forEach(LayoutInfo::dispose); super.clear(); } } /** * Wrapper class for layout data related to a specific {@link VFXTableColumn}. * This stores: its index [init:-1], its width as a {@link DoubleBinding}, its x position [default:-1.0], * and its visibility [default:null]. *

* Width handling *

* For better performance, the column's width is stored as a binding, so the value is computed lazily (only upon request). * Invalidation is handled "manually". Check {@link #createWidthBinding()} for more details. *

* Null visibility? What? *

* This uses {@code null} as a possible visibility value, to indicate that it is invalid and thus must be computed. */ public class LayoutInfo implements Comparable { //================================================================================ // Properties //================================================================================ private VFXTableColumn column; private int index = -1; private DoubleBinding wBinding; private double pos = -1.0; private Boolean visible = null; //================================================================================ // Constructors //================================================================================ public LayoutInfo(VFXTableColumn column) { this.column = column; this.wBinding = createWidthBinding(); } //================================================================================ // Methods //================================================================================ /** * @return the column instance the layout data refers to */ public VFXTableColumn getColumn() { return column; } /** * @return the column's index retrieved the first time by {@link VFXTable#indexOf(VFXTableColumn)} (then cached) */ public int getIndex() { if (index == -1) index = table.indexOf(column); return index; } /** * Calls {@link DoubleBinding#get()} on the column's width binding. */ public double getWidth() { return wBinding.get(); } /** * @return whether {@link DoubleBinding#isValid()} is true */ public boolean isWidthValid() { return wBinding.isValid(); } /** * Invalidates the width binding by calling {@link DoubleBinding#invalidate()}. */ private void invalidateWidth() { wBinding.invalidate(); } /** * @return the stored column's x position */ public double getPos() { return pos; } /** * Sets the column's x position. */ private void setPos(double pos) { this.pos = pos; } /** * Resets, and thus invalidates, the column's x position to -1.0 */ private void resetPos() { setPos(-1.0); } /** * @return whether the column is visible in the viewport. Beware, this can also return {@code null} to indicate * that the value is invalid and should be re-computed by the cache */ public Boolean isVisible() { return visible; } /** * Sets whether the column is visible in the viewport. */ private void setVisible(Boolean visible) { this.visible = visible; } /** * Resets, and thus invalidates, the column's visibility to {@code null}. */ private void resetVisibility() { setVisible(null); } /** * This is responsible for creating the {@link DoubleBinding} which computes the column's width by using * the function set by {@link #setWidthFunction(BiFunction)}. *

* It returns an inline custom binding which depends on {@link VFXTableColumn#prefWidthProperty()}. When this * property changes two things must happen: *

1) obviously the binding must become invalid, because now the width function may return a different value *

2) we must partially invalidate the positions and visibility values. By partial, I mean only the columns * starting from {@link #getIndex()} to the last one. */ private DoubleBinding createWidthBinding() { return new DoubleBinding() { { bind(column.prefWidthProperty()); } private void invalidatePartial() { VFXTable table = getTable(); ObservableList>> columns = table.getColumns(); int index = getIndex(); int size = columns.size(); for (int i = index; i < size; i++) { if (cache.getPos(i) == -1.0) break; cache.setPos(i, -1.0); if (i + 1 < size) cache.setVisibility(columns.get(i + 1), null); } } @Override protected double computeValue() { return widthFn.apply(column, column == lColumn); } @Override protected void onInvalidating() { invalidatePartial(); invalidatingAction.accept(column == lColumn); } @Override public void dispose() { unbind(column.prefWidthProperty()); } }; } /** * Calls {@link DoubleBinding#dispose()} and sets both the binding and the column instances to {@code null}. */ private void dispose() { wBinding.dispose(); wBinding = null; column = null; } //================================================================================ // Overridden Methods //================================================================================ @Override public int compareTo(LayoutInfo o) { return Integer.compare(getIndex(), o.getIndex()); } @SuppressWarnings("unchecked") @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; LayoutInfo that = (LayoutInfo) o; return Objects.equals(getColumn(), that.getColumn()); } @Override public int hashCode() { return Objects.hash(getColumn()); } } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy