添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

最近做了个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

Dragging Indicator 自定义

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

此时有两种解决方案

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

    方法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没有了

    或者你也可以通过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中有两个问题

  • 我需要的是拖动整行,所以我希望矩形框是包围整行的
  • 插入两个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

    当然既然这是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()

    为了在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比较老)

    上图中的问题是,如图所示虽然我改变了放手时的判定,现在没以前那么灵敏了,(因为判定插入位置的要求放宽了,只要离间隙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) 在上图的效果中,既放宽了插入的判定,又画出了包围整行的框,而且放手的时候也是“所见即所得”的了(即当是横线的时候放手就是插入,是框的时候放手就是成为子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

    未完待续 …