如何在 Linux 上的拖放错误期间实施自动滚动的解决方法?

How can I implement a workaround for the autoscroll during drag and drop bug on Linux?

我有一个列表,其中包含滚动窗格中的许多元素,并且我已经在列表上实现了拖放操作。当我 select 列表中的一个项目并将其拖到列表底部时,只要我将鼠标靠近边缘,列表就会自动向下滚动。这在 Windows 上工作正常,但在 Linux 上列表滚动一个元素然后停止。

这是一个揭示此错误的简短程序:

import java.awt.BorderLayout;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.datatransfer.UnsupportedFlavorException;
import java.io.IOException;

import javax.swing.DropMode;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.SwingUtilities;
import javax.swing.TransferHandler;
import javax.swing.WindowConstants;


public class JListAutoscroll {

    protected static Container createUI() {
        JList<String> jlist = new JList<>(generateData(100));
        setDragAndDrop(jlist);
        JScrollPane scrollPane = new JScrollPane(jlist);
        JPanel panel = new JPanel(new BorderLayout());
        panel.add(scrollPane, BorderLayout.CENTER);
        return panel;
    }

    private static void setDragAndDrop(JList<String> jlist) {
        jlist.setDragEnabled(true);
        jlist.setDropMode(DropMode.INSERT);
        jlist.setTransferHandler(new ListTransferHandler());
    }

    private static String[] generateData(int nRows) {
        String rows[] = new String[nRows];
        for (int i = 0; i < rows.length; i++) {
            rows[i] = "element " + i;
        }
        return rows;
    }

    private static class ListTransferHandler extends TransferHandler {

        @Override
        public int getSourceActions(JComponent component) {
            return COPY_OR_MOVE;
        }

        @Override
        protected Transferable createTransferable(JComponent component) {
            return new ListItemTransferable((JList)component);
        }

        @Override
        public boolean canImport(TransferHandler.TransferSupport support) {
            return true;
        }

        @Override
        public boolean importData(TransferHandler.TransferSupport support) {
            return true;
        }
    }

    private static class ListItemTransferable implements Transferable {

        private String item;

        public ListItemTransferable(JList<String> jlist) {
            item = jlist.getSelectedValue();
        }

        @Override
        public DataFlavor[] getTransferDataFlavors() {
            return new DataFlavor[] { DataFlavor.stringFlavor };
        }

        @Override
        public boolean isDataFlavorSupported(DataFlavor flavor) {
            return flavor.equals(DataFlavor.stringFlavor);
        }

        @Override
        public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException, IOException {
            if(!isDataFlavorSupported(flavor)) {
                throw new UnsupportedFlavorException(flavor);
            }
            return item;
        }

    }

    public static void main(String args[]) {
        SwingUtilities.invokeLater(new Runnable() {

            @Override
            public void run() {
                JFrame frame = new JFrame("JList Autoscroll");
                frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
                frame.setContentPane(createUI());
                frame.setPreferredSize(new Dimension(400, 600));
                frame.pack();
                frame.setVisible(true);
            }

        });
    }

}

我实现了一个简单的 TransferHandler,它在拖放时不执行任何操作,但足以在拖到列表边缘时显示问题。

这似乎是 JDK 中的一个已知错误,在 this report. I've seen some suggested workarounds, like this one or this one 中有最好的描述, 但我不清楚如何实施它们。在我看来,我必须创建一个 DropTarget 子类,并且我使用它的组件应该实现 Autoscroll 接口。但是JList并没有实现!另外,如果我在列表中设置 DropTarget 而不是 TransferHandler,我是否会丢失 TransferHandler 实现的所有默认拖放行为?

那么我该如何修改我的程序来解决这个错误?

bug description 中所述,有两个 class 处理拖放:

  • DropTargetAutoScroller, a member class of java.awt.dnd.DropTarget, responsible of supporting components implementing the Autoscroll interface;
  • DropHandler, a member class of javax.swing.TransferHandler, that automates d&d autoscrolling on components implementing the Scrollable interface.

