Drag and drop

Следующая лаба у нас про стэки и очереди. И одним из ярких примеров работы с очередями с стеками является перетаскивание разных объектов.

Ну и в общем, так чтобы прям прочувствовать это своими руками тут мы будем учится делать приложения, в которых можно будет перетаскивать объекты из одного места в другое.

Для перетаскивания объектов на форме используется так называемый механизм drag and drop (по-русски: тяни и бросай). Работает он примерно так:

то есть, физически (если так можно выразится) ничего на форме не двигается. Но внутренние данные каждого списка реагирует на процесс перетаскивания, и создается иллюзия будто мы действительно что-то двигали.

Работать будем снова с JavaFX. В общем, создаем приложение.

Сразу идем в функцию main и убираем конкретные размеры формы:

// ...

public class Main extends Application {

    @Override
    public void start(Stage primaryStage) throws Exception{
        Parent root = FXMLLoader.load(getClass().getResource("sample.fxml"));
        primaryStage.setTitle("Drag and drop"); // переименовал
        primaryStage.setScene(new Scene(root)); // убрал размеры
        primaryStage.show();
    }


    public static void main(String[] args) {
        launch(args);
    }
}

на форме я буду использовать AnchorPane, который позволяет размещать объекты на форме более свободно (примерно, как это было с Windows Forms в C#).

И добавлю на форму два объекта типа Label, должно получится что-то такое:

Imgur

Код разметки выглядит так:

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.AnchorPane?>


<AnchorPane prefHeight="197.0" prefWidth="389.0" xmlns="http://javafx.com/javafx/8.0.171" xmlns:fx="http://javafx.com/fxml/1" fx:controller="sample.Controller">
   <children>
      <Label layoutX="32.0" layoutY="26.0" prefHeight="23.0" prefWidth="99.0" text="Текст метки 1" />
      <Label layoutX="253.0" layoutY="26.0" prefHeight="23.0" prefWidth="99.0" text="Текст метки 2" />
   </children>
</AnchorPane>

привяжем метки к контроллеру

package sample;

import javafx.fxml.FXML;
import javafx.scene.control.Label;

public class Controller {
    @FXML
    public Label label1;
    @FXML
    public Label label2;
}

пропишем соответствующие fx:id у объектов на форме.

<Label fx:id="label1" ... text="Текст метки 1" />
<Label fx:id="label2" ... text="Текст метки 2" />

подключим интерфейс Initializable

package sample;

import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Label;

import java.net.URL;
import java.util.ResourceBundle;

public class Controller implements Initializable {
    @FXML
    public Label label1;
    @FXML
    public Label label2;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        
    }
}

Делаем перетягивание текст с одного лейбла на другой

Будем пытаться сделать что-то такое:

Вы, наверное, замечали, что в SceneBuilder, когда добавляли реакцию на onAction, там справа есть целая куча всяких onDrag* полей

Imgur

Эти поля и нужны, чтобы привязывать к объектам реакцию на перетягивание.

Используются они примерно в таком порядке:

  • OnDragDetected – вызывается, когда вы кликаете на объект и, не отпуская кнопку мышки, начинаете тянуть
  • OnDragOver – вызывается, когда вы дотягиваете до какого-нибудь другого объекта и начинаете двигать над ним мышью
  • OnDragDropped – вызывается, когда вы отпускаете мышку над каким-нибудь объектом

в принципе этих событий нам хватит, остальные нужны для более тонкой настройки и нам не понадобятся.

Событие OnDragDetected

Добавляем реакцию на перетаскивание:

package sample;

import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Label;
import javafx.scene.input.ClipboardContent; // добавил
import javafx.scene.input.Dragboard; // добавил
import javafx.scene.input.MouseEvent; // добавил
import javafx.scene.input.TransferMode; // добавил

import java.net.URL;
import java.util.ResourceBundle;

public class Controller implements Initializable {
    @FXML
    public Label label1;
    @FXML
    public Label label2;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        // привязываем реакцию на начала перетаскивания к лейблу
        label1.setOnDragDetected(this::onLabelDragDetected);
    }

    // собственно описание реакции
    public void onLabelDragDetected(MouseEvent mouseEvent) {
        /**
         * этот код как правило от приложения к приложению не сильно меняется
         * и содержит минимальный набор действий, которые надо сделать чтобы инициировать перетаскивание
         */

        // 1. сначала создаем объект который представляет собой своего рода физическую сущность процесса перетаскивания
        // у большинства javafx компонент есть метод startDragAndDrop, который эту сущность создает
        Dragboard db = label1.startDragAndDrop(TransferMode.ANY);

        //  2. следующим этапом привязываем данные к сущности Dragboard db
        // привязывается немного хитрым способом, путем создания объекта ClipboardContent
        ClipboardContent content = new ClipboardContent();
        // запихиванием в него данных, в нашем случае пихаем текст лейбла
        content.putString(label1.getText());
        // а потом запихиваем этот самый ClipboardContent в сущность перетаскивания
        db.setContent(content);

        // 3. Вот эта штука не обязательная, но как правило ее ставят,
        // он препятствует дальнейшей обработки событий мыши,
        // если таковые конечно имеются у каких-нибудь элементов на форме в точке где вы начали тянут объект
        // Если непонятно что я тут написал, то, короче, просто оставьте тут это
        mouseEvent.consume();
    }
}

можно запустить и попробовать потянуть:

Видите, так как мы привязали событие только к первому label, он реагирует на начало перетягивания изменением курсора мыши. Второй же лейбл никак не реагирует.

Курсор меняется на знак стоп, что означает что ни один объект на форме не допускает сбросить на него перетягиваемые данные.

Событие OnDragOver

Чтобы разрешить «сброс», надо определить реакцию на событие OnDragOver. Так как в качестве приемника у нас будет второй label, то реакцию будем добавлять уже для него.

Как правило код тоже получается типовой.

// ...

@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
    label1.setOnDragDetected(this::onLabelDragDetected);
    
    // привязываем реакцию на движение удерживаемого объекта над вторым лейблом
    label2.setOnDragOver(this::onLabelDragOver);
}

public void onLabelDragOver(DragEvent dragEvent) {
    // достаем ту самую сущность перетаскивания
    Dragboard db = dragEvent.getDragboard();
    // проверяем есть ли в ней данные
    // в принципе, сюда можно всякие хитрые проверки пихать, мы чуть позже это поделаем
    if (db.hasString()) {
        // если все ок, то разрешаем бросать тут
        // но только разрешаем, бросание будет обрабатываться в другом методе
        dragEvent.acceptTransferModes(TransferMode.ANY);
    }
}

public void onLabelDragDetected(MouseEvent mouseEvent) {
    /* ... */
}

проверяем

видите, теперь курсор мышки меняется при наведении на label2, потому что он разрешает сбросить на него данные.

Событие onDragDroped

Ну и последний этап. Собственно, реакция на отпускание мыши. Определяется для объекта, над которым отпускают мышь. То есть опять же для label2.

Тут уже код не типовой, и сильно зависит от ситуации. В нашем случае надо достать строку из dragBoard и установить ее в качестве текста для label2

Делаем это:

@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
    label1.setOnDragDetected(this::onLabelDragDetected);
    label2.setOnDragOver(this::onLabelDragOver);
    
    // добавляем реакцию
    label2.setOnDragDropped(this::onLabelDragDropped);
}

public void onLabelDragDropped(DragEvent dragEvent) {
    // запрашиваем сущность перетаскивания
    Dragboard db = dragEvent.getDragboard();
    // присваиваем переносимую строку как текст метки 2 
    label2.setText(db.getString());
}

public void onLabelDragOver(DragEvent dragEvent) { /* ... */ }
public void onLabelDragDetected(MouseEvent mouseEvent) { /* ... */ }

проверяем

Обобщаем

Наверное, было бы здорово, чтобы и со второго лейбла можно было бы перетянуть на первый. Для этого надо, чтобы у каждого лейбла были одинаковые обработчики. Сделаем это:

@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
    label1.setOnDragDetected(this::onLabelDragDetected);
    label1.setOnDragOver(this::onLabelDragOver);
    label1.setOnDragDropped(this::onLabelDragDropped);
    label2.setOnDragDetected(this::onLabelDragDetected);
    label2.setOnDragOver(this::onLabelDragOver);
    label2.setOnDragDropped(this::onLabelDragDropped);
}

проверим:

Чет тянется, но меняется где-то не там… Точно! У нас же в onLabelDragDropped всегда меняется текст label2, глянем код:

public void onLabelDragDropped(DragEvent dragEvent) {
    Dragboard db = dragEvent.getDragboard();
    label2.setText(db.getString()); // << вот тут меняется
}

к счастью, у нас есть возможность узнать над каким объектом отпустили мышь:

