How to perform a drag-and-drop without holding dow

2019-07-11 06:30发布

问题:

1. Objective

My purpose is to build a rightmouse-click-menu like this:

When the user clicks on Grab and move, the button should disappear from the QScrollArea() and move quickly towards the mouse. When it arrives at the mouse pointer, the button should fade out and the drag-and-drop operation can start.

 

2. Minimal, Reproducible Example

I got something working, but it isn't perfect yet. Please copy-paste the code below and run it with Python 3.x (I use Python 3.7) and PyQt5.

Note: To make the line pixmap = QPixmap("my_pixmap.png") work properly, let it refer to an existing png-image on your computer.

from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import sys

class MyButton(QPushButton):
    '''
    A special push button.
    '''
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setFixedWidth(300)
        self.setFixedHeight(30)
        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.showMenu)
        return

    def showMenu(self, pos):
        '''
        Show this popup menu when the user clicks with the right mouse button.
        '''
        menu = QMenu()
        menuAction_01 = menu.addAction("action 01")
        menuAction_02 = menu.addAction("action 02")
        menuAction_03 = menu.addAction("action 03")
        menuAction_04 = menu.addAction("action 04")
        menuAction_grab = menu.addAction("grab")
        action = menu.exec_(self.mapToGlobal(pos))
        if action == menuAction_01:
            print("clicked on action 01")
        elif action == menuAction_02:
            print("clicked on action 02")
        elif action == menuAction_03:
            print("clicked on action 03")
        elif action == menuAction_04:
            print("clicked on action 04")
        elif action == menuAction_grab:
            print("clicked on grab")
            # 1. Start animation
            #      -> button moves to mouse pointer
            self.animate()
            # 2. After animation finishes (about 1 sec)
            #      -> start drag operation
            QTimer.singleShot(1000, self.start_drag)
        return

    def animate(self):
        '''
        The button removes itself from the QScrollArea() and flies to the mouse cursor.
        For more details, see the anser of @eyllanesc at
        https://stackoverflow.com/questions/56216698/how-display-a-qpropertyanimation-on-top-of-the-qscrollarea 
        '''
        startpoint = self.window().mapFromGlobal(self.mapToGlobal(QPoint()))
        endpoint = self.window().mapFromGlobal(QCursor.pos())
        self.setParent(self.window())
        anim = QPropertyAnimation(
            self,
            b"pos",
            self,
            duration=1000,
            startValue=startpoint,
            endValue=endpoint,
            finished=self.hide,
        )
        anim.start()
        self.show()
        return

    def start_drag(self):
        '''
        Start the drag operation.
        '''
        drag = QDrag(self)
        pixmap = QPixmap("my_pixmap.png")
        pixmap = pixmap.scaledToWidth(100, Qt.SmoothTransformation)
        drag.setPixmap(pixmap)
        mimeData = QMimeData()
        mimeData.setText("Foobar")
        drag.setMimeData(mimeData)
        dropAction = drag.exec(Qt.CopyAction | Qt.MoveAction)
        return


class CustomMainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setGeometry(100, 100, 600, 300)
        self.setWindowTitle("ANIMATION TEST")

        # OUTER FRAME
        # ============
        self.frm = QFrame()
        self.frm.setStyleSheet("""
            QFrame {
                background: #d3d7cf;
                border: none;
            }
        """)
        self.lyt = QHBoxLayout()
        self.frm.setLayout(self.lyt)
        self.setCentralWidget(self.frm)

        # BUTTON FRAME
        # =============
        self.btn_frm = QFrame()
        self.btn_frm.setStyleSheet("""
            QFrame {
                background: #ffffff;
                border: none;
            }
        """)
        self.btn_frm.setFixedWidth(400)
        self.btn_frm.setFixedHeight(200)
        self.btn_lyt = QVBoxLayout()
        self.btn_lyt.setAlignment(Qt.AlignTop)
        self.btn_lyt.setSpacing(5)
        self.btn_frm.setLayout(self.btn_lyt)

        # SCROLL AREA
        # ============
        self.scrollArea = QScrollArea()
        self.scrollArea.setStyleSheet("""
            QScrollArea {
                border-style: solid;
                border-width: 1px;
            }
        """)
        self.scrollArea.setWidget(self.btn_frm)
        self.scrollArea.setWidgetResizable(True)
        self.scrollArea.setFixedWidth(400)
        self.scrollArea.setFixedHeight(150)
        self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
        self.lyt.addWidget(self.scrollArea)

        # ADD BUTTONS TO BTN_LAYOUT
        # ==========================
        self.btn_lyt.addWidget(MyButton("Foo"))
        self.btn_lyt.addWidget(MyButton("Bar"))
        self.btn_lyt.addWidget(MyButton("Baz"))
        self.btn_lyt.addWidget(MyButton("Qux"))
        self.show()

        self.setAcceptDrops(True)
        return

    def dropEvent(self, event):
        event.acceptProposedAction()
        print("dropEvent at {0!s}".format(event))
        return

    def dragLeaveEvent(self, event):
        event.accept()
        return

    def dragEnterEvent(self, event):
        event.acceptProposedAction()
        return

