メインコンテンツまでスキップ

CustomLayoutを作ろう

最近あまり PySide を触っていなかったせいで、だいぶ忘れかけてしまったので
勉強を兼ねて C++のサンプルを参考に PySide のカスタムレイアウトを作ってみます。 https://doc.qt.io/qt-5/qtwidgets-layouts-flowlayout-example.html 参考にしたサンプルはこちら。

PySide のレイアウト

まず、PySide にはデフォルトで

  • Vertical Layout(縦に並べる)
  • Horizontal Layout(横に並べる)
  • Grid Layout(格子状に並べる)
  • Form Layout(Form みたいに並べる)

の4つが用意されています。
大体のことはこの4つでできますが、それ以外に幅によって改行するようなレイアウトを作りたい場合
などは、自分で作らないといけません。

https://doc.qt.io/qt-5/qtwidgets-layouts-flowlayout-example.html

このようなレイアウトを、「Flow Layout」呼ぶようで
公式に、C++のコードサンプルが用意されています。

ので、今回はこのサンプルをベースに、PySide2 でかきなおしつつ
細かい実装方法をみていこうとおもいます。

QLayout / QLayoutItem

まず、PySide のレイアウトは、QLayout を継承して作成します。

クラスの継承はこのようになっています。

まず、QLayoutItem が、QLayout が操作する抽象的なアイテムを提供します。 QLayout を継承して作るカスタムレイアウトは、この QLayoutItem で指定された関数を
再実装することで、作ることができます。

class SampleGridLayout(QLayout):
def __init__(self, margin: int, hSpacing: int, vSpacing: int, parent=None):
super().__init__(parent=parent)
self.setContentsMargins(margin, margin, margin, margin)

self.itemList = []
self.hSpacing = hSpacing
self.vSpacing = vSpacing

まずは基本構造。

カスタムレイアウトを作る場合は、QLayout を継承したクラスを作成します。
まず、レイアウトは、Widget に対して配置します。
そしてその Widget と Layout のスペース(緑の部分)が contentsMargins という形で指定されます。
それとは別に、FlowLayout では
Widget と Widget との間のスペースを調整できるようにしたいので、そのパラメーターを hSpacing vSpacing という形で指定できるようにします。

今回作りたい FlowLayout はどういう挙動かというと、
基本は横方向に Widget を配置し、配置する Widget がウィンドウの範囲を超えた場合 越えたところで Widget を改行させます。 なので、まず必要なのはレイアウトの範囲の取得と、ウィジェットの大きさの取得 そしてそれをどこのタイミングでアップデートするのか? というのが今回の実装のポイントになります。

必要な関数の実装

まず、配置部分の前に、最低限必要な関数の解説から。 QLayout でレイアウトを実装する場合
かならず定義がひつようとなる関数があります。 それが

  • addItem
  • sizeHint
  • setGeometry
  • itemAt
  • takeAt
  • hasHeightForWidth
  • heightForWidth
  • count

です。

レイアウトの追加/取得

    def addItem(self, item: QLayoutItem):

self.itemList.append(item)

def count(self):

return len(self.itemList)

def itemAt(self, index: int):

if self.count() > 0 and index < self.count():
return self.itemList[index - 1]
return None

def takeAt(self, index):

if index >= 0 and index < self.count():
return self.itemList.pop(index - 1)
return None

Widget の追加・取得関係

addItem は、QLayout で addWidget したときに呼ばれる関数で
その名の通りレイアウトに配置するアイテムを変数に入れる部分です。
今回のレイアウトは二次元配列ではなく、レイアウトの幅に応じて改行するので 単純なリストであれば OK です。

このときに受け取るのは、Widget ではなく QLayoutItem になります。

count はその名の通りレイアウトに配置している Widget 数を返します。

itemAt は、指定の Index のアイテムを返し
takeAt は、指定の Index のアイテムを取り出して返します。

    def hasHeightForWidth(self):
return True

def heightForWidth(self, width: int):
height = self.doLayout(QRect(0, 0, width, 0))
return height

次に hasHeightForWidth と heightForWidth。 これは、レイアウトのサイズが横サイズに応じて縦のサイズが決定する場合
hasHeightForWidth を True にします。 今回の場合は動的に決めたいので True をかえしていますが
そうではない場合、hasHeightForWidth で判定を入れることで heightForWidth が呼ばれないので
その分処理を軽くすることができます。

heightForWidth の処理は doLayout のときに詳しく書きます。

sizeHint


def sizeHint(self):
return self.minimumSize()

def minimumSize(self):

size = QSize()
for item in self.itemList:
size = size.expandedTo(item.minimumSize())

margins = self.contentsMargins()
size += QSize(margins.left() + margins.right(), margins.top() + margins.bottom())

次にレイアウトサイズ関連。 今回の場合の最小サイズは、レイアウトに配置している Widget の中で最も大きな値+マージンを指定するようにします。

doLayout

    def setGeomertry(self, rect):
# 配置されるWidgetのジオメトリを計算するときに呼び出される
super().setGeometry(rect)
self.doLayout(rect)

def doLayout(self, rect):

# rect は、現在のLayoutの矩形
x = rect.x()
y = rect.y()
lineHeight = 0

for item in self.itemList:
wid = item.widget()
# 次のWidgetの配置場所
nextX = x + item.sizeHint().width() + self.hSpacing
if nextX - self.hSpacing > rect.right() and lineHeight > 0:
x = rect.x()
y = y + lineHeight + self.vSpacing
nextX = x + item.sizeHint().width() + self.hSpacing
lineHeight = 0

item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = nextX
lineHeight = max(lineHeight, item.sizeHint().height())

return y + lineHeight - rect.y()

そしてこれが今回のメインになるレイアウト部分です。

まず、レイアウトの再配置の計算が必要になるタイミングがなにかというと

  • setGeometry で、レイアウトの大きさをコマンドで変更した場合
  • ウィンドウサイズを変更した場合

の 2 パターンになります。 このうち、setGeometry を使った場合は、指定の QRect(矩形)を受け取ったときに そのサイズにレイアウトサイズを変更後、その枠内に収まるように再配置をします。

ウィンドウサイズを変更した場合が heightForWidth で、
ウィンドウを変更したときにこの関数が呼ばれ、その中の処理が実行されるようになります。

height = self.doLayout(QRect(0, 0, width, 0))

そのときに呼ぶのがこの再配置のメイン処理を行う doLayout。
このときは横のサイズは決定しているので横サイズ以外は 0 にした QRect を
引数として渡します。

レイアウトのメイン処理

まず、レイアウトの中を書き表すとこのようになります。 配置した Widget と Widget の間のスペースは hSpacing vSpacing として指定されています。 レイアウトの位置は、

レイアウトの矩形(黄色部分)は、Geometry という名前で表されていて、 setGeometry getometry 関数でそれぞれ指定や取得をすることができます。 取得できるのは QRectです。 この QRect はレイアウト・Widget それぞれの現在の位置(x,y)と大きさ(width,height)を取得・設定することができます。

まず、引数で指定した Rect から、開始位置の座標を決めます。

そして、配置している Widget 分繰り返します。 widget のサイズは、 item.sizeHint().width() で取得することができるので、
配置位置は 前回の配置位置 + Widget サイズ(sizeHint().width()) + 最初にしていたスペースです。 そして、その位置がレイアウトの大きさ(geometry())よりも大きい場合は
次の行に改行したいので、

            if nextX - self.hSpacing > rect.right() and lineHeight > 0:
x = rect.x()
y = y + lineHeight + self.vSpacing
nextX = x + item.sizeHint().width() + self.hSpacing

x は初期位置(引数で指定された左側)に戻し、
配置しているアイテムの height() つまり右下の位置にスペース分の値を追加した値を
セットします。

そうして計算した位置に、item 自体のサイズ(item.sizeHint()) の QRect を作り
item に対して setGeometry を使用して移動します。

最後は、最終的な高さを返します。 この値が、heightForWidth (指定した width のときに自動てきにきまる高さ)
となります。

https://snippets.cacher.io/snippet/1a7849e8231b23939f03 https://snippets.cacher.io/snippet/5c7938abe6b116fbb156 (テスト用 UI) 全コードはこちら。

FlowLayout を利用して、ScrollArea に対して 100 個のボタンを配置しましました。
よくある使い方として、タグクラウド的な UI を作りたい場合
この FlowLayout は重宝します。

まとめ

最初はどうやって作るのかわかりにくかったですが、 heightForWidth と gometry を理解できれば、全体像がわかりやすくなります。

VBox や HBox などでは微妙にやりにくいものなどは カスタムを作ると色々便利になりそうです。

参考