How can I access scroll position in controlsfx GridView for JavaFX? - javafx

I am using a controlsfx GridView in a JavaFX application. It shows a scrollbar when needed, but I can't find any way to determine where the scrollbar is positioned at, nor update it. I need to be able to do things like respond to a "go to the top" command from the user and scroll up; or scroll to keep the selected thumbnail visible as the user uses arrow keys to navigate through the grid. But I don't see how to get access to the current scroll position, nor manipulate it, as you can with a ScrollPane.
For example, here is a sample application that creates a GridView with 100 generated images in it. I add a listener to the "onScrollProperty", but it is never called. I also have no idea how I would cause it to scroll to a certain scroll position (0..1):
import java.awt.image.BufferedImage;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.stage.Stage;
import org.controlsfx.control.GridView;
import org.controlsfx.control.cell.ImageGridCell;
// Demo class to illustrate the slowdown problem without worrying about thumbnail generation or fetching.
public class GridViewDemo extends Application {
private static final int CELL_SIZE = 200;
public static void main(String[] args) {
launch(args);
}
public void start(Stage primaryStage) {
// Create a Scene with a ScrollPane that contains a TilePane.
GridView<Image> gridView = new GridView<>();
gridView.setCellFactory(gridView1 -> new ImageGridCell());
gridView.setCellWidth(CELL_SIZE);
gridView.setCellHeight(CELL_SIZE);
gridView.setHorizontalCellSpacing(10);
gridView.setVerticalCellSpacing(10);
addImagesToGrid(gridView);
gridView.onScrollProperty().addListener((observable, oldValue, newValue) -> {
// Never called
System.out.println("Scrolled...");
});
primaryStage.setScene(new Scene(gridView, 1000, 600));
primaryStage.setOnCloseRequest(x -> {
Platform.exit();
System.exit(0);
});
primaryStage.show();
}
private void addImagesToGrid(GridView<Image> gridView) {
for (int i = 0; i < 100; i++) {
final Image image = createFakeImage(i, CELL_SIZE);
gridView.getItems().add(image);
}
}
// Create an image with a bunch of rectangles in it just to have something to display.
private static Image createFakeImage(int imageIndex, int size) {
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
for (int i = 1; i < size; i ++) {
g.setColor(new Color(i * imageIndex % 256, i * 2 * (imageIndex + 40) % 256, i * 3 * (imageIndex + 60) % 256));
g.drawRect(i, i, size - i * 2, size - i * 2);
}
return SwingFXUtils.toFXImage(image, null);
}
}
The needed maven include is:
<dependency>
<groupId>org.controlsfx</groupId>
<artifactId>controlsfx</artifactId>
<version>8.0.6_20</version>
</dependency>
Here is a screen shot of what it looks like.

Maybe to late for the questioner. But after i stumbled across the same problem, after hours of investigation, hopefully useful to others...
I add a listener to the "onScrollProperty", but it is never called.
This "works as intended". :-/ See https://bugs.openjdk.java.net/browse/JDK-8096847.
You have to use "addEventFilter()". See example below.
To set the scroll position is a pain. You have to get the underlying "VirtualFlow" object of the GridView. VirtualFlow contains methods to set the scroll position to specific rows. It's strange that GridView seems to have no API for this common use case. :-(
A "prove of concept" example how to set scroll position, for a GridView with images:
/**
* Register a scroll event and a key event listener to gridView.
* After that navigate with "arrow up" and "arrow down" the grid.
* #param gridView
*/
private void addScrollAndKeyhandler(final GridView<Image> gridView) {
// example for scroll listener
gridView.addEventFilter(ScrollEvent.ANY, e-> System.out.println("*** scroll event fired ***"));
// add UP and DOWN arrow key listener, to set scroll position
gridView.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.UP) oneRowUp(gridView);
if (e.getCode() == KeyCode.DOWN) oneRowDown(gridView);
});
}
int selectedRow = 0; // current "selected" GridView row.
/**
* Scrolls the GridView one row up.
* #param gridView
*/
private void oneRowUp(final GridView<Image> gridView) {
// get the underlying VirtualFlow object
VirtualFlow<?> flow = (VirtualFlow<?>) ((GridViewSkin<?>) gridView.getSkin()).getChildren().get(0);
if (flow.getCellCount() == 0) return; // check that rows exist
if (--selectedRow < 0) selectedRow = 0;
if (selectedRow >= flow.cellCountProperty().get()) selectedRow = flow.getCellCount() - 1;
System.out.println("*** KeyEvent oneRowUp " + selectedRow + " ***");
flow.scrollTo(selectedRow);
}
/**
* Scrolls the GridView one row down.
* #param gridView
*/
private void oneRowDown(final GridView<Image> gridView) {
// get the underlying VirtualFlow object
VirtualFlow<?> flow = (VirtualFlow<?>) ((GridViewSkin<?>) gridView.getSkin()).getChildren().get(0);
if (flow.getCellCount() == 0) return; // check that rows exist
if (++selectedRow >= flow.cellCountProperty().get()) selectedRow = flow.getCellCount() - 1;
System.out.println("*** KeyEvent oneRowDown " + selectedRow + " ***");
flow.scrollTo(selectedRow);
}
(Tested with javafx 15.0.1 and controlsfx 11.0.3)

Related

how to differentiate between a single click or double click on a table row in javafx

I am trying to create a table in javafx that allows a user to click on a row to go to one page or double click the row to go to a different page. The problem is that the application registers the event of the single click, but does not wait to see if there is another double click. Is there a way to have the program wait and see if there is another click?
what i have so far looks similar to something like
TableView searchResults;
ObservableList<MovieRow> rows = FXCollections.observableArrayList();
private TableColumn<MovieRow, String> title;
title.setCellValueFactory(new PropertyValueFactory<>("mTitle"));
rows.add(new MovieRow("The cat in the hat"));
searchResults.setItems(rows);
searchResults.setRowFactory(tv -> {
TableRow<MovieRow> row = new TableRow<>();
row.setOnMouseClicked(event -> {
MovieRow tempResult = row.getItem();
if (event.getClickCount() == 1) {
System.out.println(tempResult.getMTitle + " was clicked once");
}else{
System.out.println(tempResult.getMTitle + " was clicked twice");
}
});
return row;
});
public class MovieRow{
private String mTitle;
public MovieRow(String title){
mTitle = title;
}
public String getMTitle() {
return mTitle;
}
}
actual output
single click: The cat in the hat was clicked once
double click: The cat in the hat was clicked once
desired output
single click: The cat in the hat was clicked once
double click: The cat in the hat was clicked twice
I've only found results on handling double clicks by themselves or single clicks by themselves but not having both, so I'm not sure if this is even possible. Any help would be much appreciated. Thanks.
There's no way to do this that's part of the API: you just have to code "have the program wait and see if there is another click" yourself. Note that this means that the single-click action has to have a slight pause before it's executed; there's no way around this (your program can't know what's going to happen in the future). You might consider a different approach (e.g. left button versus right button) to avoid this slightly inconvenient user experience.
However, a solution could look something like this:
public class DoubleClickHandler {
private final PauseTransition delay ;
private final Runnable onSingleClick ;
private final Runnable onDoubleClick ;
private boolean alreadyClickedOnce ;
public DoubleClickHandler(
Duration maxTimeBetweenClicks,
Runnable onSingleClick,
Runnable onDoubleClick) {
alreadyClickedOnce = false ;
this.onSingleClick = onSingleClick ;
this.onDoubleClick = onDoubleClick ;
delay = new PauseTransition(maxTimeBetweenClicks);
delay.setOnFinished(e -> {
alreadyClickedOnce = false ;
onSingleClick.run()
});
}
public void applyToNode(Node node) {
node.setOnMouseClicked(e -> {
delay.stop();
if (alreadyClickedOnce) {
alreadyClickedOnce = false ;
onDoubleClick.run();
} else {
alreadyClickedOnce = true ;
delay.playFromStart();
}
});
}
}
Which you can use with:
searchResults.setRowFactory(tv -> {
TableRow<MovieRow> row = new TableRow<>();
DoubleClickHandler handler = new DoubleClickHandler(
Duration.millis(500),
() -> {
MovieRow tempResult = row.getItem();
System.out.println(tempResult.getMTitle + " was clicked once");
},
() -> {
MovieRow tempResult = row.getItem();
System.out.println(tempResult.getMTitle + " was clicked twice");
}
);
handler.applyToNode(row);
return row ;
});
I encountered the same requirement once and worked on developing a custom event dispatcher. The solution what #James_D provided is clean, simple and works great. But if you want to generalize this behavior on a large scale, you can define a new custom mouse event and an event dispatcher.
The advantage of this approach is its usage will be just like other mouse events and can be handled in both event filters and handlers.
Please check the below demo and the appropriate code:
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.event.*;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.util.Duration;
public class DoubleClickEventDispatcherDemo extends Application {
#Override
public void start(Stage stage) throws Exception {
Rectangle box1 = new Rectangle(150, 150);
box1.setStyle("-fx-fill:red;-fx-stroke-width:2px;-fx-stroke:black;");
addEventHandlers(box1, "Red Box");
Rectangle box2 = new Rectangle(150, 150);
box2.setStyle("-fx-fill:yellow;-fx-stroke-width:2px;-fx-stroke:black;");
addEventHandlers(box2, "Yellow Box");
HBox pane = new HBox(box1, box2);
pane.setSpacing(10);
pane.setAlignment(Pos.CENTER);
addEventHandlers(pane, "HBox");
Scene scene = new Scene(new StackPane(pane), 450, 300);
stage.setScene(scene);
stage.show();
// THIS IS THE PART OF CODE SETTING CUSTOM EVENT DISPATCHER
scene.setEventDispatcher(new DoubleClickEventDispatcher(scene.getEventDispatcher()));
}
private void addEventHandlers(Node node, String nodeId) {
node.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> System.out.println("" + nodeId + " mouse clicked filter"));
node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> System.out.println("" + nodeId + " mouse clicked handler"));
node.addEventFilter(CustomMouseEvent.MOUSE_DOUBLE_CLICKED, e -> System.out.println("" + nodeId + " mouse double clicked filter"));
node.addEventHandler(CustomMouseEvent.MOUSE_DOUBLE_CLICKED, e -> System.out.println(nodeId + " mouse double clicked handler"));
}
/**
* Custom MouseEvent
*/
interface CustomMouseEvent {
EventType<MouseEvent> MOUSE_DOUBLE_CLICKED = new EventType<>(MouseEvent.ANY, "MOUSE_DBL_CLICKED");
}
/**
* Custom EventDispatcher to differentiate from single click with double click.
*/
class DoubleClickEventDispatcher implements EventDispatcher {
/**
* Default delay to fire a double click event in milliseconds.
*/
private static final long DEFAULT_DOUBLE_CLICK_DELAY = 215;
/**
* Default event dispatcher of a node.
*/
private final EventDispatcher defaultEventDispatcher;
/**
* Timeline for dispatching mouse clicked event.
*/
private Timeline clickedTimeline;
/**
* Constructor.
*
* #param initial Default event dispatcher of a node
*/
public DoubleClickEventDispatcher(final EventDispatcher initial) {
defaultEventDispatcher = initial;
}
#Override
public Event dispatchEvent(final Event event, final EventDispatchChain tail) {
final EventType<? extends Event> type = event.getEventType();
if (type == MouseEvent.MOUSE_CLICKED) {
final MouseEvent mouseEvent = (MouseEvent) event;
final EventTarget eventTarget = event.getTarget();
if (mouseEvent.getClickCount() > 1) {
if (clickedTimeline != null) {
clickedTimeline.stop();
clickedTimeline = null;
final MouseEvent dblClickedEvent = copy(mouseEvent, CustomMouseEvent.MOUSE_DOUBLE_CLICKED);
Event.fireEvent(eventTarget, dblClickedEvent);
}
return mouseEvent;
}
if (clickedTimeline == null) {
final MouseEvent clickedEvent = copy(mouseEvent, mouseEvent.getEventType());
clickedTimeline = new Timeline(new KeyFrame(Duration.millis(DEFAULT_DOUBLE_CLICK_DELAY), e -> {
Event.fireEvent(eventTarget, clickedEvent);
clickedTimeline = null;
}));
clickedTimeline.play();
return mouseEvent;
}
}
return defaultEventDispatcher.dispatchEvent(event, tail);
}
/**
* Creates a copy of the provided mouse event type with the mouse event.
*
* #param e MouseEvent
* #param eventType Event type that need to be created
* #return New mouse event instance
*/
private MouseEvent copy(final MouseEvent e, final EventType<? extends MouseEvent> eventType) {
return new MouseEvent(eventType, e.getSceneX(), e.getSceneY(), e.getScreenX(), e.getScreenY(),
e.getButton(), e.getClickCount(), e.isShiftDown(), e.isControlDown(), e.isAltDown(),
e.isMetaDown(), e.isPrimaryButtonDown(), e.isMiddleButtonDown(),
e.isSecondaryButtonDown(), e.isSynthesized(), e.isPopupTrigger(),
e.isStillSincePress(), e.getPickResult());
}
}
}