所以,实际上,解决方法不适合 JList,它实现了 Scrollable 而不是 Autoscroll。但是,如果您查看 DropTargetTransferHandler 的源代码,您会注意到自动滚动代码基本相同,而且在这两种情况下都是错误的。解决方法也与 DropTarget 代码非常相似,只是添加了几行。基本上,解决方案是将鼠标光标的位置从组件坐标系转换到屏幕坐标系。这样,在检查鼠标是否移动时,使用绝对坐标。所以我们可以从 TransferHandler 复制代码并添加这几行。

太好了...但是我们将这段代码放在哪里以及如何调用它?

如果我们查看 setTransferHandler(),我们会发现它实际上设置了一个 DropTarget,这是一个 package-private static class SwingDropTarget 来自 TransferHandler class。它将拖放事件委托给名为 DropHandlerprivate static DropTargetListener。这个 class 完成了拖放过程中发生的所有魔法,当然它还使用了 TransferHandler 中的其他私有方法。这意味着我们不能只设置我们自己的 DropTarget 而不会丢失已经在 TransferHandler 中实现的所有内容。我们可以重写 TransferHandler(大约 1800 行),添加几行来修复错误,但这不太现实。

一个更简单的解决方案是写一个DropTargetListener,我们只需从DropHandler(它也实现了这个接口)复制自动滚动相关的代码,添加我们的行。这是 class:

import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Toolkit;
import java.awt.dnd.DropTargetDragEvent;
import java.awt.dnd.DropTargetDropEvent;
import java.awt.dnd.DropTargetEvent;
import java.awt.dnd.DropTargetListener;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.TooManyListenersException;

import javax.swing.JComponent;
import javax.swing.Scrollable;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.Timer;


public class AutoscrollWorkaround implements DropTargetListener, ActionListener {

    private JComponent component;

    private Point lastPosition;

    private Rectangle outer;
    private Rectangle inner;

    private Timer timer;
    private int hysteresis = 10;

    private static final int AUTOSCROLL_INSET = 10;

    public AutoscrollWorkaround(JComponent component) {
        if (!(component instanceof Scrollable)) {
            throw new IllegalArgumentException("Component must be Scrollable for autoscroll to work!");
        }
        this.component = component;
        outer = new Rectangle();
        inner = new Rectangle();

        Toolkit t = Toolkit.getDefaultToolkit();
        Integer prop;

        prop = (Integer)t.getDesktopProperty("DnD.Autoscroll.interval");
        timer = new Timer(prop == null ? 100 : prop.intValue(), this);

        prop = (Integer)t.getDesktopProperty("DnD.Autoscroll.initialDelay");
        timer.setInitialDelay(prop == null ? 100 : prop.intValue());

        prop = (Integer)t.getDesktopProperty("DnD.Autoscroll.cursorHysteresis");
        if (prop != null) {
            hysteresis = prop.intValue();
        }
    }

    @Override
    public void dragEnter(DropTargetDragEvent e) {
        lastPosition = e.getLocation();
        SwingUtilities.convertPointToScreen(lastPosition, component);
        updateRegion();
    }

    @Override
    public void dragOver(DropTargetDragEvent e) {
        Point p = e.getLocation();
        SwingUtilities.convertPointToScreen(p, component);

        if (Math.abs(p.x - lastPosition.x) > hysteresis
                || Math.abs(p.y - lastPosition.y) > hysteresis) {
            // no autoscroll
            if (timer.isRunning()) timer.stop();
        } else {
            if (!timer.isRunning()) timer.start();
        }

        lastPosition = p;
    }

    @Override
    public void dragExit(DropTargetEvent dte) {
        cleanup();
    }

    @Override
    public void drop(DropTargetDropEvent dtde) {
        cleanup();
    }

    @Override
    public void dropActionChanged(DropTargetDragEvent e) {
    }