if __name__== '__main__':
    app = QApplication(sys.argv)
    QApplication.setStyle(QStyleFactory.create('Plastique'))
    myGUI = CustomMainWindow()
    sys.exit(app.exec_())

Run the script and you will see a small window with a few buttons in a QScrollArea():

STEP 1: Click on one of the buttons with your right mouse button. You should see a popup menu. Click on "grab".

STEP 2: The button moves to your mouse pointer. Don't move the mouse pointer.

STEP 3: As soon as your mouse pointer is over the button (don't move the mouse, wait for the button to arrive), click and hold the mouse button down.

STEP 4: Now move the mouse (while holding the mouse button down). You should be in a drag-and-drop operation, with the pixmap locked to your mouse!

Okay, it works, but there are a few downsides.

 

3. Problem

At the end of the animation, the flying button is under your mouse pointer. But if you move your mouse pointer a tiny bit, the button disappears and you miss the drag-and-drop operation.
In other words, what I got now is not very robust. The user can easily miss the drag-and-drop operation.

NOTE: Apparently the problem I describe here only appears on Windows (not on Linux). But I got to make this thing work on Windows...

 

4. Potential solution

I believe the following approach would be better, and still intuitive to the user:

As soon as the button arrives under the mouse pointer (the end of the animation), the button fades away. The drag-and-drop operation starts automatically, without the need to click and hold down the mouse button. The drag continues while you move the mouse pointer, until you click somewhere. That mouse press is the dropEvent().

Do you know how to implement this? Or perhaps you have another approach in mind?

 

5. Notes

My question is actually the sequel of this one: How display a QPropertyAnimation() on top of the QScrollArea()?
Thank you @eyllanesc for solving that one ^_^

回答1:

1. Solution

Before presenting a solution, I want to express my gratitude to Mr. @eyllanesc for helping me. Without his help, I wouldn't have a solution right now.

from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import sys, functools

class MyButton(QPushButton):
    '''
    A special push button.
    '''
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setFixedWidth(300)
        self.setFixedHeight(30)
        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.showMenu)
        self.dragStartPosition = 0
        self.set_style(False)
        return

    def set_style(self, blink):
        if blink:
            background = "#d3d7cf"
        else:
            background = "#2e3436"
        self.setStyleSheet(f"""
            QPushButton {{
                /* white on red */
                background-color:{background};
                color:#ffffff;
                border-color:#888a85;
                border-style:solid;
                border-width:1px;
                border-radius: 6px;
                font-family:Courier;
                font-size:10pt;
                padding:2px 2px 2px 2px;
            }}
        """)
        self.update()
        return

    def showMenu(self, pos):
        '''
        Show this popup menu when the user clicks with the right mouse button.
        '''
        menu = QMenu()
        menuAction_01 = menu.addAction("action 01")
        menuAction_02 = menu.addAction("action 02")
        menuAction_03 = menu.addAction("action 03")
        menuAction_04 = menu.addAction("action 04")
        menuAction_grab = menu.addAction("grab")
        action = menu.exec_(self.mapToGlobal(pos))
        if action == menuAction_01:
            print("clicked on action 01")
        elif action == menuAction_02:
            print("clicked on action 02")
        elif action == menuAction_03:
            print("clicked on action 03")
        elif action == menuAction_04:
            print("clicked on action 04")
        elif action == menuAction_grab:
            print("clicked on grab")
            # Start animation -> button moves to mouse pointer
            self.animate()
        return

    def animate(self):
        '''
        The button removes itself from the QScrollArea() and flies to the mouse cursor.
        For more details, see the anser of @eyllanesc at
        https://stackoverflow.com/questions/56216698/how-display-a-qpropertyanimation-on-top-of-the-qscrollarea
        '''
        def start():
            startpoint = self.window().mapFromGlobal(self.mapToGlobal(QPoint()))
            endpoint = self.window().mapFromGlobal(QCursor.pos() - QPoint(int(self.width()/2), int(self.height()/2)))
            self.setParent(self.window())
            anim = QPropertyAnimation(
                self,
                b"pos",
                self,
                duration=500,
                startValue=startpoint,
                endValue=endpoint,
                finished=blink,
            )
            anim.start()
            self.show()
            return
        def blink():
            # Flash the button to catch attention
            self.setText("GRAB ME")
            QTimer.singleShot(10, functools.partial(self.set_style, True))
            QTimer.singleShot(100, functools.partial(self.set_style, False))
            QTimer.singleShot(200, functools.partial(self.set_style, True))
            QTimer.singleShot(300, functools.partial(self.set_style, False))
            QTimer.singleShot(400, functools.partial(self.set_style, True))
            QTimer.singleShot(500, functools.partial(self.set_style, False))
            finish()
            return
        def finish():
            # After two seconds, hide the button
            # (even if user did not grab it)
            QTimer.singleShot(2000, self.hide)
            return
        start()
        return

    def start_drag(self):
        '''
        Start the drag operation.
        '''
        # 1. Start of drag-and-drop operation
        #    => button must disappear
        self.hide()

        # 2. Initiate drag-and-drop
        drag = QDrag(self)
        pixmap = QPixmap("my_pixmap.png")
        pixmap = pixmap.scaledToWidth(100, Qt.SmoothTransformation)
        drag.setPixmap(pixmap)
        mimeData = QMimeData()
        mimeData.setText("Foobar")
        drag.setMimeData(mimeData)
        dropAction = drag.exec(Qt.CopyAction | Qt.MoveAction)
        return

    def mousePressEvent(self, event):
        '''
        Left or Right mouseclick
        '''
        def leftmouse():
            print("left mouse click")
            self.dragStartPosition = event.pos()
            return
        def rightmouse():
            print("right mouse click")
            return
        if event.button() == Qt.LeftButton:
            leftmouse()
            return
        if event.button() == Qt.RightButton:
            rightmouse()
            return
        return

    def mouseMoveEvent(self, event):
        '''
        Mouse move event
        '''
        event.accept()
        if event.buttons() == Qt.NoButton:
            return
        if self.dragStartPosition is None:
            return
        if (event.pos() - self.dragStartPosition).manhattanLength() < QApplication.startDragDistance():
            return
        self.start_drag()
        return

class CustomMainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setGeometry(100, 100, 600, 300)
        self.setWindowTitle("ANIMATION & DRAG AND DROP")

        # OUTER FRAME
        # ============
        self.frm = QFrame()
        self.frm.setStyleSheet("""
            QFrame {
                background: #d3d7cf;
                border: none;
            }
        """)
        self.lyt = QHBoxLayout()
        self.frm.setLayout(self.lyt)
        self.setCentralWidget(self.frm)

        # BUTTON FRAME
        # =============
        self.btn_frm = QFrame()
        self.btn_frm.setStyleSheet("""
            QFrame {
                background: #ffffff;
                border: none;
            }
        """)
        self.btn_frm.setFixedWidth(400)
        self.btn_frm.setFixedHeight(200)
        self.btn_lyt = QVBoxLayout()
        self.btn_lyt.setAlignment(Qt.AlignTop)
        self.btn_lyt.setSpacing(5)
        self.btn_frm.setLayout(self.btn_lyt)

        # SCROLL AREA
        # ============
        self.scrollArea = QScrollArea()
        self.scrollArea.setStyleSheet("""
            QScrollArea {
                border-style: solid;
                border-width: 1px;
            }
        """)
        self.scrollArea.setWidget(self.btn_frm)
        self.scrollArea.setWidgetResizable(True)
        self.scrollArea.setFixedWidth(400)
        self.scrollArea.setFixedHeight(150)
        self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
        self.lyt.addWidget(self.scrollArea)

        # ADD BUTTONS TO BTN_LAYOUT
        # ==========================
        self.btn_lyt.addWidget(MyButton("Foo"))
        self.btn_lyt.addWidget(MyButton("Bar"))
        self.btn_lyt.addWidget(MyButton("Baz"))
        self.btn_lyt.addWidget(MyButton("Qux"))
        self.show()

        self.setAcceptDrops(True)
        return

    def dropEvent(self, event):
        event.acceptProposedAction()
        print("dropEvent at {0!s}".format(event))
        return

    def dragLeaveEvent(self, event):
        event.accept()
        return

    def dragEnterEvent(self, event):
        event.acceptProposedAction()
        return

if __name__== '__main__':
    app = QApplication(sys.argv)
    QApplication.setStyle(QStyleFactory.create('Plastique'))
    myGUI = CustomMainWindow()
    sys.exit(app.exec_())

This is what I changed:

  1. I improved the animation. Not the top left corner but the middle of the button flies to your mouse pointer when you click "grab" in the rightmouse menu. This improvement makes it much easier to grab the button once the animation has finished.

  2. At the end of the animation, the button flashes for a short moment to catch the user's attention. The text on the button changes into "GRAB ME". The button's self.hide() function is NOT called for two seconds. So the user has two seconds time to initiate a drag-and-drop operation.

  3. Initiating the drag-and-drop operation happens in the usual way: hold down the leftmouse button and move the mouse pointer.

  4. If the user doesn't do anything for two seconds, the button will just disappear. Otherwise, the button would just sit there indefinitely.

 

2. Results

It works like a charm. Just copy past the code in a .py file and run it with Python 3.x (I got Python 3.7) and PyQt5:

 

3. Conclusion

I know that this solution is not exactly what I aimed at from the beginning: performing a drag-and-drop without holding down the mouse button. Nevertheless, I think the new approach is better because it's closer to the general intuition what a drag-and-drop actually is.