MayaのframeLayoutをPySideで作る!!

MayaのframeLayoutって、たくさんあるUIを1つにまとめて、折りたたんだり、開いたりできて便利ですよね(*´ω`*)

し!か!し!

この便利なframeLayoutがPySideには、、、

ない(´-﹏-`;)

なぜだ、、、なぜなんだーーーー!PySideよ!っという感じですよね、、、(´;ω;`)

今回は、paintEventなどを駆使して、frameLayoutっぽいものをPySideで作ってみたいと思います(`・ω・´)ゞ

クラスの用意

MayaっぽいframeLayoutを作るにあたって、今回は、QGroupBoxをベースにやってみます!フレームに表示するタイトルは、QGroupBoxにあるものをそのまま使います。

from PySide import QtCore
from PySide import QtGui

class FrameWidget(QtGui.QGroupBox):
	def __init__(self, title='', parent=None):
		super(FrameWidget, self).__init__(title, parent)

この段階で、以下のようなサンプルコードで使ってみます。このテストコードは、何度か使います(`・ω・´)ゞ

window = QtGui.QMainWindow()
window.setWindowTitle('Frame Widget Test')

frame = FrameWidget('Frame Title', window)
window.setCentralWidget(frame)

widget = QtGui.QWidget(frame)
layout = QtGui.QVBoxLayout(widget)
frame.setLayout(layout)
for i in range(5):
	layout.addWidget(QtGui.QPushButton('Button %s' % i, widget))

window.show()

すると、こんな感じになります。普通にQGroupBoxを使っただけですね、、、w

QGroupBoxにWidgetを追加

次は、frameLayoutの要素として必要なWidgetを、QGroupBoxの中に作っていきます。

from PySide import QtCore
from PySide import QtGui

class FrameWidget(QtGui.QGroupBox):
	def __init__(self, title='', parent=None):
		super(FrameWidget, self).__init__(title, parent)
		
		layout = QtGui.QVBoxLayout()# (1)
		layout.setContentsMargins(0, 7, 0, 0)
		layout.setSpacing(0)
		super(FrameWidget, self).setLayout(layout)#(2)
		
		self.__widget = QtGui.QFrame(parent)# (3)
		self.__widget.setFrameShape(QtGui.QFrame.Panel)
		self.__widget.setFrameShadow(QtGui.QFrame.Plain)
		self.__widget.setLineWidth(0)
		layout.addWidget(self.__widget)#(4)
	
	def setLayout(self, layout):#(5)
		self.__widget.setLayout(layout)

まず、ウィジェットを追加するにはレイアウトが必要なので、QVBoxLayoutを追加します(1)。しかし、FrameWidgetが完成した時に、中に入れたいものがある時にもLayoutを突っ込んでもらうことを想定しています。なので、「setLayout」をオーバーライドしても大丈夫なように「super」を使ってQGroupBoxにレイアウトを追加しました。(2)

次に、FrameWidgetの下部のようにするために、QFrameを作成します(3)。このQFrameは、Maya2016らへんのデザインのように境界線や陥没した感じにならないように、FrameShapeとFrameShadowを設定しました。

作成したQFrameは、内部使用のレイアウトに追加します。(4)

完成した時にFrameWidgetに追加するレイアウトのために、「setLayout」をオーバーライドします(5)。指定されたレイアウトは、QFrameの方にレイアウトを追加するように修正します。

この状態で、実行すると、このような見た目に変化します。タイトルの下の隙間が、ちょーっと広くなりました(;´∀`)

折りたたみ状況の追加

次に、折りたたみのON/OFFのためのデータを追加し、マウスでクリックすると、QFrameが表示されたり、非表示になるようにします。

from PySide import QtCore
from PySide import QtGui

class FrameWidget(QtGui.QGroupBox):
	def __init__(self, title='', parent=None):
		super(FrameWidget, self).__init__(title, parent)
		
		layout = QtGui.QVBoxLayout()
		layout.setContentsMargins(0, 7, 0, 0)
		layout.setSpacing(0)
		super(FrameWidget, self).setLayout(layout)
		
		self.__widget = QtGui.QFrame(parent)
		self.__widget.setFrameShape(QtGui.QFrame.Panel)
		self.__widget.setFrameShadow(QtGui.QFrame.Plain)
		self.__widget.setLineWidth(0)
		layout.addWidget(self.__widget)
		
		self.__collapsed = False#(1)
	
	def setLayout(self, layout):
		self.__widget.setLayout(layout)
		
	def expandCollapseRect(self):#(2)
		return QtCore.QRect(0, 0, self.width(), 20)#(3)

	def mouseReleaseEvent(self, event):#(4)
		if self.expandCollapseRect().contains(event.pos()):#(5)
			self.toggleCollapsed()
			event.accept()
		else:
			event.ignore()
	
	def toggleCollapsed(self):#(6)
		self.setCollapsed(not self.__collapsed)
		
	def setCollapsed(self, state=True):#(7)
		self.__collapsed = state

		if state:#(8)
			self.setMinimumHeight(20)
			self.setMaximumHeight(20)
			self.__widget.setVisible(False)
		else:
			self.setMinimumHeight(0)
			self.setMaximumHeight(1000000)
			self.__widget.setVisible(True)

window = QtGui.QMainWindow()
window.setWindowTitle('Frame Widget Test')

frame = FrameWidget('Frame Title', window)
window.setCentralWidget(frame)

widget = QtGui.QWidget(frame)
layout = QtGui.QVBoxLayout(widget)
frame.setLayout(layout)
for i in range(5):
	layout.addWidget(QtGui.QPushButton('Button %s' % i, widget))

window.show()

最初に、「__init__」に、折りたたみ状況を管理するためのインスタンス変数を追加します(1)。

次に、マウスでクリックした時の動作を設定していきたいのですが、どこをクリックしてもOKっというわけには行けません、、、できれば、上部のフレーム部分に限定したいです。そこで、フレームの大きさを管理するためのメソッド「expandCollapseRect」を用意します(2)。

このメソッド「expandCollapseRect」では、FrameWidgetの左上から、現状の幅で、上から20pxの範囲を「QRect」を使って作成しています。(3)

ボタンをクリックして離した時に、ON/OFFを発動させたいので「mouseReleaseEvent」をオーバーライドします(4)。クリックした場所が適切な場所かチェックしたいのですが、QRectの「contains」を使うと、簡単に当たり判定を行えます!先程のメソッド「expandCollapseRect」を使って有効範囲を取得して、当たっている場合は、後述の処理をするように条件分岐し、イベントをacceptします。当たっていない場合は、ignoreして終了します(5)。

次は、ON/OFFがしやすいように、トグル用のメソッド「toggleCollapsed」を用意します。インスタンス変数の値を反転して、実際のON/OFFの処理を呼び出すようにします(6)。

最後に、ON/OFFの具体的な処理をするメソッド「setCollapsed」を用意します(7)。

ONの場合は、折りたたむ状態なので、FrameWidgetの高さが「20px」になるようにして、QFrameの表示をOFFにします。これで、FrameWidgetに入れたボタン達が見えなくなります。OFFの場合は、展開する状態なので、高さの制限に非現実的な値を設定し、レイアウトに応じた高さになるようにして、QFrameの表示をONにします(8)。

この状態で実行すると、このような感じになります。

paintEventの実装1

いよいよ目玉の「paintEvent」をオーバーライドして、ぐっとframeLayoutのような見た目になるようにしていきます。

from PySide import QtCore
from PySide import QtGui

class FrameWidget(QtGui.QGroupBox):
	def __init__(self, title='', parent=None):
		super(FrameWidget, self).__init__(title, parent)
		
		layout = QtGui.QVBoxLayout()
		layout.setContentsMargins(0, 7, 0, 0)
		layout.setSpacing(0)
		super(FrameWidget, self).setLayout(layout)
		
		self.__widget = QtGui.QFrame(parent)
		self.__widget.setFrameShape(QtGui.QFrame.Panel)
		self.__widget.setFrameShadow(QtGui.QFrame.Plain)
		self.__widget.setLineWidth(0)
		layout.addWidget(self.__widget)
		
		self.__collapsed = False
	
	def setLayout(self, layout):
		self.__widget.setLayout(layout)
		
	def expandCollapseRect(self):
		return QtCore.QRect(0, 0, self.width(), 20)

	def mouseReleaseEvent(self, event):
		if self.expandCollapseRect().contains(event.pos()):
			self.toggleCollapsed()
			event.accept()
		else:
			event.ignore()
	
	def toggleCollapsed(self):
		self.setCollapsed(not self.__collapsed)
		
	def setCollapsed(self, state=True):
		self.__collapsed = state

		if state:
			self.setMinimumHeight(20)
			self.setMaximumHeight(20)
			self.__widget.setVisible(False)
		else:
			self.setMinimumHeight(0)
			self.setMaximumHeight(1000000)
			self.__widget.setVisible(True)
			
	def paintEvent(self, event):
		painter = QtGui.QPainter()#(1)
		painter.begin(self)
		
		font = painter.font()#(2)
		font.setBold(True)
		painter.setFont(font)

		x = self.rect().x()#(3)
		y = self.rect().y()
		w = self.rect().width()
		offset = 25
		
		painter.setRenderHint(painter.Antialiasing)(4)
		painter.fillRect(self.expandCollapseRect(), QtGui.QColor(93, 93, 93))#(5)
		painter.drawText(
			x + offset, y + 3, w, 16,
			QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop,
			self.title()
			)#(6)
		painter.setRenderHint(QtGui.QPainter.Antialiasing, False)
		painter.end()

まず、描画に必要なQPainterを用意し、描画をスタートさせます(1)。

次に、文字を書くためのフォントを準備します。ここでは、デフォルトのフォントを取得して、Boldになるように設定を変更しQPainterに設定します(2)。

