Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to select multiple rows in JavaFX ListView with mouse drag

I'm new to JavaFX and I just can't seem to find how to do this.

I have a ListView inside a Vbox that I populate with an ObservableList of Strings. I've set the SelectionMode of the ListView to MULTIPLE and that has allowed me to select multiple items while holding the Ctrl or Shift keys.

I'd like to be able to click on a row and drag the mouse down and select multiple rows but I can't figure out how to do this. I've tried several searches and seem to only find Drag and Drop and that's not what I need.

@FXML private ListView availableColumnList;

private ObservableList<String> availableColumns = FXCollections.<String>observableArrayList("One","Two","Three","Four");

availableColumnList.getItems().addAll(availableColumns);

availableColumnList.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
like image 743
Faye Avatar asked Aug 05 '19 20:08

Faye


1 Answers

If you're using JavaFX 10+ then you can extend ListViewSkin and add the functionality there. The reason you need JavaFX 10 or later is because that's when the VirtualContainerBase class, which ListViewSkin extends, had the getVirtualFlow() method added. You can then use the animation API, such as an AnimationTimer, to scroll the ListView via the VirtualFlow#scrollPixels(double) method.

Below is a proof-of-concept. All it does is auto-scroll the ListView when the mouse is near the top (or left) or near the bottom (or right) of the ListView. When the mouse enters a cell, the item is selected (crudely). If you want to deselect items if you start dragging the mouse in the opposite direction then you need to implement that yourself. Another thing you probably want to implement is stopping the AnimationTimer if the ListView is hidden or removed from the scene.

Note: The below uses a "full press-drag-release" gesture. In other words, there's a mixture of MouseEvent handlers and MouseDragEvent handlers. The reason for using MouseDragEvents is because they can be delivered to other nodes, not just the original (unlike with a "simple press-drag-release" gesture). Check out this documentation for more information.

Main.java

import java.util.stream.Collectors;
import java.util.stream.IntStream;
import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.scene.Scene;
import javafx.scene.control.ListView;
import javafx.scene.control.SelectionMode;
import javafx.stage.Stage;

public final class Main extends Application {

    @Override
    public void start(Stage primaryStage) {
        var listView = IntStream.range(0, 1000)
                .mapToObj(Integer::toString)
                .collect(Collectors.collectingAndThen(
                        Collectors.toCollection(FXCollections::observableArrayList),
                        ListView::new
                ));
        // Sets the custom skin. Can also be set via CSS.
        listView.setSkin(new CustomListViewSkin<>(listView));
        listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
        primaryStage.setScene(new Scene(listView, 600, 400));
        primaryStage.show();
    }

}

CustomListViewSkin.java

import javafx.animation.AnimationTimer;
import javafx.geometry.Rectangle2D;
import javafx.scene.control.ListView;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.skin.ListViewSkin;
import javafx.scene.input.MouseDragEvent;
import javafx.scene.input.MouseEvent;

public class CustomListViewSkin<T> extends ListViewSkin<T> {

    private static final double DISTANCE = 10;
    private static final double PERCENTAGE = 0.05;

    private AnimationTimer scrollAnimation = new AnimationTimer() {

        @Override
        public void handle(long now) {
            if (direction == -1) {
                getVirtualFlow().scrollPixels(-DISTANCE);
            } else if (direction == 1) {
                getVirtualFlow().scrollPixels(DISTANCE);
            }
        }

    };

    private Rectangle2D leftUpArea;
    private Rectangle2D rightDownArea;

    private int direction = 0;
    private int anchorIndex = -1;

    public CustomListViewSkin(final ListView<T> control) {
        super(control);
        final var flow = getVirtualFlow();
        final var factory = flow.getCellFactory();

        // decorate the actual cell factory
        flow.setCellFactory(vf -> {
            final var cell = factory.call(flow);

            // handle drag start
            cell.addEventHandler(MouseEvent.DRAG_DETECTED, event -> {
                if (control.getSelectionModel().getSelectionMode() == SelectionMode.MULTIPLE) {
                    event.consume();
                    cell.startFullDrag();
                    anchorIndex = cell.getIndex();
                }
            });

            // handle selecting items when the mouse-drag enters the cell
            cell.addEventHandler(MouseDragEvent.MOUSE_DRAG_ENTERED, event -> {
                event.consume();
                if (event.getGestureSource() != cell) {
                    final var model = control.getSelectionModel();
                    if (anchorIndex < cell.getIndex()) {
                        model.selectRange(anchorIndex, cell.getIndex() + 1);
                    } else {
                        model.selectRange(cell.getIndex(), anchorIndex + 1);
                    }
                }
            });

            return cell;
        });

        // handle the auto-scroll functionality
        flow.addEventHandler(MouseDragEvent.MOUSE_DRAG_OVER, event -> {
            event.consume();
            if (leftUpArea.contains(event.getX(), event.getY())) {
                direction = -1;
                scrollAnimation.start();
            } else if (rightDownArea.contains(event.getX(), event.getY())) {
                direction = 1;
                scrollAnimation.start();
            } else {
                direction = 0;
                scrollAnimation.stop();
            }
        });

        // stop the animation when the mouse exits the flow/list (desired?)
        flow.addEventHandler(MouseDragEvent.MOUSE_DRAG_EXITED, event -> {
            event.consume();
            scrollAnimation.stop();
        });

        // handle stopping the animation and reset the state when the mouse
        // is released. Added to VirtualFlow because it doesn't matter
        // which cell receives the event.
        flow.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
            if (anchorIndex != -1) {
                event.consume();
                anchorIndex = -1;
                scrollAnimation.stop();
            }
        });

        updateAutoScrollAreas();
        registerChangeListener(control.orientationProperty(), obs -> updateAutoScrollAreas());
        registerChangeListener(flow.widthProperty(), obs -> updateAutoScrollAreas());
        registerChangeListener(flow.heightProperty(), obs -> updateAutoScrollAreas());
    }

    // computes the regions where the mouse needs to be
    // in order to start auto-scrolling. The regions depend
    // on the orientation of the ListView.
    private void updateAutoScrollAreas() {
        final var flow = getVirtualFlow();
        switch (getSkinnable().getOrientation()) {
            case HORIZONTAL:
                final double width = flow.getWidth() * PERCENTAGE;
                leftUpArea = new Rectangle2D(0, 0, width, flow.getHeight());
                rightDownArea = new Rectangle2D(flow.getWidth() - width, 0, width, flow.getHeight());
                break;
            case VERTICAL:
                final double height = flow.getHeight() * PERCENTAGE;
                leftUpArea = new Rectangle2D(0, 0, flow.getWidth(), height);
                rightDownArea = new Rectangle2D(0, flow.getHeight() - height, flow.getWidth(), height);
                break;
            default:
                throw new AssertionError();
        }
    }

    @Override
    public void dispose() {
        unregisterChangeListeners(getSkinnable().orientationProperty());
        unregisterChangeListeners(getVirtualFlow().widthProperty());
        unregisterChangeListeners(getVirtualFlow().heightProperty());
        super.dispose();

        scrollAnimation.stop();
        scrollAnimation = null;
    }
}

Note: As mentioned by kleopatra, at least some of this functionality is better suited for the behavior class. However, for simplicity's sake, I decided to only use the existing, public skin class (by extending it). Again, the above is only a proof-of-concept.

like image 117
Slaw Avatar answered Jan 04 '23 12:01

Slaw