I am trying to write a custom resize policy that acts like TableView.CONSTRAINED_RESIZE_POLICY in that it set the total width of all visible to columns to the total available width in the table, so that the horizontal scroll bar never appears.
I am using the line double widthAvailable = table.getWidth() - getScrollbarWidth(table); to try do to this (see full code below).
Unfortunately, this calculation seems to return 4 too many. My guess is that there is some other thing I am also supposed to be subtracting from the table width that I am missing, which happens to be 4 pixels wide in the default JavaFX theme.
Here is a complete program demonstrating the problem:
package net.joshuad.hypnos.test;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import javafx.application.Application;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.ScrollBar;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.util.Callback;
public class CustomResizeExample extends Application {
#SuppressWarnings("unchecked")
private Parent getContent () {
TableView <Locale> table = new TableView <>( FXCollections.observableArrayList( Locale.getAvailableLocales() ) );
TableColumn <Locale, String> countryCode = new TableColumn <>( "CountryCode" );
countryCode.setCellValueFactory( new PropertyValueFactory <>( "country" ) );
TableColumn <Locale, String> language = new TableColumn <>( "Language" );
language.setCellValueFactory( new PropertyValueFactory <>( "language" ) );
table.getColumns().addAll( countryCode, language );
TableColumn <Locale, Locale> local = new TableColumn <>( "Locale" );
local.setCellValueFactory( c -> new SimpleObjectProperty <>( c.getValue() ) );
table.getColumns().addAll( local );
table.setColumnResizePolicy( new CustomResizePolicy() );
BorderPane pane = new BorderPane( table );
return pane;
}
#Override
public void start ( Stage stage ) throws Exception {
stage.setScene( new Scene( getContent(), 800, 400 ) );
stage.show();
}
public static void main ( String[] args ) {
launch ( args );
}
}
#SuppressWarnings ( "rawtypes" )
class CustomResizePolicy implements Callback <TableView.ResizeFeatures, Boolean> {
#SuppressWarnings("unchecked")
#Override
public Boolean call ( TableView.ResizeFeatures feature ) {
TableView table = feature.getTable();
List <TableColumn> columns = table.getVisibleLeafColumns();
double widthAvailable = table.getWidth() - getScrollbarWidth ( table );
double forEachColumn = widthAvailable / columns.size();
for ( TableColumn column : columns ) {
column.setMinWidth( forEachColumn );
column.setMaxWidth( forEachColumn );
}
return true;
}
private double getScrollbarWidth ( TableView table ) {
double scrollBarWidth = 0;
Set <Node> nodes = table.lookupAll( ".scroll-bar" );
for ( final Node node : nodes ) {
if ( node instanceof ScrollBar ) {
ScrollBar sb = (ScrollBar) node;
if ( sb.getOrientation() == Orientation.VERTICAL ) {
if ( sb.isVisible() ) {
scrollBarWidth = sb.getWidth();
}
}
}
}
return scrollBarWidth;
}
}
See how the table is a little wider than it ought to be? I would like to be able to set the columns' width such that no horizontal scrollbar appears.
Scenic View is a useful tool to get acquainted with. It gives you many details about the scene graph. The space you are looking for is clipped-container:
Note that setting a CustomResizePolicy the way you did effectively does not allow columns to be resized as they are always allocated an equal share of available space.
Here is the calculation you are probably looking for:
Region region = null;
Set<Node> nodes = table.lookupAll(".clipped-container");
for (Node node : nodes) {
if (node instanceof Region) {
region = (Region) node;
}
}
for (TableColumn<Locale, ?> column : table.getColumns()) {
column.setPrefWidth(region.getWidth() / table.getColumns().size());
}
I don't completely understand why CONSTRAINED_RESIZE_POLICY is not what you want, as it divides the space equally and does not allow resizing to exceed the allocated space (thus a horizontal scrollbar does not appear), but if you're making some special resize policy the above should help.
Related
In JavaFX 8 is was able to do the following to always center a selected row in a TableView in the middle of the viewport:
TableView<T> tv = getTableView();
// Position selection in the middle of the viewPort.
if (tv.getSelectionModel().getSelectedItem() != null) {
TableViewSkin<?> ts = (TableViewSkin<?>) tv.getSkin();
Optional<VirtualFlow> vfOpt = ts.getChildren().stream()
.filter(child -> child instanceof VirtualFlow)
.map(VirtualFlow.class::cast)
.findFirst();
// TODO sometimes not centering correctly. The scrollTo used in JavaFX 17
// differs from that used in JavaFX 8!
if (vfOpt.isPresent()) {
VirtualFlow vf = vfOpt.get();
int first = vf.getFirstVisibleCell().getIndex();
int last = vf.getLastVisibleCell().getIndex();
int selectedIndex = tv.getSelectionModel().getSelectedIndex();
int scrollPosition = selectedIndex - ((last - first) / 2) > 0 ? selectedIndex - ((last - first) / 2) : 0;
vf.scrollTo(scrollPosition);
}
}
In JavaFX 17, this no longer works. I tracked it down to the implementation of the vf.scrollTo(int) method, that has gone through some changes compared to JavaFX 8. The code above will sometimes work and sometimes it won't (depending on the first and last index).
I noted down the following (FI = first, LA = last, SEL = selectedIndex, POS = calculated scroll position, RES result):
FI = 0, LA = 16, SEL = 13, POS = 5, RES = to top of viewport
FI = 12, LA = 29, SEL = 13, POS = 5, RES = to middle of viewport
FI = 5, LA = 21, SEL = 13, POS = 5, RES = to top of viewport
So, it appears to have something to do with the calculated position falling already inside the viewport, causing the selected row to go to the top.
Can anyone offer any help?
VirtualFlow.scrollTo(int) only guarantees the item at the specified index will be visible in the viewport; it makes no guarantees about where in the viewport it will be positioned.
Instead, you can use VirtualFlow.scrollToTop(int) to move the selected item to the top of the viewport (if possible), and then use Viewport.scrollPixels(double) to adjust by half the height of the viewport. You need to layout the viewport in between (I think otherwise the second call overrides the first, though I am not entirely clear).
This approach should be more robust than your original approach, as it relies only on the specification, instead of the actual implementation, though I have not tested on versions prior to JavaFX 18.
import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.fxml.FXMLLoader;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.skin.TableViewSkin;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.stage.Stage;
import java.io.IOException;
public class HelloApplication extends Application {
#Override
public void start(Stage stage) throws IOException {
TableView<Item> table = new TableView<>();
for (int i = 1 ; i <= 1000 ; i++) {
table.getItems().add(new Item("Item "+i));
}
TableColumn<Item, String> col = new TableColumn<>("Item");
col.setCellValueFactory(data -> data.getValue().nameProperty());
table.getColumns().add(col);
Button scrollSelected = new Button("Scroll to Selected");
scrollSelected.setOnAction(e -> {
int selected = table.getSelectionModel().getSelectedIndex();
if (selected == -1) return ;
TableViewSkin<?> skin = (TableViewSkin<?>) table.getSkin();
skin.getChildren().stream()
.filter(VirtualFlow.class::isInstance)
.map(VirtualFlow.class::cast)
.findAny()
.ifPresent(vf -> {
vf.scrollToTop(selected);
vf.layout();
vf.scrollPixels(-vf.getHeight()/2);
});
});
HBox controls = new HBox(scrollSelected);
controls.setAlignment(Pos.CENTER);
controls.setPadding(new Insets(5));
BorderPane root = new BorderPane(table);
root.setBottom(controls);
Scene scene = new Scene(root);
stage.setScene(scene);
stage.show();
}
public static class Item {
private final StringProperty name = new SimpleStringProperty();
public Item(String name) {
this.name.set(name);
}
public StringProperty nameProperty() {
return name ;
}
}
public static void main(String[] args) {
launch();
}
}
There are various other solutions, e.g.
vf.scrollTo(selected);
vf.layout();
Cell<?> cell = vf.getCell(selected);
double y = cell.getBoundsInParent().getCenterY();
vf.scrollPixels(y - vf.getHeight()/2);
I am using PopOver from ControlsFX, in a TableView If I trigger the startEdit of a cell, it should pop the PopOver. This part it works, the problem is, the arrow which is pointing to the row is not on the right place every time. If I select a row from the table which is at the bottom of the table , it points to a cell above it.
I need that arrow to point every time to the right cell in the TableView.
ControlsFX , version: 8.40.14
How can I solve this?
Here is the code where you can see how it works:
package stackoverflow.popover;
import com.sun.deploy.util.StringUtils;
import javafx.beans.property.ListProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.ListView;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.controlsfx.control.PopOver;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.ResourceBundle;
public class Controller implements Initializable {
#FXML
private TableView<Model> table;
#FXML
private TableColumn<Model, ObservableList<String>> listCell;
#Override
public void initialize(URL location, ResourceBundle resources) {
Model model = new Model(FXCollections.observableArrayList("Apple", "Peach"));
ObservableList<Model> items = FXCollections.observableArrayList();
for (int i = 0; i < 50; i++) {
items.add(model);
}
table.setItems(items);
table.setEditable(true);
listCell.setCellFactory(factory -> new ListTableCell(
FXCollections.observableArrayList("Apple", "Orange", "Peach", "Banana", "Lemon", "Lime")));
listCell.setCellValueFactory(data -> data.getValue().list);
}
private class ListTableCell extends TableCell<Model, ObservableList<String>> {
private ObservableList<String> allItems;
ListTableCell(ObservableList<String> allItems) {
this.allItems = allItems;
}
#Override
public void startEdit() {
super.startEdit();
PopOver popOver = new PopOver();
popOver.setAutoHide(true);
PopupController sc = new PopupController(allItems, new ArrayList<>(getItem()));
popOver.setContentNode(new StackPane(sc.getPane()));
popOver.setOnHiding(event -> commitEdit(sc.getItems()));
popOver.show(this);
}
#Override
protected void updateItem(ObservableList<String> item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
} else {
setText(StringUtils.join(item, ","));
}
}
}
private class Model {
ListProperty<String> list;
public Model(ObservableList<String> list) {
this.list = new SimpleListProperty<>(list);
}
}
private class PopupController {
private BorderPane pane = new BorderPane();
private ListView<String> left = new ListView<>();
private ListView<String> right = new ListView<>();
private Button toLeft = new Button("<");
private Button toRight = new Button(">");
PopupController(List<String> all, List<String> selected) {
VBox leftBox = new VBox();
leftBox.setSpacing(5);
leftBox.getChildren().add(toRight);
leftBox.getChildren().add(left);
pane.setLeft(leftBox);
VBox rightBox = new VBox();
rightBox.setSpacing(5);
rightBox.getChildren().add(toLeft);
rightBox.getChildren().add(right);
pane.setRight(rightBox);
ObservableList<String> allItems = FXCollections.observableArrayList(all);
allItems.removeAll(selected);
left.setItems(allItems);
right.setItems(FXCollections.observableArrayList(selected));
toLeft.disableProperty().bind(right.getSelectionModel().selectedItemProperty().isNull());
toRight.disableProperty().bind(left.getSelectionModel().selectedItemProperty().isNull());
toLeft.setOnAction(event -> {
String str = right.getSelectionModel().getSelectedItem();
right.getItems().remove(str);
left.getItems().add(str);
});
toRight.setOnAction(event -> {
String str = left.getSelectionModel().getSelectedItem();
left.getItems().remove(str);
right.getItems().add(str);
});
}
BorderPane getPane() {
return pane;
}
ObservableList<String> getItems() {
return right.getItems();
}
}
}
Here are two screenshots to show what I mean :
This is even worst: (with setAutoFix(false))
I am not expert with ControlFX but I believe the problem you are facing its because the height of your PopOver is greater than your current screen size thus it is trying to relocate itself in a way to be inside the screen local bounds. So in order to achieve what you are trying you will need to manually set the ArrowLocation of your PopOver control. Here is how you can solve the issue (using your code) :
#Override
public void startEdit() {
super.startEdit();
PopOver popOver = new PopOver();
popOver.setAutoHide(true);
// first set auto fix to false
// to manually set the arrow location
popOver.setAutoFix(false);
PopupController sc = new PopupController(allItems, new ArrayList<>(getItem()));
// set a specific height for our pane
final double paneHeight = 300;
StackPane popOverPane = new StackPane(sc.getPane());
popOverPane.setPrefHeight(paneHeight);
popOver.setContentNode(popOverPane);
popOver.setOnHiding(event -> commitEdit(sc.getItems()));
// find coordinates relative to the screen
Bounds screenBounds = this.localToScreen(this.getBoundsInLocal());
// get our current y position ( on screen )
int yPos = (int) screenBounds.getMinY();
// get screen size
Rectangle2D primaryScreenBounds = Screen.getPrimary().getVisualBounds();
int screenHeight = (int) primaryScreenBounds.getHeight();
// if the PopOver height + the current position is greater than
// the max screen's height then set the arrow position to bottom left
if(screenHeight < yPos + paneHeight) {
popOver.setArrowLocation(ArrowLocation.LEFT_BOTTOM);
}
popOver.show(this);
}
Using the code above you would see some things you need to change and think more carefully.
The first one is that you will need to set a specific size for your StackPane or to find a dynamic way to calculate it.
Secondly in my example I am using the Screen.getPrimary() which will get the Rectangle2D dimensions of your primary screen and not the screen you have your application, this means that if you have more monitors with different resolution and your program is displayed on the second one, the code above will still use the first ( default ) monitor's resolution which might not match with the primary one, so you will have to find a way to get the correct monitor resolution.
Lastly you will need to do the same when the window is on the right side of the screen because then the width of the 'Popover' will exceed the width of your monitor
Try setting setAutoFix(false) on the PopOver instance. From the documentation of the autoFix property of the PopOver's superclass PopupWindow:
This convenience variable indicates whether, when the popup is shown,
it should automatically correct its position such that it doesn't end
up positioned off the screen.
I wrote a custom resize policy for a TableView which is similar to TableView.CONSTRAINED_RESIZE_POLICY in that the total width of visible columns always equals the width of the table itself.
Whenever a column is resized, either by the table being resized or the user dragging the column, the resize policy is called and the columns are resized appropriately.
However, when one of the dividers in the table's header is double-clicked (to "shrink wrap" the column's content) the custom resize policy isn't triggered.
As a result, the total width of the columns can be more or less than the table's width, which is not good.
How can I detect these double clicks and cause my CustomResizePolicy to trigger a call afterwards?
Here is a working example showing the double clicks do not result in a call to the CustomResizePolicy:
import java.util.Locale;
import javafx.application.Application;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.util.Callback;
public class CustomResizeExample extends Application {
#SuppressWarnings("unchecked")
private Parent getContent () {
TableView <Locale> table = new TableView <>( FXCollections.observableArrayList( Locale.getAvailableLocales() ) );
TableColumn <Locale, String> countryCode = new TableColumn <>( "CountryCode" );
countryCode.setCellValueFactory( new PropertyValueFactory <>( "country" ) );
TableColumn <Locale, String> language = new TableColumn <>( "Language" );
language.setCellValueFactory( new PropertyValueFactory <>( "language" ) );
table.getColumns().addAll( countryCode, language );
TableColumn <Locale, Locale> local = new TableColumn <>( "Locale" );
local.setCellValueFactory( c -> new SimpleObjectProperty <>( c.getValue() ) );
table.getColumns().addAll( local );
table.setColumnResizePolicy( new CustomResizePolicy() );
BorderPane pane = new BorderPane( table );
return pane;
}
#Override
public void start ( Stage stage ) throws Exception {
stage.setScene( new Scene( getContent(), 800, 400 ) );
stage.show();
}
public static void main ( String[] args ) {
launch ( args );
}
}
#SuppressWarnings ( "rawtypes" )
class CustomResizePolicy implements Callback <TableView.ResizeFeatures, Boolean> {
#Override
public Boolean call ( TableView.ResizeFeatures feature ) {
System.out.println ( "Called" ); //This does not print when the divider is double-clicked.
return TableView.CONSTRAINED_RESIZE_POLICY.call( feature );
}
}
This is a bug in TableViewSkin.
In Java 8, the method resizeColumnToFitContent, which is invoked by double clicking a column separator, does not call resizeColumn in TableView. The result is that the layout is not updated correctly. It's enough that you try to resize a fit-to-width column to observe this. In fact, fitting-to-width repeatedly will set the width closer and closer to its real width. Try to fit and then resize several times in a row the same column (doing this with other columns will reset the behavior).
I didn't have time to find a good workaround. There's some semblance of improvement by subclassing TableViewSkin and overriding resizeColumnToFitContent:
class CustomTableSkin<T> extends TableViewSkin<T> {
public CustomTableSkin(TableView<T> tableView) {
super(tableView);
i = tableView.getColumns().size() + 1;
}
private int i;
#Override
protected void resizeColumnToFitContent(TableColumn<T, ?> tc, int maxRows) {
double before = tc.getWidth();
super.resizeColumnToFitContent(tc, maxRows);
double now = tc.getWidth();
double diff = before - now;
i--;
if (i >= 0)
resizeColumn(tc, -diff);
}
}
The addition of calls to resizeColumn help in some cases, but it will take some more research to find out the correct fix.
Java 9 changed the skinning implementation, though I believe the problem is still present there.
I have a table view with an anchor pane panel with 2 children, a table view and a pagination. The pagination is not directly linked to the table view (like if you put a button with a label that gets updated).
The only examples I found is that the pagination itself handles the UI updates via it's setPageFactory method.
I know I shouldn't design it like this, unfortunately I don't have the time to change it for now. So here's my current solution:
paginationTab1.setPageFactory(e -> {
updateTableViewWithOffset(e);
//hack, as the pagination is not directly linked with the tableView
//just return an empty component that is not managed by the parent component
Label l = new Label();
l.setManaged(false);
return l;
});
Is this an acceptable workaround (return null doesn't update well the UI after...) ? Or is there a way to get the same listener's behavior as the setPageFactory method provides (i.e get the page offset when it's clicked either on the pagination's arrows or pagination's numbers)?
You can observe the Pagination's currentPageIndexProperty():
paginationTab1.currentPageIndexProperty().addListener((obs, oldIndex, newIndex) ->
updateTableViewWithOffset(newIndex.intValue()));
Here's a SSCCE:
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import javafx.application.Application;
import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.scene.Scene;
import javafx.scene.control.Pagination;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
public class IndependentPaginationTest extends Application {
private static final int NUM_PAGES = 20 ;
private static final int ITEMS_PER_PAGE = 20 ;
#Override
public void start(Stage primaryStage) {
TableView<String> table = new TableView<>();
TableColumn<String, String> col = new TableColumn<>("Item");
table.getColumns().add(col);
col.setCellValueFactory(cellData -> new ReadOnlyStringWrapper(cellData.getValue()));
updateTable(table, 0);
Pagination pagination = new Pagination();
pagination.setPageCount(NUM_PAGES);
pagination.currentPageIndexProperty().addListener((obs, oldIndex, newIndex) ->
updateTable(table, newIndex.intValue()));
BorderPane root = new BorderPane(table, null, null, pagination, null);
primaryStage.setScene(new Scene(root, 800, 600));
primaryStage.show();
}
private void updateTable(TableView<String> table, Integer index) {
int start = index * ITEMS_PER_PAGE + 1;
int end = start + ITEMS_PER_PAGE ;
table.getItems().setAll(
IntStream.range(start, end)
.mapToObj(Integer::toString)
.map("Item "::concat)
.collect(Collectors.toList()));
}
public static void main(String[] args) {
launch(args);
}
}
I made a single editable combo box.....and when you type something in it whatever you type goes to the bottom of the list. The problem i am having is when i click on something that is already in the combo box it doesn't just get selected it also adds again into the combo box as a new entry creating a "Duplicate" any ideas on how i can prevent that? here is what i have.
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.scene.layout.GridPane;
import javafx.geometry.*;
import javafx.stage.*;
import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
public class ComboBoxProblem extends Application {
Scene scene1;
ObservableList<String> randomStrings;
public void start(Stage primaryStage)throws Exception{
primaryStage.setTitle("ComboBox Problem!");
primaryStage.setResizable(false);
primaryStage.sizeToScene();
GridPane gridPane = new GridPane();
scene1 = new Scene(gridPane);
ComboBox<String> box1 = new ComboBox<String>();
randomStrings = FXCollections.observableArrayList(
"Cool","Dude","BRO!","Weirdo","IDK"
);
box1.setItems(randomStrings);
box1.setEditable(true);
box1.setValue(null);
box1.setOnAction(event -> {
String value =
box1.valueProperty().getValue();
if( value != String.valueOf(randomStrings)){
randomStrings.addAll(box1.valueProperty().getValue());
box1.setValue(null);
}
});
gridPane.setAlignment(Pos.CENTER);
gridPane.setConstraints(box1,0,0);
gridPane.getChildren().addAll(box1);
primaryStage.setScene(scene1);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Just add another condition on the button's action to check if the string already exists in the list of items. If not, add it.
!box1.getItems().contains(value)
The condition is to be added to the following statement.
if (!value.equals(String.valueOf(randomStrings)) &&
!box1.getItems().contains(value)){
randomStrings.addAll(value);
box1.setValue(null);
}
As correctly pointed out by #uluk, the way you are comparing Strings is incorrect and you must use equals in place of !=
Comparing the String values with != or == operators is erroneous.
value != String.valueOf(randomStrings) // Incorrect
value.equals(String.valueOf(randomStrings)) // Correct but not logical in your use case
You may check the input value then add it to the items of the combobox:
box1.setOnAction( event ->
{
if ( box1.getValue() != null && !box1.getValue().trim().isEmpty() )
{
String value = box1.getValue().trim();
if ( !randomStrings.contains( value ) )
{
randomStrings.add( value );
box1.setValue( null );
}
}
} );