次に、描画に必要な座標をやオフセットを取得します(3)。後述の説明と合わせて観ていただければ幸いです。

描画を始める前に、まずアンチエイリアスをONにします(4)。描画は書いた順番に重なって行くので、いちばん奥の背景を当たり判定にも使った「expandCollapseRect」の範囲で塗りつぶします(5)。

次にFrameWidgetに設定されたタイトルを描画します。次の章でやる▼を描画するために、左側に隙間をあけておきます(6)。

最後にアンチエイリアスをOFFにして、描画を終了します。

この状態で実行すると、以下のようになります。ダイブ完成に近づいてきましたね!(゚∀゚)

paintEventの実装2

最後に▼の描画を追加して、FrameWidgetを完成させたいと思います。ちょっと処理が長いので、▼の描画は、別途メソッドを用意することにしました。

from PySide import QtCore
from PySide import QtGui

class FrameWidget(QtGui.QGroupBox):
	def __init__(self, title='', parent=None):
		super(FrameWidget, self).__init__(title, parent)
		
		layout = QtGui.QVBoxLayout()
		layout.setContentsMargins(0, 7, 0, 0)
		layout.setSpacing(0)
		super(FrameWidget, self).setLayout(layout)
		
		self.__widget = QtGui.QFrame(parent)
		self.__widget.setFrameShape(QtGui.QFrame.Panel)
		self.__widget.setFrameShadow(QtGui.QFrame.Plain)
		self.__widget.setLineWidth(0)
		layout.addWidget(self.__widget)
		
		self.__collapsed = False
	
	def setLayout(self, layout):
		self.__widget.setLayout(layout)
		
	def expandCollapseRect(self):
		return QtCore.QRect(0, 0, self.width(), 20)

	def mouseReleaseEvent(self, event):
		if self.expandCollapseRect().contains(event.pos()):
			self.toggleCollapsed()
			event.accept()
		else:
			event.ignore()
	
	def toggleCollapsed(self):
		self.setCollapsed(not self.__collapsed)
		
	def setCollapsed(self, state=True):
		self.__collapsed = state

		if state:
			self.setMinimumHeight(20)
			self.setMaximumHeight(20)
			self.__widget.setVisible(False)
		else:
			self.setMinimumHeight(0)
			self.setMaximumHeight(1000000)
			self.__widget.setVisible(True)
	
	def paintEvent(self, event):
		painter = QtGui.QPainter()
		painter.begin(self)
		
		font = painter.font()
		font.setBold(True)
		painter.setFont(font)

		x = self.rect().x()
		y = self.rect().y()
		w = self.rect().width()
		offset = 25
		
		painter.setRenderHint(painter.Antialiasing)
		painter.fillRect(self.expandCollapseRect(), QtGui.QColor(93, 93, 93))
		painter.drawText(
			x + offset, y + 3, w, 16,
			QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop,
			self.title()
			)
		self.__drawTriangle(painter, x, y)#(1)
		painter.setRenderHint(QtGui.QPainter.Antialiasing, False)
		painter.end()
		
	def __drawTriangle(self, painter, x, y):#(2)		
		if not self.__collapsed:#(3)
			points = [	QtCore.QPoint(x+10,  y+6 ),
						QtCore.QPoint(x+20, y+6 ),
						QtCore.QPoint(x+15, y+11)
						]
			
		else:
			points = [	QtCore.QPoint(x+10, y+4 ),
						QtCore.QPoint(x+15, y+9 ),
						QtCore.QPoint(x+10, y+14)
						]
			
		currentBrush = painter.brush()#(4)
		currentPen   = painter.pen()
		
		painter.setBrush(
			QtGui.QBrush(
				QtGui.QColor(187, 187, 187),
				QtCore.Qt.SolidPattern
				)
			)#(5)
		painter.setPen(QtGui.QPen(QtCore.Qt.NoPen))#(6)
		painter.drawPolygon(QtGui.QPolygon(points))#(7)
		painter.setBrush(currentBrush)#(8)
		painter.setPen(currentPen)

まず、paintEventに▼の描画メソッド「__drawTriangle」を呼び出すようにします(1)。

次にメソッド「__drawTriangle」を実装していきます。引数にpainterと、描画を開始する座標XYを指定できるようにしました(2)。

三角形を描画をするには、3点の位置を決める必要があります。指定されたXYをもとに、状態に合わせて「▼」「▶」になるように3点を作りListでまとめておきます(3)。

三角形を描画するにあたり、ブラシとペンを変更するので、現状のデータを取得します。これは後で元に戻す時に使用します(4)(8)。

次に、三角形の色と、塗り方を決めたQBrushを作成しpainterに設定します(5)。輪郭の描画は不要なのでPenはなしにします(6)。最後に、指定した点を結ぶ多角形を描画する「drawPolygon」を使って、三角形を描画します(7)。

これで実行すると、以下のようになります!ちょっと長いコードになってしまいましたが、MayaライクなFrameWidgetが出来上がりました!