一半君的总结纸

听话只听一半君

QTreeWidget 自定义拖拽之乱搞

最近做了个ui需要用到树形结构(本来该用TreeView的,一开始图简单用了TreeWidget,后悔中…)

同时这也可以作为问问题的标准模板(在StackOverflow问或是QQ群里问或是去#pyqt问),lz把所有折腾的例子都放在github的gists里了(以提升逼格).(唉,直接贴gists行号对不上,免费wordpress.com没法自定义css或是javascript…)

默认问问题模板在此

#!/usr/bin/env python2
import os
import sys
import re

from PyQt4 import QtGui, QtCore
from PyQt4.QtCore import Qt, QString

class TheUI(QtGui.QDialog):

    def __init__(self, args=None, parent=None):
        super(TheUI, self).__init__(parent)
        self.layout1 = QtGui.QVBoxLayout(self)
        treeWidget = QtGui.QTreeWidget()

        # treeWidget.setSelectionMode( QtGui.QAbstractItemView.ExtendedSelection )

        button1 = QtGui.QPushButton('Add')
        button2 = QtGui.QPushButton('Add Child')

        self.layout1.addWidget(treeWidget)

        self.layout2 = QtGui.QHBoxLayout()
        self.layout2.addWidget(button1)
        self.layout2.addWidget(button2)

        self.layout1.addLayout(self.layout2)

        treeWidget.setHeaderHidden(True)

        self.treeWidget = treeWidget
        self.button1 = button1
        self.button2 = button2
        self.button1.clicked.connect(lambda *x: self.addCmd())
        self.button2.clicked.connect(lambda *x: self.addChildCmd())

        HEADERS = ( "script", "chunksize", "mem" )
        self.treeWidget.setHeaderLabels(HEADERS)
        self.treeWidget.setColumnCount( len(HEADERS) )

        self.treeWidget.setColumnWidth(0,160)
        self.treeWidget.header().show()

        self.treeWidget.setDragDropMode(QtGui.QAbstractItemView.InternalMove)

        self.resize(500,500)
        for i in xrange(6):
            item =self.addCmd(i)
            if i in (3,4):
                self.addChildCmd()
                if i==4:
                    self.addCmd('%s-2' % i,parent=item)

        self.treeWidget.expandAll()
        self.setStyleSheet("QTreeWidget::item{ height: 30px;  }")

    def addChildCmd(self):
        parent = self.treeWidget.currentItem()
        self.addCmd(parent=parent)
        self.treeWidget.setCurrentItem(parent)

    def addCmd(self, i=None,parent=None):
        'add a level to tree widget'

        root = self.treeWidget.invisibleRootItem()
        if not parent:
            parent=root

        if i is None:
            if parent == root:
                i = self.treeWidget.topLevelItemCount()
            else:
                i = str(parent.text(0))[7:]
                i = '%s-%s' %(i,parent.childCount()+1)

        item = QtGui.QTreeWidgetItem(parent,['script %s' %i,'1','150'])

        self.treeWidget.setCurrentItem(item)
        self.treeWidget.expandAll()
        return item

if __name__ == '__main__':
    app = QtGui.QApplication(sys.argv)
    gui = TheUI()
    gui.show()
    app.exec_()

此例子开了多选(特意先注释掉了,因为不好处理拖拽),拖拽,下面两个按钮可以加item
simple tree widget

Dragging Indicator 自定义

遇到的第一个想自定义的地方是不想显示放手之前的dragging indicator(也就是被拖动的那行的截图示意),因为这个ui最后有很多嵌套的树形结构,如果显示那一大条蓝色的截图,很难看到放手的位置具体在哪里(用户不知道何时放手,才能放到他想放的位置,黑色横线可能会被那长条挡住)

此时有两种解决方案

  1. 想办法把拖动的蓝条搞成透明的(这条在公司里似乎无法实现,似乎一定需要系统开有Compositing Manager才行)此处是疑惑之处,公司的CentOS 6.5没法透明,家里的7.0可以,但是我在家里执行下面代码,也是False(难道说明透明支持与否不是看这个么)
    PyQt4.QtGui import QX11Info
    QX11Info.isCompositingManagerRunning()
    
  2. 直接把那个蓝条隐藏了

先来看方法1,主要是自定义mouseMoveEvent来达到的,完整代码在这里

class MyTreeWidget(QtGui.QTreeWidget):

  def mouseMoveEvent(self, e):
        listsQModelIndex = self.selectedIndexes()
        if listsQModelIndex:
            dataQMimeData = self.model().mimeData(listsQModelIndex)

            index = self.indexFromItem(self.currentItem(),0)
            row = index.row()

            drag_rect =  self.visualItemRect(self.itemFromIndex(index))

            header_height = self.header().sizeHint().height()

            row_height = self.rowHeight(index)
            # screenshot_y = row * row_height +  header_height +2

            screenshot_y = drag_rect.y() + row_height

            pixmap = QtGui.QPixmap.grabWidget(self,0,screenshot_y,-1,row_height)

            painter = QtGui.QPainter(pixmap)
            painter.setCompositionMode(painter.CompositionMode_DestinationIn)
            painter.fillRect(pixmap.rect(), QtGui.QColor(0, 0, 0, 127))
            painter.end()

            # make a QDrag
            drag = QtGui.QDrag(self)
            # put our MimeData
            drag.setMimeData(dataQMimeData)
            # set its Pixmap
            drag.setPixmap(pixmap)
            # shift the Pixmap so that it coincides with the cursor position
            # drag.setHotSpot(e.pos())

            drag.setHotSpot(QtCore.QPoint(e.pos().x(),e.pos().y()-screenshot_y+row_height))

            # start the drag operation
            # exec_ will return the accepted action from dropEvent
            drag.exec_()

simple tree widget dragging transparent

方法2 是通过reimplement startDrag, 但是不给QDrag Object设置pixmap达到的,完整代码在这里

class MyTreeWidget(QtGui.QTreeWidget):

    def startDrag (self, supportedActions):
        listsQModelIndex = self.selectedIndexes()
        if listsQModelIndex:
            mimeData = QtCore.QMimeData()
            dataQMimeData = self.model().mimeData(listsQModelIndex)
            if not dataQMimeData:
                return None
            dragQDrag = QtGui.QDrag(self)
            # dragQDrag.setPixmap(QtGui.QPixmap('test.jpg')) # <- For put your custom image here
            dragQDrag.setMimeData(dataQMimeData)
            defaultDropAction = QtCore.Qt.IgnoreAction
            if ((supportedActions & QtCore.Qt.CopyAction) and (self.dragDropMode() != QtGui.QAbstractItemView.InternalMove)):
                defaultDropAction = QtCore.Qt.CopyAction;
            dragQDrag.exec_(supportedActions, defaultDropAction)

如下图所示,dragging indicator没有了
simple_treeWidget_mute_drag_indicator.py

或者你也可以通过reimplement mouseMoveEvent来让dragging indicator没有掉,总之是要让QDrag Object没有pixmap,代码在这里

class MyTreeWidget(QtGui.QTreeWidget):

    def mouseMoveEvent(self, e):
        mimeData = self.model().mimeData(self.selectedIndexes())
        drag = QtGui.QDrag(self)
        drag.setMimeData(mimeData)
        # pixmap = QtGui.QPixmap('xxx.png')
        # drag.setPixmap(pixmap)
        drag.exec_(QtCore.Qt.MoveAction)
Dropping Indicator 自定义

此外,默认效果是拖拽过程中,未放手之前的话,如果鼠标位于其他item上,会画一个矩形框,如果位于两个item中间,会画一条横线,这在我要做的ui中有两个问题

  1. 我需要的是拖动整行,所以我希望矩形框是包围整行的
  2. 插入两个item之间的”判定”太弱,导致操作的时候很难把一个item拖动,并”插入”到另外的两个item之间

试了搜了下,用stylesheet似乎没法做到,即使我把行高设得很大,或是间隔设大,插入判定依然很弱,想插入的时候手一抖就变成相邻item的子item了

先试试看delegate能不能做到,似乎是不能,下面的例子中虽然可以让鼠标指向的行下方有一条横线,但是当你放手的时候,他该去到哪里还是会去哪里,delegate只是影响显示效果而已,代码在这里

class MyDelegate(QtGui.QStyledItemDelegate):

    def paint(self, painter, option, index):
        QtGui.QStyledItemDelegate.paint(self, painter, option, index)
        painter.save()
        data = index.model().data(index, Qt.UserRole).toInt()
        # if UserRole = 1 draw custom line
        if data[1] and data[0] == 1:
            line = QtCore.QLine(option.rect.topLeft(), option.rect.topRight())
            painter.drawLine(line)
        painter.restore()

class MyTreeWidget(QtGui.QTreeWidget):

    def paintEventx(self, event):
        painter = QtGui.QPainter(self.viewport())
        self.drawTree(painter, event.region())
        # in original implementation, it calls an inline function paintDropIndicator here

    def dragMoveEvent(self, event):
        pos = event.pos()
        item = self.itemAt(pos)

        # If hovered over an item during drag, set UserRole = 1
        if item:
            index = self.indexFromItem(item)
            self.model().setData(index, 1, Qt.UserRole)

        # reset UserRole to 0 for all other indices
        # This only reset topLevel item UserRole data
        # for i in range(self.model().rowCount()):
        #     _index = self.model().index(i, 0)
        #     if not item or index != _index:
        #         self.model().setData(_index, 0, Qt.UserRole)

        iterator = QtGui.QTreeWidgetItemIterator(self)
        while iterator.value():
            item_iter = iterator.value()
            if item_iter is not item:
                _index = self.indexFromItem(item_iter, 0)
                self.model().setData(_index, 0, Qt.UserRole)
            iterator += 1

simple_treeWidget_delegate.py

当然既然这是delegate,那我也可以想办法让他画一个框出来(当鼠标指向item的时候),但是我如何知道该怎么写呢,如果在网上搜不到例子的情况下,此时只好去看qt源码,按照此法clone到本机(当然你也可以在线看,但是lz觉得clone到本机后用jedit的搜索更方便)

首先在QTreeView.cpp的paintEvent里,你会看到他调用了paintDropIndicator method, 他位于qabstractitemview_p.h里,是个inline function,进一步搜索发现,在qcommonstyle.cpp里,他只是说如果rect的高度是0,就画一条横线,不然就画一个框

上图中,放手后,delegate画的横线还在那没有消失,这是因为放手之后,放手处的item上的UseRole的data依然是1没有变,我们需要把他再设回0,代码在这里

class MyTreeWidget(QtGui.QTreeWidget):

    def dropEvent(self,e):
        pos = e.pos()
        item = self.itemAt(pos)
        if item:
            index = self.indexFromItem(item)
            self.model().setData(index, 0, Qt.UserRole)        

        QtGui.QTreeWidget.dropEvent(self,e)
        self.expandAll()

simple_treeWidget_delegate_dropEvent.py

为了在delegate里画出来框,显然你得知道鼠标当前在两个item之间,还是在item上,如果不知道怎么写,还是只能去源码里找,考虑到默认的效果是带框的,那显然应该去qabstractitemview.cpp的dragMoveEvent里找,根据这段代码可知,他是通过 position 这个method(位于qabstractitemview_p.h里)来得到鼠标指针当前位置,存在self.dropIndicatorPosition里,从而作出相应的是画线还是画框的举措的.

所以同理我可以把他搬过来,代码在这里

class MyDelegate(QtGui.QStyledItemDelegate):

    def paint(self, painter, option, index):
        QtGui.QStyledItemDelegate.paint(self, painter, option, index)
        painter.save()
        data = index.model().data(index, Qt.UserRole).toInt()

        option_rect = option.rect
        # if UserRole = 1 draw custom line
        if data[1]:
            if data[0] == QtGui.QAbstractItemView.AboveItem:
                line = QtCore.QLine(option_rect.topLeft(), option_rect.topRight())
                painter.drawLine(line)
            elif data[0] == QtGui.QAbstractItemView.BelowItem:
                line = QtCore.QLine(option_rect.bottomLeft(), option_rect.bottomRight())
                painter.drawLine(line)
            elif data[0] == QtGui.QAbstractItemView.OnItem:
                rect = QtCore.QRect(option_rect)
                painter.drawRect(rect)

        painter.restore()

通过在dragMoveEvent里把当前鼠标所在位置判断好,存在item的data里,然后在Delegate里再通过这个data来得知是该画线还是画框,判定是在position method里做出的,默认的margin是2,我给改成了10,你可以想像的到,margin*2 之后必须比行高小,不然你就没法得到 鼠标刚好在item之上(OnItem)的判定了,显然margin*2+x=行高,你至少得给x留有一定的大小吧

同时这个例子里也加上了鼠标悬停时候,下方的item的高亮阴影(通过stylesheet加的,不知为何在家里CentOS7上没加也有这效果,可能是更高版本的Qt自带效果吧,公司Qt比较老)

simple_treeWidget_delegate_dropEvent_drawRect_hotzone.py

上图中的问题是,如图所示虽然我改变了放手时的判定,现在没以前那么灵敏了,(因为判定插入位置的要求放宽了,只要离间隙10px范围内都算是“插入”位置,之前是2px),但是当你放手的时候他依然把拖拽的item“放”到了放手处的item下面,成为了他的子item,这是因为delegate只是改变了显示效果(通过禁止默认的dropIndicator的绘制,自己画线或是框),实际你放手的话该怎么样还是会怎么样(因为你控制你放手之后发生了什么的操作在QTreeWidget的dropEvent里)

还有个问题是如果我想让框包围整行,在delegate里没法直接做到,因为delegate的参数的index只是当前列的index,(我只给第一列设了item.data),没法直接得到最右边一列是第几列,当然可以把treeWidget传给delegate,从而得到最右边一列的index,不过这样总感觉违背了model/view的设计目的

既然你都自己subclass了QTreeWidget了,那何不如 不要用delegate,直接reimplement TreeWidget的dropOn method,来使你放手时,被拖拽的item去到他应该去的地方,并reimplement paintDropIndicator,来画出你想要的drop indicator呢
代码在这里

class MyTreeView(QtGui.QTreeView):

    def __init__(self, parent=None):
        super(MyTreeView, self).__init__(parent)
        self.dropIndicatorRect = QtCore.QRect()

    def paintEvent(self, event):
        painter = QtGui.QPainter(self.viewport())
        self.drawTree(painter, event.region())
        # in original implementation, it calls an inline function paintDropIndicator here
        self.paintDropIndicator(painter)

    def paintDropIndicator(self, painter):

        if self.state() == QtGui.QAbstractItemView.DraggingState:
            opt = QtGui.QStyleOption()
            opt.init(self)
            opt.rect = self.dropIndicatorRect
            rect = opt.rect

            if rect.height() == 0:
                pen = QtGui.QPen(QtCore.Qt.black, 2, QtCore.Qt.SolidLine)
                painter.setPen(pen)
                painter.drawLine(rect.topLeft(), rect.topRight())
            else:
                pen = QtGui.QPen(QtCore.Qt.black, 2, QtCore.Qt.SolidLine)
                painter.setPen(pen)
                painter.drawRect(rect)

simple_treeWidget_custom_dropIndicator.py
在上图的效果中,既放宽了插入的判定,又画出了包围整行的框,而且放手的时候也是“所见即所得”的了(即当是横线的时候放手就是插入,是框的时候放手就是成为子item)

存在的问题

  • 拖到自己身上放手,drop indicator不会消失
  • 拖到其他列的上面,没放手之前drop indicator没显示出来

通过在position里加上判断是不是在item的中间,来判定是不是OnItem,因为在dragMoveEvent里用了item = self.itemAt(pos)来得到item,此时得到的总是第一列的index
如果在自己本身上放手,就不进行后续的删除插入操作了.
代码在这里

    def position(self, pos, rect, index):
        r = QtGui.QAbstractItemView.OnViewport
        # margin*2 must be smaller than row height, or the drop onItem rect won't show
        margin = 10
        if pos.y() - rect.top() < margin:
            r = QtGui.QAbstractItemView.AboveItem
        elif rect.bottom() - pos.y() < margin:
            r = QtGui.QAbstractItemView.BelowItem

        # this rect is always the first column rect 
        # elif rect.contains(pos, True):
        elif pos.y() - rect.top() > margin and rect.bottom() - pos.y() > margin:
            r = QtGui.QAbstractItemView.OnItem

        return r

    def dropEvent(self, event):
        pos = event.pos()
        item = self.itemAt(pos)

        if  item is self.currentItem():
            QtGui.QTreeWidget.dropEvent(self, event)
            event.accept()
            return 
        ...

simple_treeWidget_custom_dropIndicator_multi_1.py

未完待续 …

Advertisements

发表评论

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / 更改 )

Twitter picture

You are commenting using your Twitter account. Log Out / 更改 )

Facebook photo

You are commenting using your Facebook account. Log Out / 更改 )

Google+ photo

You are commenting using your Google+ account. Log Out / 更改 )

Connecting to %s

%d 博主赞过: