-
-
Save TomasMikula/6c5d97edc51ec8fa3d9e to your computer and use it in GitHub Desktop.
Using ReactFX to avoid multiple layouts.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package test.layoutproblem; | |
import static reactfx.EventStreams.*; | |
import java.util.ArrayList; | |
import java.util.List; | |
import javafx.application.Application; | |
import javafx.application.Platform; | |
import javafx.beans.InvalidationListener; | |
import javafx.beans.Observable; | |
import javafx.beans.property.BooleanProperty; | |
import javafx.beans.property.ObjectProperty; | |
import javafx.beans.property.SimpleBooleanProperty; | |
import javafx.beans.property.SimpleObjectProperty; | |
import javafx.beans.property.StringProperty; | |
import javafx.beans.value.ChangeListener; | |
import javafx.beans.value.ObservableValue; | |
import javafx.collections.FXCollections; | |
import javafx.collections.ListChangeListener; | |
import javafx.collections.ObservableList; | |
import javafx.event.EventHandler; | |
import javafx.geometry.Pos; | |
import javafx.scene.Node; | |
import javafx.scene.Scene; | |
import javafx.scene.control.Button; | |
import javafx.scene.control.Label; | |
import javafx.scene.control.TreeCell; | |
import javafx.scene.control.TreeItem; | |
import javafx.scene.control.TreeView; | |
import javafx.scene.input.KeyCode; | |
import javafx.scene.input.KeyCodeCombination; | |
import javafx.scene.input.KeyCombination; | |
import javafx.scene.input.KeyEvent; | |
import javafx.scene.layout.BorderPane; | |
import javafx.scene.layout.FlowPane; | |
import javafx.scene.layout.HBox; | |
import javafx.scene.layout.Priority; | |
import javafx.scene.layout.VBox; | |
import javafx.scene.paint.Color; | |
import javafx.stage.Stage; | |
import javafx.util.Callback; | |
import reactfx.EventSource; | |
import reactfx.EventStream; | |
import reactfx.Indicator; | |
public class LayoutProblem extends Application { | |
public static void main(String[] args) { | |
Application.launch(args); | |
} | |
@Override | |
public void start(Stage primaryStage) throws Exception { | |
BorderPane borderPane = new BorderPane(); | |
TreeListPane treeListPane = new TreeListPane(); | |
borderPane.setTop(new Button("A")); // here to give something other than the TreeView the focus | |
borderPane.setCenter(treeListPane); | |
MediaNode s1 = new MediaNode("Season 1", "S1", false, null); | |
MediaNode s2 = new MediaNode("Season 2", "S2", false, null); | |
s1.add(new MediaNode("Episode 1x01", "1x01", true, null)); | |
s1.add(new MediaNode("Episode 1x02", "1x02", true, null)); | |
s2.add(new MediaNode("Episode 2x01", "2x01", true, null)); | |
s2.add(new MediaNode("Episode 2x02", "2x02", true, null)); | |
/* | |
* Below, the treeListPane is configured. This involves setting up which nodes to display (mediaNodes) | |
* and whether or not the Tabs should be shown (expandTopLevel). | |
* | |
* Because changing either one of these properties must result in the (internal) TreeView's root to be | |
* changed, this is "expensive" (especially if the TreeItem's that are temporarily | |
* visible are the wrong ones and trigger background loads of images for example). Hence, the call | |
* to TreeListPane#buildTree should only occur ONCE, even when both mediaNodes and expandTopLevel are | |
* changed. | |
* | |
* This is accomplished internally by having an InvalidationListener on both these properties. The | |
* InvalidationListener calls requestLayout whenever buildTree was not called yet. In one of the | |
* compute* methods of TreeListPane this gets resolved and buildTree is called. | |
* | |
* Note: the tabs are not clickable (not a requirement), but you can use the cursor left/right to | |
* change them. | |
*/ | |
treeListPane.beingUpdated.onWhile(() -> { | |
treeListPane.mediaNodes.addAll(s1, s2); | |
treeListPane.expandTopLevel.set(true); | |
treeListPane.focusedMediaNode.set(s1.children.get(1)); | |
}); | |
Scene scene = new Scene(borderPane); | |
scene.setFill(Color.BLACK); | |
scene.getStylesheets().add("test/layoutproblem/default.css"); | |
primaryStage.setScene(scene); | |
primaryStage.show(); | |
} | |
public static class TreeListPane extends BorderPane { | |
private static final KeyCombination LEFT = new KeyCodeCombination(KeyCode.LEFT); | |
private static final KeyCombination RIGHT = new KeyCodeCombination(KeyCode.RIGHT); | |
public final ObservableList<MediaNode> mediaNodes = FXCollections.observableArrayList(); | |
public final ObjectProperty<MediaNode> focusedMediaNode = new SimpleObjectProperty<>(); | |
public final BooleanProperty expandTopLevel = new SimpleBooleanProperty(); | |
private final TreeView<MediaNode> treeView = new TreeView<>(); | |
private final Filter filter = new Filter() {{ | |
getStyleClass().add("seasons"); // FIXME this is way too specific | |
}}; | |
private final InvalidationListener invalidateTreeListener = new InvalidationListener() { | |
@Override | |
public void invalidated(Observable observable) { | |
beingUpdated.onWhile(() -> treeInvalidations.push(null)); | |
} | |
}; | |
private final ChangeListener<TreeItem<MediaNode>> updateFocusedMediaNode = new ChangeListener<TreeItem<MediaNode>>() { | |
@Override | |
public void changed(ObservableValue<? extends TreeItem<MediaNode>> observable, TreeItem<MediaNode> old, TreeItem<MediaNode> current) { | |
focusedMediaNode.set(current == null ? null : current.getValue()); | |
} | |
}; | |
public final Indicator beingUpdated = new Indicator(); | |
private final EventSource<Void> treeInvalidations = new EventSource<>(); | |
private final EventStream<Void> buildTreeImpulse = emit(treeInvalidations).on(beingUpdated.offs()); | |
public TreeListPane() { | |
focusedMediaNode.addListener(new ChangeListener<MediaNode>() { | |
@Override | |
public void changed(ObservableValue<? extends MediaNode> observable, MediaNode old, MediaNode current) { | |
setSelectedNode(current); | |
} | |
}); | |
treeView.getStyleClass().add("main-list"); | |
treeView.setEditable(false); | |
treeView.setShowRoot(false); | |
treeView.addEventHandler(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() { | |
@Override | |
public void handle(KeyEvent event) { | |
if(LEFT.match(event)) { | |
filter.activatePrevious(); | |
treeView.getFocusModel().focus(0); | |
event.consume(); | |
} | |
else if(RIGHT.match(event)) { | |
filter.activateNext(); | |
treeView.getFocusModel().focus(0); | |
event.consume(); | |
} | |
} | |
}); | |
filter.activeProperty().addListener(new ChangeListener<Node>() { | |
@Override | |
public void changed(ObservableValue<? extends Node> observable, Node oldValue, Node value) { | |
Label oldLabel = (Label)oldValue; | |
Label label = (Label)value; | |
if(oldLabel != null) { | |
oldLabel.setText(((MediaNodeTreeItem)oldValue.getUserData()).getValue().getShortTitle()); | |
} | |
label.setText(((MediaNodeTreeItem)value.getUserData()).getValue().getTitle()); | |
refilter(); | |
} | |
}); | |
expandTopLevel.addListener(invalidateTreeListener); | |
mediaNodes.addListener(invalidateTreeListener); | |
buildTreeImpulse.subscribe(impulse -> buildTree()); | |
setTop(filter); | |
setCenter(treeView); | |
} | |
private void buildTree() { | |
System.out.println(">>>>>>> buildTree"); | |
treeView.getFocusModel().focusedItemProperty().removeListener(updateFocusedMediaNode); // prevent focus updates from changing tree root | |
treeView.setCellFactory(new Callback<TreeView<MediaNode>, TreeCell<MediaNode>>() { | |
@Override | |
public TreeCell<MediaNode> call(TreeView<MediaNode> param) { | |
return new MediaItemTreeCell(); | |
} | |
}); | |
filter.getChildren().clear(); | |
if(expandTopLevel.get()) { | |
for(MediaNode node : mediaNodes) { | |
Label label = new Label(node.getShortTitle()); | |
filter.getChildren().add(label); | |
label.setUserData(new MediaNodeTreeItem(node)); | |
} | |
} | |
else { | |
treeView.setRoot(new MediaNodeTreeItem(new MediaNode("root", "root", false, mediaNodes), false)); | |
} | |
treeView.getFocusModel().focusedItemProperty().addListener(updateFocusedMediaNode); | |
setSelectedNode(focusedMediaNode.get()); | |
} | |
@Override | |
public void requestFocus() { | |
treeView.requestFocus(); | |
} | |
private void refilter() { | |
MediaNodeTreeItem group = (MediaNodeTreeItem)filter.activeProperty().get().getUserData(); | |
treeView.setRoot(group); | |
} | |
private final class MediaNodeTreeItem extends TreeItem<MediaNode> { | |
private final boolean isLeaf; | |
private boolean childrenPopulated; | |
MediaNodeTreeItem(MediaNode value, boolean isLeaf) { | |
super(value); | |
this.isLeaf = isLeaf; | |
} | |
MediaNodeTreeItem(MediaNode value) { | |
this(value, value.isLeaf()); | |
} | |
@Override | |
public boolean isLeaf() { | |
return isLeaf; | |
} | |
@Override | |
public ObservableList<TreeItem<MediaNode>> getChildren() { | |
ObservableList<TreeItem<MediaNode>> treeChildren = super.getChildren(); | |
if(!childrenPopulated) { | |
childrenPopulated = true; | |
for(MediaNode child : getValue().getChildren()) { | |
treeChildren.add(new MediaNodeTreeItem(child)); | |
} | |
} | |
return treeChildren; | |
} | |
} | |
private final class MediaItemTreeCell extends TreeCell<MediaNode> { | |
private final DuoLineCell duoLineCell = new DuoLineCell(); | |
@Override | |
protected void updateItem(final MediaNode mediaNode, boolean empty) { | |
super.updateItem(mediaNode, empty); | |
if(empty) { | |
setGraphic(null); | |
return; | |
} | |
duoLineCell.titleProperty().set(mediaNode.getTitle()); | |
double maxWidth = treeView.getWidth() - 35; | |
duoLineCell.setMaxWidth(maxWidth); // WORKAROUND for being unable to restrict cells to the width of the view | |
duoLineCell.setPrefWidth(maxWidth); | |
setGraphic(duoLineCell); | |
} | |
} | |
private void setSelectedNode(MediaNode selectedMediaNode) { | |
TreeItem<MediaNode> focusedTreeItem = treeView.getFocusModel().getFocusedItem(); | |
if(selectedMediaNode == null || (focusedTreeItem != null && selectedMediaNode.equals(focusedTreeItem.getValue()))) { | |
return; | |
} | |
List<MediaNode> stack = new ArrayList<>(); | |
stack.add(selectedMediaNode); | |
while(stack.get(0).getParent() != null) { | |
stack.add(0, stack.get(0).getParent()); | |
} | |
/* | |
* First level of the stack matches the filter (if used). This needs to be set | |
* correctly first to create a new root in the TreeView. | |
*/ | |
if(!filter.getChildren().isEmpty()) { | |
for(Node n : filter.getChildren()) { | |
if(((MediaNodeTreeItem)n.getUserData()).getValue().equals(stack.get(0))) { | |
filter.activeProperty().set(n); | |
stack.remove(0); | |
break; | |
} | |
} | |
} | |
/* | |
* Get the TreeView's root, which either existed already or was just setup by | |
* adjusting the filter. | |
*/ | |
TreeItem<MediaNode> treeItemForSelection = treeView.getRoot(); | |
if(treeItemForSelection != null) { | |
/* | |
* Expand all TreeItem's towards the MediaNode to be selected. | |
*/ | |
for(MediaNode node : stack) { | |
treeItemForSelection.setExpanded(true); | |
treeItemForSelection = findTreeItem(treeItemForSelection, node); | |
if(treeItemForSelection == null) { | |
break; | |
} | |
} | |
/* | |
* If after the search for the MediaNode to be selected we found a matching TreeItem | |
* that can be selected, it will be focused and scrolled to. | |
*/ | |
if(treeItemForSelection != null) { | |
final int index = treeView.getRow(treeItemForSelection); | |
treeView.getFocusModel().focus(index); | |
Platform.runLater(new Runnable() { | |
@Override | |
public void run() { | |
treeView.scrollTo(index); | |
} | |
}); | |
} | |
} | |
} | |
private static TreeItem<MediaNode> findTreeItem(TreeItem<MediaNode> root, MediaNode mediaNode) { | |
for(TreeItem<MediaNode> child : root.getChildren()) { | |
if(child.getValue().equals(mediaNode)) { | |
return child; | |
} | |
} | |
return null; | |
} | |
} | |
public static class Filter extends FlowPane { | |
private final ObjectProperty<Node> active = new SimpleObjectProperty<>(); | |
public ObjectProperty<Node> activeProperty() { return active; } | |
public Filter() { | |
getStyleClass().add("filter"); | |
getChildren().addListener(new ListChangeListener<Node>() { | |
@Override | |
public void onChanged(ListChangeListener.Change<? extends Node> event) { | |
update(); | |
} | |
}); | |
active.addListener(new ChangeListener<Node>() { | |
@Override | |
public void changed(ObservableValue<? extends Node> arg0, Node arg1, Node arg2) { | |
update(); | |
} | |
}); | |
} | |
public void activateNext() { | |
if(!getChildren().isEmpty()) { | |
int index = getChildren().indexOf(active.get()); | |
index++; | |
if(index >= getChildren().size()) { | |
index = 0; | |
} | |
active.set(getChildren().get(index)); | |
} | |
} | |
public void activatePrevious() { | |
if(!getChildren().isEmpty()) { | |
int index = getChildren().indexOf(active.get()); | |
index--; | |
if(index < 0) { | |
index = getChildren().size() - 1; | |
} | |
active.set(getChildren().get(index)); | |
} | |
} | |
private void update() { | |
Node activeNode = this.active.get(); | |
for(Node node : getChildren()) { | |
node.setDisable(!node.equals(activeNode)); | |
} | |
} | |
} | |
public static class MediaNode { | |
private final String title; | |
private final List<MediaNode> children; | |
private final String shortTitle; | |
private final boolean isLeaf; | |
private MediaNode parent; | |
public MediaNode(String title, String shortTitle, boolean isLeaf, List<MediaNode> children) { | |
this.title = title; | |
this.shortTitle = shortTitle; | |
this.isLeaf = isLeaf; | |
this.children = children == null ? new ArrayList<>() : new ArrayList<>(children); | |
} | |
public String getShortTitle() { | |
return shortTitle; | |
} | |
public List<MediaNode> getChildren() { | |
return children; | |
} | |
public boolean isLeaf() { | |
return isLeaf; | |
} | |
public MediaNode getParent() { | |
return parent; | |
} | |
public String getTitle() { | |
return title; | |
} | |
public void add(MediaNode child) { | |
if(child.parent != null) { | |
throw new IllegalStateException("cannot add child twice: " + child); | |
} | |
child.parent = this; | |
children.add(child); | |
} | |
} | |
public static class DuoLineCell extends HBox { | |
public StringProperty titleProperty() { return title.textProperty(); } | |
private final Label title = new Label() {{ | |
getStyleClass().add("title"); | |
}}; | |
private final VBox description = new VBox() {{ | |
getChildren().addAll(title); | |
}}; | |
public DuoLineCell() { | |
setFillHeight(false); | |
setAlignment(Pos.CENTER_LEFT); | |
HBox.setHgrow(description, Priority.ALWAYS); | |
getChildren().addAll(description); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Posted in reply to openjfx-dev.
Related to RT-35830.