public void onLabelDragDropped(DragEvent dragEvent) {
    Dragboard db = dragEvent.getDragboard();
    
    // dragEvent.getGestureTarget() -- содержит информацию о том, для кого вызывалось событие отпускания мыши,
    // то есть если отпускать над label1 -- будет label2
    // если над label2 -- будет label2
    Label targetLabel = (Label) dragEvent.getGestureTarget();
    targetLabel.setText(db.getString());
}

проверяем:

уже лучше, но все равно значение label1 не меняется. Точнее оно меняется, но туда по новой записывается текст, который лежит в Dragboard db, а туда мы всегда запихиваем значение label1.

Так что идем в onLabelDragDetected и там используем уже getSource, чтобы положить правильный текст в Dragboard:

public void onLabelDragDetected(MouseEvent mouseEvent) {
    // берем данные об label который инициализировал событие
    Label sourceLabel = (Label) mouseEvent.getSource();
    Dragboard db = sourceLabel.startDragAndDrop(TransferMode.ANY);

    ClipboardContent content = new ClipboardContent();
    // тут используем текст этого лейбла, остальное не трогаем
    content.putString(sourceLabel.getText());
    db.setContent(content);

    mouseEvent.consume();
}

отлично:

Частично избавляемся от Dragboard

Вообще если так подумать, на кой нам это Dragboard?

Зачем нам использовать его для передачи данных, если вся информация о том, кто инициировал перетягивание и где отпустили мышь содержится в DragEvent.

Ведь получается, что к объектам можно обращаться напрямую не использую прослойку в виде Dragboard.

К сожалению, полностью от него избавится не получится, но по ключевым моментам вполне.

Сначала заменим в onLabelDragDropped где собственно происходит присваивание:

public void onLabelDragDropped(DragEvent dragEvent) {
    // над каким лейблом отпустили
    Label targetLabel = (Label) dragEvent.getGestureTarget();
    
    // с какого лейбла тянули
    Label sourceLabel = (Label) dragEvent.getGestureSource();
    
    // меняем текст обобщенно
    targetLabel.setText(sourceLabel.getText());
}

проверяем:

теперь правим в onLabelDragDetected:

public void onLabelDragDetected(MouseEvent mouseEvent) {
    // тут немного обобщим, это позволит нам использовать этот метод 
    // для инициализации перетаскивания в любой компоненте
    Node sourceNode = (Node) mouseEvent.getSource();
    Dragboard db = sourceNode.startDragAndDrop(TransferMode.ANY);

    ClipboardContent content = new ClipboardContent();
    content.putString(""); // ну и вот тут подкрутим, нам не важно, что положить в db, главное хоть что-нибудь, вот и пусть будет пустая строка
    db.setContent(content);

    mouseEvent.consume();
}

по идее все должно работать как раньше.

Запрещаем отпускание мыши над лейблом, из которого начали тянуть

Сейчас если мы начнем тянуть, а потом сразу отпустим, у нас выполнится переприсваивание значения text для начального лейбла.

Такое поведение часто нежелательно. Поэтому добавим запрет на отпускание мыши (точнее отпустить мышь можно будет, просто событие DragDropped вызываться не будет).

Для этого используются возможности события onLabelDragOver.

По сути нам надо просто проверять чтобы getGestureSource (с какого лейбла начали тянуть) не совпадал с getSource (над каким лейблом водят мышью, да-да таки именно Source).

Правим код:

public void onLabelDragOver(DragEvent dragEvent) {
    // это откуда начали тянуть
    Label sourceLabel = (Label) dragEvent.getGestureSource();
    // это над кем сейчас происходит событие DragOver, 
    // поэтому по отношения к этому событию тут должен быть getSource
    Label targetLabel = (Label) dragEvent.getSource();
    
    // проверяем что они не совпадают
    if (targetLabel != sourceLabel) {
        dragEvent.acceptTransferModes(TransferMode.ANY);
    }
}

запускаем:

Красота! =)

Drag&Drop на компоненту ListView

И так, перепилим на интерфейс и код почти с нуля. На форме добавим компоненты под списки (тип ListView) и два лейбла с соответствующим текстом:

Imgur

код разметки:

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ListView?>
<?import javafx.scene.layout.AnchorPane?>

<AnchorPane prefHeight="235.0" prefWidth="389.0" xmlns="http://javafx.com/javafx/8.0.171" xmlns:fx="http://javafx.com/fxml/1" fx:controller="sample.Controller">
   <children>
      <Label fx:id="lblBlack" layoutX="14.0" layoutY="53.0" prefHeight="23.0" prefWidth="99.0" text="Черный" />
      <ListView fx:id="lstBlack" layoutX="175.0" layoutY="14.0" prefHeight="100.0" prefWidth="200.0" />
      <ListView fx:id="lstWhite" layoutX="175.0" layoutY="121.0" prefHeight="100.0" prefWidth="200.0" />
      <Label fx:id="lblWhite" layoutX="14.0" layoutY="160.0" prefHeight="23.0" prefWidth="99.0" text="Белый" />
   </children>
</AnchorPane>

соответственно почистим контроллер:

package sample;

import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Label;

import java.net.URL;
import java.util.ResourceBundle;

public class Controller implements Initializable {
    @FXML
    public Label lblBlack;
    @FXML
    public Label lblWhite;

    @FXML
    public ListView<String> lstBlack;
    @FXML
    public ListView<String> lstWhite;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {

    }
}

идея у меня такая: я хочу реализовать возможность перетаскивать текст из лейблов и добавлять его к элементам списков.

Причем в верхний список можно будет перетаскивать только черный лейбл, а в нижний только белый.

Но прежде разберемся как работать со списком. Для того чтобы работать со списком было удобно как правило создают объект типа ObservableList и привязывают его к компоненте.

Делается это так:

public class Controller implements Initializable {
    @FXML
    public Label lblBlack;
    @FXML
    public Label lblWhite;

    @FXML
    public ListView<String> lstBlack;
    @FXML
    public ListView<String> lstWhite;
    
    
    // добавляем коллекцию
    ObservableList<String> lstBlackItems = FXCollections.observableArrayList();

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        // привязываем коллекцию к компоненте списку
        lstBlack.setItems(lstBlackItems);
        
        // добавляем объект в коллекцию
        lstBlackItems.add("item");
    }
}

если теперь запустить, то увидим что-то такое

Imgur

это очень удобно, когда данные у нас отдельно, компоненты – отдельно. Мы можем даже сделать вот так:

@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
    lstBlack.setItems(lstBlackItems);
    lstWhite.setItems(lstBlackItems);

    lstBlackItems.add("item");
}

и если запустить, увидим

Imgur

классно, да?

В принципе этих познаний о списке нам вполне хватит. Сделаем чтобы у белого списка тоже была своя коллекция

public class Controller implements Initializable {
    /* ... */

    ObservableList<String> lstBlackItems = FXCollections.observableArrayList();
    ObservableList<String> lstWhiteItems = FXCollections.observableArrayList(); // добавил

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        lstBlack.setItems(lstBlackItems);
        lstWhite.setItems(lstWhiteItems); // привязал
    }
}

теперь реализуем перетаскивание.

Для инициализации будем использовать наш ранее разработанный универсальный метод:

@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
    lstBlack.setItems(lstBlackItems);
    lstWhite.setItems(lstWhiteItems);
    
    // привязали к лейблам инициализацию перетягивания
    lblBlack.setOnDragDetected(this::onDragDetected);
    lblWhite.setOnDragDetected(this::onDragDetected);
}

// это скопипастили из прошлой реализации
public void onDragDetected(MouseEvent mouseEvent) {
    Node sourceNode = (Node) mouseEvent.getSource();
    Dragboard db = sourceNode.startDragAndDrop(TransferMode.ANY);

    ClipboardContent content = new ClipboardContent();
    content.putString("");
    db.setContent(content);

    mouseEvent.consume();
}

теперь добавим разрешение на бросание в ListView:

@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
    lstBlack.setItems(lstBlackItems);
    lstWhite.setItems(lstWhiteItems);

    lblBlack.setOnDragDetected(this::onDragDetected);
    lblWhite.setOnDragDetected(this::onDragDetected);
    
    // привязываем обработчик движения мыши над списком
    // обратите внимание что тут уже lstBlack и lstWhite
    lstBlack.setOnDragOver(this::onListViewDragOver);
    lstWhite.setOnDragOver(this::onListViewDragOver);
}

public void onDragDetected(MouseEvent mouseEvent) { /* ... */}

public void onListViewDragOver(DragEvent dragEvent) {
    dragEvent.acceptTransferModes(TransferMode.ANY);
}

добавляем реакцию на отпускание мыши. Я хочу, чтобы в список добавился текст лейбла, с которого, начали тянуть:

@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
    lstBlack.setItems(lstBlackItems);
    lstWhite.setItems(lstWhiteItems);

    lblBlack.setOnDragDetected(this::onDragDetected);
    lblWhite.setOnDragDetected(this::onDragDetected);

    lstBlack.setOnDragOver(this::onListViewDragOver);
    lstWhite.setOnDragOver(this::onListViewDragOver);
    
    // реакция на отпускание мыши
    lstBlack.setOnDragDropped(this::onListViewDragDropped);
    lstWhite.setOnDragDropped(this::onListViewDragDropped);
}

public void onDragDetected(MouseEvent mouseEvent) { /* ... */ }
public void onListViewDragOver(DragEvent dragEvent) { /* ... */ }

public void onListViewDragDropped(DragEvent dragEvent) {
    // над каким списком отпустили
    ListView<String> targetListView = (ListView) dragEvent.getGestureTarget();

    // с какого лейбла тянули
    Label sourceLabel = (Label) dragEvent.getGestureSource();

    // добавляем текст лейбла в список
    targetListView.getItems().add(sourceLabel.getText());
}

проверяем

теперь сделаем так, чтобы черный можно было бы перенести только на первый список, а белый только на второй.

Правим метод onListViewDragOver

public void onListViewDragOver(DragEvent dragEvent) {
    if (dragEvent.getGestureSource() == lblBlack && dragEvent.getSource() == lstBlack
            || dragEvent.getGestureSource() == lblWhite && dragEvent.getSource() == lstWhite) {
        dragEvent.acceptTransferModes(TransferMode.ANY);
    }
}

запускаем:

Перетягивание между списками

Сначала надо добавить вообще возможность тянуть с одного списка на другой, для этого подключаем наш универсальный обработчик onDragDetected

@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
    // это не трогаем
    lstBlack.setItems(lstBlackItems);
    lstWhite.setItems(lstWhiteItems);
    
    // и это не трогаем
    lblBlack.setOnDragDetected(this::onDragDetected);
    lblWhite.setOnDragDetected(this::onDragDetected);
    
    // тут добавил возможность тянуть с одного списка на другой
    lstBlack.setOnDragDetected(this::onDragDetected);
    lstWhite.setOnDragDetected(this::onDragDetected);
    
    // остальное оставляем как есть
    lstBlack.setOnDragOver(this::onListViewDragOver);
    lstWhite.setOnDragOver(this::onListViewDragOver);

    lstBlack.setOnDragDropped(this::onListViewDragDropped);
    lstWhite.setOnDragDropped(this::onListViewDragDropped);
}

таким образом, если тянуть с любого списка, появится реакция:

Теперь нам надо подправить onListViewDragOver, чтобы он разрешал сбрасывать перетянутое

public void onListViewDragOver(DragEvent dragEvent) {
    // это так и оставили
    if (dragEvent.getGestureSource() == lblBlack && dragEvent.getSource() == lstBlack
            || dragEvent.getGestureSource() == lblWhite && dragEvent.getSource() == lstWhite) {
        dragEvent.acceptTransferModes(TransferMode.ANY);
    }
    
    // проверяем что если тянут со списка
    if (dragEvent.getGestureSource() instanceof ListView) {
        // то разрешаем
        dragEvent.acceptTransferModes(TransferMode.ANY);
    }
}

проверяем:

Ну и последнее. Осталось добавить реакцию на сброс.
Тут надо разделить логику для перетягивания с лейбла, и перетягивания с ListView.

Правим метод onListViewDragDropped, получится что-то такое

public void onListViewDragDropped(DragEvent dragEvent) {
    // над каким списком отпустили
    ListView<String> targetListView = (ListView) dragEvent.getGestureTarget();

    // старый обработчик, когда тянут с лейбл, его просто под if спрятали
    if (dragEvent.getGestureSource() instanceof Label) {
        Label sourceLabel = (Label) dragEvent.getGestureSource();
        targetListView.getItems().add(sourceLabel.getText());
    } else if (dragEvent.getGestureSource() instanceof ListView) {
        // новый обработчик, когда тянут с ListView
        // берем источник
        ListView sourceListView = (ListView) dragEvent.getGestureSource();

        // берем последний элемент в списке
        String lastItem = (String)sourceListView.getItems().get(sourceListView.getItems().size() - 1);

        // удаляем последний элемент из списка источника
        sourceListView.getItems().remove(sourceListView.getItems().size() - 1);

        // добавляем в список, над котором отпустили мышку
        targetListView.getItems().add(lastItem);
    }
}

таким способом будет перетягиваться всегда последний элемент со списка.

Поздравляю! Первое задание четвертой лабы готово! =)