JavaFX focus on tab's content upon switch

I have a TabPane with a TextArea inside each of its Tabs.
What I want to achieve is when switching tabs, the textArea get focused.
I tried with a listener but it doesn't seem to work :
#FXML
public void initialize() {
for(Tab tab : tabPane.getTabs())
{
tab.setOnSelectionChanged(event->
{
if(tab.isSelected())
{
System.out.println(tab.getText());
TextArea ta = (TextArea)((AnchorPane)tab.getContent()).getChildren().get(0);
ta.requestFocus();
}
});
}
}
When I switch tabs, the output shows the active tab title but it stays focused, how can I focus on the TextArea after switching?
Thanks!
While it's not unusual that node.requestFocus() doesn't focus the node as expected (with the usual slightly smelly way around of wrapping it into Platform.runlater()) I'm interested why exactly it doesn't work in this context.
Turned out that one technical reason is that at the time of getting notified by any of the selection properties (selectedItem/-Index, isSelected) the node is not yet in a visible parent hierarchy - so it can't be a valid focus target. To see, add a println to the onSelected handler:
Node tabContent = tab.getContent();
if (tab.isSelected() && tab.getContent() != null && tab.getContent().getParent() != null ) {
System.out.println("onSelection " + tab.getText()
+ tabContent.getParent().isVisible());
}
That is due to skin's layout/management of tabs: the content of each is wrapped into a specialized StackPane (TabContentRegion), all these are stacked on top of each other with only the selected with its visibility property true.
So a first approximation for a solution is to register a listener to the visibility property of that container: when changed to true, its children should be eligable as focus targets. Which in fact they are .. just .. the TabPaneBehavior is interfering by forcing the focus onto the tabPane itself whenever selection is changed by user interaction (both by clicking the tab header and using ctrl-tab)
// unconditionally by mouse
new MouseMapping(MouseEvent.MOUSE_PRESSED, e -> getNode().requestFocus())
// method called by keyMappings that move the selection
private void moveSelection(int startIndex, int delta) {
final TabPane tabPane = getNode();
if (tabPane.getTabs().isEmpty()) return;
int tabIndex = findValidTab(startIndex, delta);
if (tabIndex > -1) {
final SelectionModel<Tab> selectionModel = tabPane.getSelectionModel();
selectionModel.select(tabIndex);
}
tabPane.requestFocus();
}
Next round: let the tabPane pass-on the focus whenever it gets focused during selection change. One sentence posing two stumble stones:
there is no public api to support transfer focus, it must be hacked around, f.i. by manually firing a TAB
during selection change needs state logic to decide its start and end
In all, looks like a task for a custom skin which is outlined (beware: not formally tested!) in the example below (it's for fx11, fx8 might be similar but requires to access internal classes because skins are not yet public)
public class TabPaneFocusOnSelectionSO extends Application {
/**
* Custom skin that tries to focus the first child of selected tab when
* selection changed.
*
*/
public static class MyTabPaneSkin extends TabPaneSkin {
private boolean selecting = true;
/**
* #param control
*/
public MyTabPaneSkin(TabPane control) {
super(control);
// TBD: dynamic update on changing tabs at runtime
addTabContentVisibilityListener(getChildren());
registerChangeListener(control.focusedProperty(), this::focusChanged);
registerChangeListener(control.getSelectionModel().selectedItemProperty(), e -> {
selecting = true;
});
}
/**
* Callback from listener to skinnable's focusedProperty.
*
* #param focusedProperty the property that's changed
*/
protected void focusChanged(ObservableValue focusedProperty) {
if (getSkinnable().isFocused() && selecting) {
transferFocus();
selecting = false;
}
}
/**
* Callback from listener to tab visibility.
*
* #param visibleProperty the property that's changed
*/
protected void tabVisibilityChanged(ObservableValue visibleProperty) {
BooleanProperty b = (BooleanProperty) visibleProperty;
if (b.get()) {
transferFocus();
}
}
/**
* No public api to transfer focus "away" from any node, hack by firing
* a TAB key on the TabPane.
*/
protected void transferFocus() {
final KeyEvent tabEvent = new KeyEvent(KeyEvent.KEY_PRESSED, "", "",
KeyCode.TAB, false, false, false, false);
Event.fireEvent(getSkinnable(), tabEvent);
}
/**
* Register the visibilityListener to each child in the given list that
* is a TabContentArea.
*
*/
protected void addTabContentVisibilityListener(List<? extends Node> children) {
children.forEach(node -> {
if (node.getStyleClass().contains("tab-content-area")) {
registerChangeListener(node.visibleProperty(), this::tabVisibilityChanged);
}
});
}
}
private TabPane tabPane;
private Parent createContent() {
tabPane = new TabPane() {
#Override
protected Skin<?> createDefaultSkin() {
return new MyTabPaneSkin(this);
}
};
for (int i = 0; i < 3; i++) {
VBox tabContent = new VBox();
tabContent.getChildren().addAll(new Button("dummy " +i), new TextField("just a field " + i));
Tab tab = new Tab("Tab " + i, tabContent);
tabPane.getTabs().add(tab);
}
tabPane.getTabs().add(new Tab("no content"));
tabPane.getTabs().add(new Tab("not focusable content", new Label("me!")));
BorderPane content = new BorderPane(tabPane);
return content;
}
#Override
public void start(Stage stage) throws Exception {
stage.setScene(new Scene(createContent()));
stage.setTitle(" TabPane with custom skin ");
stage.show();
}
public static void main(String[] args) {
launch(args);
}
}

JavaFX unwanted change event while removing items from Combobox

I have a JavaFX ComboBox, and I need to remove an item from it, but once an item is removed, it will trigger 3-4 unwanted change events. Can anybody help me to avoid those unwanted events?
My code is like this:
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.control.ComboBox;
public class ComboboxTest
{
private Boolean comboBoxRemovingMode = false;
public ComboBox<String> createCombo()
{
final ComboBox<String> myComboBox = new ComboBox<>();
myComboBox.getItems().addAll("prompt_txt", "A", "B", "C");
myComboBox.getSelectionModel().selectedIndexProperty().addListener(new ChangeListener<Number>() {
#Override
public void changed(final ObservableValue<? extends Number> ov, final Number oldvalue, final Number newvalue)
{
if (comboBoxRemovingMode) {
return;
}
if ((newvalue == null) || (newvalue.intValue() < 0) || (myComboBox.getItems().get(newvalue.intValue()).equalsIgnoreCase("prompt_txt"))) {
return;
}
synchronized (comboBoxRemovingMode) {
comboBoxRemovingMode = myComboBox.getItems().remove("prompt_txt");
// .... some actions
myComboBox.getSelectionModel().select(newvalue.intValue() - 1);
comboBoxRemovingMode = false;
}
}
});
return myComboBox;
}
public static void main(final String args[])
{
final ComboboxTest t = new ComboboxTest();
final ComboBox<String> box = t.createCombo();
box.getSelectionModel().select(1); // select A
System.out.println(box.getSelectionModel().getSelectedItem()); // it should be select "A", but it's B
}
}
From your code it looks like you have an item functioning as the prompt text of the ComboBox. When a new item is selected you want to remove the prompt text item while keeping the newly selected item selected1. To do this you only have to remove the prompt text item; there's no need to try and manually call select on the selection model—the new item is already selected by this point.
comboBox.getSelectionModel().selectedIndexProperty((obs, oldVal, newVal) -> {
int oldIndex = oldVal.intValue();
var items = comboBox.getItems();
if (oldIndex >= 0 && oldIndex < items.size() && items.get(oldIndex).equalsIgnoreCase("prompt_txt")) {
items.remove(oldIndex);
}
});
Here I use the old index assuming you have the "prompt_txt" item as the initially selected item.
This code will still result in two changes being fired since removing the "prompt_txt" item will change the indices of all the remaining items. This does not matter. The listener won't do anything for any subsequent notifications and the newly selected item remains the same. As the listener's only job appears to be to remove the "prompt_txt", however, it may be prudent to remove the listener after the first notification. One way of doing this is the following:
comboBox.getSelectionModel().selectedIndexProperty().addListener(new ChangeListener<>() {
#Override
public void changed(ObservableValue<? extends Number> obs, Number oldVal, Number newVal) {
int oldIndex = oldVal.intValue();
var items = comboBox.getItems();
if (oldIndex >= 0 && oldIndex < items.size() && items.get(oldIndex).equalsIgnoreCase("prompt_txt")) {
obs.removeListener(this); // Needed anonymous class to reference "this"
items.remove(oldIndex);
}
}
});
In this case, since I remove the listener before calling remove(oldIndex), the listener is only notified once.
However
All that said, there's no need to add a special item to represent the prompt text. The ComboBoxBase class, which ComboBox inherits from, has the promptText property. Here's the documentation:
The ComboBox prompt text to display, or null if no prompt text is displayed. Prompt text is not displayed in all circumstances, it is dependent upon the subclasses of ComboBoxBase to clarify when promptText will be shown. For example, in most cases prompt text will never be shown when a combo box is non-editable (that is, prompt text is only shown when user input is allowed via text input).
Some quick testing shows ComboBox displays the prompt text even when not editable (as long as no items are selected).
1. You have // some actions... between remove("prompt_text") and select(newvalue.intValue() - 1). What those actions are may completely invalidate my answer.

Implementing tab functionality for CheckBox cells in TableView

I've created a TableView where each cell contains a TextField or a CheckBox. In the TableView you're supposed to be able to navigate left and right between cells using TAB and SHIFT+TAB, and up and down between cells using the UP and DOWN keys.
This works perfectly when a text field cell is focused. But when a check box cell is focused, the tab funcationality behaves strange. You can tab in the opposite direction of the cell you tabbed from, but you can't switch tab direction.
So for instance if you tabbed to the check box cell using only the TAB key, then SHIFT+TAB wont work. But if you tab to the next cell using the TAB key, and then TAB back using SHIFT+TAB (assuming the next cell is a text field cell), then TAB wont work.
I've tried running any code handling focus on the UI thread using Platform.runLater(), without any noteable difference. All I know is that the TAB KeyEvent is properly catched, but the check box cell and the check box never loses focus in the first place anyway. I've tried for instance removing its focus manually by doing e.g. getParent().requestFocus() but that just results in the parent being focused instead of the next cell. What makes it strange is that the same code is executed and working properly when you tab in the opposite direction of the cell you came from.
Here's a MCVE on the issue. Sadly it does not really live up to the "M" of the abbreviation:
import java.util.List;
import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.CheckBox;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Stage;
public class AlwaysEditableTable extends Application {
public void start(Stage stage) {
TableView<ObservableList<StringProperty>> table = new TableView<>();
table.setEditable(true);
table.getSelectionModel().setCellSelectionEnabled(true);
table.setPrefWidth(510);
// Dummy columns
ObservableList<String> columns = FXCollections.observableArrayList("Column1", "Column2", "Column3", "Column4",
"Column5");
// Dummy data
ObservableList<StringProperty> row1 = FXCollections.observableArrayList(new SimpleStringProperty("Cell1"),
new SimpleStringProperty("Cell2"), new SimpleStringProperty("0"), new SimpleStringProperty("Cell4"),
new SimpleStringProperty("0"));
ObservableList<StringProperty> row2 = FXCollections.observableArrayList(new SimpleStringProperty("Cell1"),
new SimpleStringProperty("Cell2"), new SimpleStringProperty("1"), new SimpleStringProperty("Cell4"),
new SimpleStringProperty("0"));
ObservableList<StringProperty> row3 = FXCollections.observableArrayList(new SimpleStringProperty("Cell1"),
new SimpleStringProperty("Cell2"), new SimpleStringProperty("1"), new SimpleStringProperty("Cell4"),
new SimpleStringProperty("0"));
ObservableList<ObservableList<StringProperty>> data = FXCollections.observableArrayList(row1, row2, row3);
for (int i = 0; i < columns.size(); i++) {
final int j = i;
TableColumn<ObservableList<StringProperty>, String> col = new TableColumn<>(columns.get(i));
col.setCellValueFactory(param -> param.getValue().get(j));
col.setPrefWidth(100);
if (i == 2 || i == 4) {
col.setCellFactory(e -> new CheckBoxCell(j));
} else {
col.setCellFactory(e -> new AlwaysEditingCell(j));
}
table.getColumns().add(col);
}
table.setItems(data);
Scene scene = new Scene(table);
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch();
}
/**
* A cell that contains a text field that is always shown.
*/
public static class AlwaysEditingCell extends TableCell<ObservableList<StringProperty>, String> {
private final TextField textField;
public AlwaysEditingCell(int columnIndex) {
textField = new TextField();
this.emptyProperty().addListener((obs, wasEmpty, isNowEmpty) -> {
if (isNowEmpty) {
setGraphic(null);
} else {
setGraphic(textField);
}
});
// The index is not changed until tableData is instantiated, so this
// ensure the we wont get a NullPointerException when we do the
// binding.
this.indexProperty().addListener((obs, oldValue, newValue) -> {
ObservableList<ObservableList<StringProperty>> tableData = getTableView().getItems();
int oldIndex = oldValue.intValue();
if (oldIndex >= 0 && oldIndex < tableData.size()) {
textField.textProperty().unbindBidirectional(tableData.get(oldIndex).get(columnIndex));
}
int newIndex = newValue.intValue();
if (newIndex >= 0 && newIndex < tableData.size()) {
textField.textProperty().bindBidirectional(tableData.get(newIndex).get(columnIndex));
setGraphic(textField);
} else {
setGraphic(null);
}
});
// Every time the cell is focused, the focused is passed down to the
// text field and all of the text in the textfield is selected.
this.focusedProperty().addListener((obs, oldValue, newValue) -> {
if (newValue) {
textField.requestFocus();
textField.selectAll();
System.out.println("Cell focused!");
}
});
// Switches focus to the cell below if ENTER or the DOWN arrow key
// is pressed, and to the cell above if the UP arrow key is pressed.
// Works like a charm. We don't have to add any functionality to the
// TAB key in these cells because the default tab behavior in
// JavaFX works here.
this.addEventFilter(KeyEvent.KEY_RELEASED, e -> {
if (e.getCode().equals(KeyCode.UP)) {
getTableView().getFocusModel().focus(getIndex() - 1, this.getTableColumn());
e.consume();
} else if (e.getCode().equals(KeyCode.DOWN)) {
getTableView().getFocusModel().focus(getIndex() + 1, this.getTableColumn());
e.consume();
} else if (e.getCode().equals(KeyCode.ENTER)) {
getTableView().getFocusModel().focus(getIndex() + 1, this.getTableColumn());
e.consume();
}
});
}
}
/**
* A cell containing a checkbox. The checkbox represent the underlying value
* in the cell. If the cell value is 0, the checkbox is unchecked. Checking
* or unchecking the checkbox will change the underlying value.
*/
public static class CheckBoxCell extends TableCell<ObservableList<StringProperty>, String> {
private final CheckBox box;
public CheckBoxCell(int columnIndex) {
this.box = new CheckBox();
this.emptyProperty().addListener((obs, wasEmpty, isNowEmpty) -> {
if (isNowEmpty) {
setGraphic(null);
} else {
setGraphic(box);
}
});
this.indexProperty().addListener((obs, oldValue, newValue) -> {
// System.out.println("Row: " + getIndex() + ", Column: " +
// columnIndex + ". Old index: " + oldValue
// + ". New Index: " + newValue);
ObservableList<ObservableList<StringProperty>> tableData = getTableView().getItems();
int newIndex = newValue.intValue();
if (newIndex >= 0 && newIndex < tableData.size()) {
// If active value is "1", the check box will be set to
// selected.
box.setSelected(tableData.get(getIndex()).get(columnIndex).equals("1"));
// We add a listener to the selected property. This will
// allow us to execute code every time the check box is
// selected or deselected.
box.selectedProperty().addListener((observable, oldVal, newVal) -> {
if (newVal) {
// If newValue is true the checkBox is selected, and
// we set the corresponding cell value to "1".
tableData.get(getIndex()).get(columnIndex).set("1");
} else {
// Otherwise we set it to "0".
tableData.get(getIndex()).get(columnIndex).set("0");
}
});
setGraphic(box);
} else {
setGraphic(null);
}
});
// If I listen to KEY_RELEASED instead, pressing tab next to a
// checkbox will make the focus jump past the checkbox cell. This is
// probably because the default TAB functionality is invoked on key
// pressed, which switches the focus to the check box cell, and then
// upon release this EventFilter catches it and switches focus
// again.
this.addEventFilter(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode().equals(KeyCode.UP)) {
System.out.println("UP key pressed in checkbox");
getTableView().getFocusModel().focus(getIndex() - 1, this.getTableColumn());
e.consume();
} else if (e.getCode().equals(KeyCode.DOWN)) {
System.out.println("DOWN key pressed in checkbox");
getTableView().getFocusModel().focus(getIndex() + 1, this.getTableColumn());
e.consume();
} else if (e.getCode().equals(KeyCode.TAB)) {
System.out.println("Checkbox TAB pressed!");
TableColumn<ObservableList<StringProperty>, ?> nextColumn = getNextColumn(!e.isShiftDown());
if (nextColumn != null) {
getTableView().getFocusModel().focus(getIndex(), nextColumn);
}
e.consume();
// ENTER key will set the check box to selected if it is
// unselected and vice versa.
} else if (e.getCode().equals(KeyCode.ENTER)) {
box.setSelected(!box.isSelected());
e.consume();
}
});
// Tracking the focused property of the check box for debug
// purposes.
box.focusedProperty().addListener((obs, oldValue, newValue) ->
{
if (newValue) {
System.out.println("Box focused on index " + getIndex());
} else {
System.out.println("Box unfocused on index " + getIndex());
}
});
// Tracking the focused property of the check box for debug
// purposes.
this.focusedProperty().addListener((obs, oldValue, newValue) ->
{
if (newValue) {
System.out.println("Box cell focused on index " + getIndex());
box.requestFocus();
} else {
System.out.println("Box cell unfocused on index " + getIndex());
}
});
}
/**
* Gets the column to the right or to the left of the current column
* depending no the value of forward.
*
* #param forward
* If true, the column to the right of the current column
* will be returned. If false, the column to the left of the
* current column will be returned.
*/
private TableColumn<ObservableList<StringProperty>, ?> getNextColumn(boolean forward) {
List<TableColumn<ObservableList<StringProperty>, ?>> columns = getTableView().getColumns();
// If there's less than two columns in the table view we return null
// since there can be no column to the right or left of this
// column.
if (columns.size() < 2) {
return null;
}
// We get the index of the current column and then we get the next
// or previous index depending on forward.
int currentIndex = columns.indexOf(getTableColumn());
int nextIndex = currentIndex;
if (forward) {
nextIndex++;
if (nextIndex > columns.size() - 1) {
nextIndex = 0;
}
} else {
nextIndex--;
if (nextIndex < 0) {
nextIndex = columns.size() - 1;
}
}
// We return the column on the next index.
return columns.get(nextIndex);
}
}
}
After some digging in the TableView source code I found the issue. Here's the source code for the focus(int row, TableColumn<S, ?> column) method:
#Override public void focus(int row, TableColumn<S,?> column) {
if (row < 0 || row >= getItemCount()) {
setFocusedCell(EMPTY_CELL);
} else {
TablePosition<S,?> oldFocusCell = getFocusedCell();
TablePosition<S,?> newFocusCell = new TablePosition<>(tableView, row, column);
setFocusedCell(newFocusCell);
if (newFocusCell.equals(oldFocusCell)) {
// manually update the focus properties to ensure consistency
setFocusedIndex(row);
setFocusedItem(getModelItem(row));
}
}
}
The issue arises when newFocusCell is compared to oldFocusCell. When tabbing to a checkbox cell the cell would for some reason not get set as the focused cell. Hence the focusedCell property returned by getFocusedCell() will be the cell we focused before the check box cell. So when we then try to focus that previous cell again, newFocusCell.equals(oldFocusCell) will return true, and the focus will be set to the currently focused cell again by doing:
setFocusedIndex(row);
setFocusedItem(getModelItem(row));`
So what I had to do was make sure that the cell isn't be the value of the focusedCell property when we want to focus it. I solved this by setting the focus manually to the whole table before trying to switch the focus from the check box cell:
table.requestFocus();

How to get JavaFX TreeView to behave consistently upon node expansion?

I have a JavaFX TreeView with an invisible root and a handful of 'folder' TreeItems that have many 'file' TreeItems as children. The 'folder' TreeItems typically fit inside the TreeView without there being any scrollbars.
invisible-root/
folder/
folder/
folder/
file
file
file
...
file
Sometimes, when I expand a 'folder' TreeItem, the scrollbars appear but the scroll position remains the same. (This is what I want!) However, sometimes, expanding a TreeItem causes the scrollbars appear and the TableView scrolls to the last child of the expanded TreeItem!
This is very unexpected and surprising, especially since I have difficulty predicting which of the two behaviors I will see: (1) stay put, or (2) scroll to last item. Personally, I think behavior (1) is less surprising and preferable.
Any thoughts on how to deal with this?
I see this behavior on Java8u31.
The problem is in VirtualFlow. In layoutChildren() there is this section:
if (lastCellCount != cellCount) {
// The cell count has changed. We want to keep the viewport
// stable if possible. If position was 0 or 1, we want to keep
// the position in the same place. If the new cell count is >=
// the currentIndex, then we will adjust the position to be 1.
// Otherwise, our goal is to leave the index of the cell at the
// top consistent, with the same translation etc.
if (position == 0 || position == 1) {
// Update the item count
// setItemCount(cellCount);
} else if (currentIndex >= cellCount) {
setPosition(1.0f);
// setItemCount(cellCount);
} else if (firstCell != null) {
double firstCellOffset = getCellPosition(firstCell);
int firstCellIndex = getCellIndex(firstCell);
// setItemCount(cellCount);
adjustPositionToIndex(firstCellIndex);
double viewportTopToCellTop = -computeOffsetForCell(firstCellIndex);
adjustByPixelAmount(viewportTopToCellTop - firstCellOffset);
}
The problem arises if position is 1.0 (== scrolled to bottom), because in that case there is no recalculation. A workaround would be to override the TreeViewSkin to provide your own VirtualFlow and fix the behavior there.
The code below is meant to illustrate the problem, it's not a real solution, just a starting point if you really want to fix it:
import com.sun.javafx.scene.control.skin.TreeViewSkin;
import com.sun.javafx.scene.control.skin.VirtualFlow;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.IndexedCell;
import javafx.scene.control.Skin;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
public class TreeViewScrollBehaviour extends Application {
#Override
public void start(Stage primaryStage) {
TreeView treeView = new TreeView() {
#Override
protected Skin createDefaultSkin() {
return new TTreeViewSkin(this); //To change body of generated methods, choose Tools | Templates.
}
};
TreeItem<String> treeItem = new TreeItem<String>("Root");
for (int i = 0; i < 20; i++) {
TreeItem<String> treeItem1 = new TreeItem<>("second layer " + i);
treeItem.getChildren().add(treeItem1);
for (int j = 0; j < 20; j++) {
treeItem1.getChildren().add(new TreeItem<>("Third Layer " + j));
}
}
treeView.setRoot(treeItem);
StackPane root = new StackPane();
root.getChildren().addAll(treeView);
Scene scene = new Scene(root, 300, 250);
primaryStage.setTitle("Hello World!");
primaryStage.setScene(scene);
primaryStage.show();
}
/**
* #param args the command line arguments
*/
public static void main(String[] args) {
launch(args);
}
class TTreeViewSkin<T extends IndexedCell> extends TreeViewSkin<T> {
public TTreeViewSkin(TreeView treeView) {
super(treeView);
}
#Override
protected VirtualFlow createVirtualFlow() {
return new TVirtualFlow<T>(); //To change body of generated methods, choose Tools | Templates.
}
}
class TVirtualFlow<T extends IndexedCell> extends VirtualFlow<T> {
#Override
public double getPosition() {
double position = super.getPosition();
if (position == 1.0d) {
return 0.99999999999;
}
return super.getPosition(); //To change body of generated methods, choose Tools | Templates.
}
#Override
public void setPosition(double newPosition) {
if (newPosition == 1.0d) {
newPosition = 0.99999999999;
}
super.setPosition(newPosition); //To change body of generated methods, choose Tools | Templates.
}
}
}

Resources