    private void updateRegion() {
        // compute the outer
        Rectangle visible = component.getVisibleRect();
        outer.setBounds(visible.x, visible.y, visible.width, visible.height);

        // compute the insets
        Insets i = new Insets(0, 0, 0, 0);
        if (component instanceof Scrollable) {
            int minSize = 2 * AUTOSCROLL_INSET;

            if (visible.width >= minSize) {
                i.left = i.right = AUTOSCROLL_INSET;
            }

            if (visible.height >= minSize) {
                i.top = i.bottom = AUTOSCROLL_INSET;
            }
        }

        // set the inner from the insets
        inner.setBounds(visible.x + i.left,
                      visible.y + i.top,
                      visible.width - (i.left + i.right),
                      visible.height - (i.top  + i.bottom));
    }

    @Override
    public void actionPerformed(ActionEvent e) {
        updateRegion();
        Point componentPosition = new Point(lastPosition);
        SwingUtilities.convertPointFromScreen(componentPosition, component);
        if (outer.contains(componentPosition) && !inner.contains(componentPosition)) {
            autoscroll(componentPosition);
        }
    }

    private void autoscroll(Point position) {
        Scrollable s = (Scrollable) component;
        if (position.y < inner.y) {
            // scroll upward
            int dy = s.getScrollableUnitIncrement(outer, SwingConstants.VERTICAL, -1);
            Rectangle r = new Rectangle(inner.x, outer.y - dy, inner.width, dy);
            component.scrollRectToVisible(r);
        } else if (position.y > (inner.y + inner.height)) {
            // scroll downard
            int dy = s.getScrollableUnitIncrement(outer, SwingConstants.VERTICAL, 1);
            Rectangle r = new Rectangle(inner.x, outer.y + outer.height, inner.width, dy);
            component.scrollRectToVisible(r);
        }

        if (position.x < inner.x) {
            // scroll left
            int dx = s.getScrollableUnitIncrement(outer, SwingConstants.HORIZONTAL, -1);
            Rectangle r = new Rectangle(outer.x - dx, inner.y, dx, inner.height);
            component.scrollRectToVisible(r);
        } else if (position.x > (inner.x + inner.width)) {
            // scroll right
            int dx = s.getScrollableUnitIncrement(outer, SwingConstants.HORIZONTAL, 1);
            Rectangle r = new Rectangle(outer.x + outer.width, inner.y, dx, inner.height);
            component.scrollRectToVisible(r);
        }
    }

    private void cleanup() {
        timer.stop();
    }
}

(您会注意到基本上只有 SwingUtilities.convertXYZ() 调用是 TransferHandler 代码中的额外调用)

接下来,我们可以在设置TransferHandler时将这个监听器添加到安装的DropTarget中。 (请注意,常规 DropTarget 只接受一个侦听器,如果添加另一个侦听器将抛出异常。SwingDropTarget 使用 DropHandler,但幸运的是它也增加了对其他侦听器的支持)

因此,让我们将这个静态工厂方法添加到 AutoscrollWorkaround class,它为我们做的是:

    public static void applyTo(JComponent component) {
        if (component.getTransferHandler() == null) {
            throw new IllegalStateException("A TransferHandler must be set before calling this method!");
        }
        try {
            component.getDropTarget().addDropTargetListener(new AutoscrollWorkaround(component));
        } catch (TooManyListenersException e) {
            throw new IllegalStateException("Something went wrong! DropTarget should have been " +
                    "SwingDropTarget which accepts multiple listeners", e);
        }
    }

这提供了一种简单且非常方便的方法,只需调用这一个方法即可将变通方法应用于任何存在此错误的组件。只要确保在组件上有 setTransferHandler() 之后调用它。所以,我们只需要在原来的程序中加一行:

private static void setDragAndDrop(JList<String> jlist) {
    jlist.setDragEnabled(true);
    jlist.setDropMode(DropMode.INSERT);
    jlist.setTransferHandler(new ListTransferHandler());
    AutoscrollWorkaround.applyTo(jlist); // <--- just this line added
}

自动滚动现在在 Windows 和 Linux 上都可以正常工作。 (尽管在 Linux 上,放置位置的线在自动滚动工作之前不会重新绘制,但是哦,好吧。)

此解决方法也适用于 JTable(我测试过)、JTree 以及可能实现 Scrollable.

的任何组件