【MayaPySide】ちょっとおしゃれなUIメソッド【2日目】

QSS,StyleSheet,PySide,Tutorial,PySide2,Python,Qt,Maya

こんにちはMayaPython Advent Calendar 2017の7日目の記事です
全記事一覧です

【MayaPySide】ちょっとおしゃれなUIメソッド【1日目】
【MayaPySide】ちょっとおしゃれなUIメソッド【2日目】
【MayaPySide】ちょっとおしゃれなUIメソッド【3日目】

ちょっとおしゃれなUIメソッドの二日目です
今回は実は予定していなかったものなのですがフレームレスの需要が高まっている中
Windowのresizeや閉じるときのアニメーションの需要があるようなので今回記事にしました。

リサイズはこんな感じの簡単なものを想定していたのですが

ysさんが素敵なサンプルを用意してくださったのでそれを利用して今回のUIを作成していきます。
Python3の対応によりここではysさんのサンプルを使用しない例に変わっています。
前回までのコードをベースに作っていきましょう。

from typing import Any

from maya import OpenMayaUI
from PySide2 import QtCore, QtGui, QtWidgets
from shiboken2 import wrapInstance

class FramelessMainWindow(QtWidgets.QMainWindow):
    radius: int = 15
    backgroundColorCode: str = "#333333"
    backgroundColor = QtGui.QColor(backgroundColorCode)

    gradient: QtGui.QLinearGradient = QtGui.QLinearGradient()
    gradient.setColorAt(0.0, "#54354e")
    gradient.setColorAt(1.0, "#6a86c7")

    def __init__(self, parent: QtWidgets.QWidget = None) -> None:
        super().__init__(parent)
        self.setAutoFillBackground(True)
        self.setAttribute(QtCore.Qt.WA_TranslucentBackground)

    def paintEvent(self, event: QtGui.QMouseEvent) -> Any:
        painter = QtGui.QPainter(self)
        painter.setRenderHint(QtGui.QPainter.Antialiasing)
        path = QtGui.QPainterPath()
        self.gradient.setStart(QtCore.QRectF(self.rect()).topLeft())
        self.gradient.setFinalStop(QtCore. QRectF(self.rect()).topRight())
        brush = QtGui.QBrush(self.gradient)
        painter.setBrush(brush)
        path.addRoundedRect(
            0, 0, self.width(), self.height(),
            self.radius, self.radius
        )
        painter.setClipPath(path)
        painter.fillPath(path, painter.brush())
        return super().paintEvent(event)

class Example(FramelessMainWindow):
    pos: QtCore.QPoint
    btn: QtWidgets.QPushButton
    def __init__(self, parent: QtWidgets.QWidget = None) -> None:
        super().__init__(parent)
        self.__initUI()
        self.setButton()

    def __initUI(self) -> None:
        self.btn = QtWidgets.QPushButton("Close", self)
        self.setWindowOpacity(0.85)
        self.setGeometry(300, 300, 500, 350)
        self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.FramelessWindowHint)

    def setButton(self) -> None:
        self.btn.move(50, 50)
        self.btn.clicked.connect(self.close)

    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
        self.pos = event.pos()

    def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
        self.pos = event.pos()

    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
        winX = event.globalX() - self.pos.x()
        winY = event.globalY() - self.pos.y()
        self.move(winX, winY)

def main() -> None:
    mayaMainWindow = wrapInstance(int(OpenMayaUI.MQtUtil.mainWindow()), QtWidgets.QMainWindow)
    ex = Example(mayaMainWindow)
    ex.show()

main()

まず最初にクローズボタンのカスタマイズからしていきましょう。クローズボタンのカスタマイズは閉じる際にフェードアウトして消えていく、というアニメーションを付けます。使用するのはQtCore.QTimerQtCore.QPropertyAnimationです。QtCore.QTimerというのは繰り返すとシングルショットタイマーを使用することができるものです。1000の単位で1秒となります。

では作っていきましょう。
まず最初にCloseButton専用のclassを作っていきます。

class ClosePushButton(QtWidgets.QPushButton):
    closed: QtCore.Signal = QtCore.Signal()

    def __init__(self, parent: QtWidgets.QWidget = None) -> None:
        super().__init__(parent)
        self.clicked.connect(self._closed)

    def _closed(self) -> None:
        self.closed.emit()

PySideにはシグナルとスロットというものがあります。
シグナルclosedを作成し、Close専用のQPushButtonを用意しました。
このclosePushButtonはクリックされたタイミングでcloseが実行されます。(設定(Slot)したファンクションが実行(Emit)されます)
このclosed専用のコードを定義することは実際はあまり意味がありませんがシグナルとスロットの扱いの参考にしてください。

次にclosePushButtonを表示するためのウィジェットを用意します。このウィジェットは後ほど、MainWindowに配置します。

class ExampleCentralWidget(QtWidgets.QWidget):

    def __init__(self, parent: QtWidgets.QWidget = None) -> None:
        super().__init__(parent)
        self.closePushButton = ClosePushButton(self)
        self.closePushButton.setText("Close")
        self.mainLayout = QtWidgets.QVBoxLayout(self)
        self.mainLayout.addWidget(self.closePushButton)
        self.mainLayout.addStretch(True)
        self.setLayout(self.mainLayout)

先ほど作ったClosePushButtonをインスタンス化して呼び出します。
フェードアウトしながらWindowを閉じる機能を実装をこのウィジェットに適応させていきましょう。

元々あるclose関数をオーバーライドします。フェードアウトの機能はQTimerQPropertyAnimationを使用し、時間をかけてWindowのOpacityを0にしていき、0になったタイミングでWindowをCloseさせます。
そしてその機能をclosePushButtonのCloseとコネクトさせます

元々用意していたbtnは使用しないのでコードから削除します。わかりやすいようにコメントアウトしておきます。


class Example(FramelessMainWindow):
    # btn: QtWidgets.QPushButton
    timer: QtCore.QTimer = QtCore.QTimer()
    fade: QtCore.QPropertyAnimation

    def __init__(self, parent: QtWidgets.QWidget = None) -> None:
        super().__init__(parent)
        self.__initUI()
        # self.setButton()
        self.setWidget()

    def __initUI(self) -> None:
        # self.btn = QtWidgets.QPushButton("Close", self)
        self.setWindowOpacity(0.85)
        self.setGeometry(300, 300, 500, 350)
        self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.FramelessWindowHint)

    # def setButton(self) -> None:
    #     self.btn.move(50, 50)
    #     self.btn.clicked.connect(self.close)

    def setWidget(self) -> None:
        self.setCentralWidget(ExampleCentralWidget(self))
        self.centralWidget().ClosePushButton.closed.connect(self.close)

    def close(self) -> None:
        self.timer.setInterval(600)
        self.timer.timeout.connect(super().close)
        self.timer.start()
        self.fade = QtCore.QPropertyAnimation(self, b"windowOpacity")
        self.fade.setStartValue(0.85)
        self.fade.setEndValue(0.0)
        self.fade.setKeyValueAt(0.5, 0.0)
        self.fade.setEasingCurve(QtCore.QEasingCurve.InOutCubic)
        self.fade.setDuration(600)
        self.fade.start()

次にWindowを移動させるする機能をFramelessMainWindowに追加していきます


class FramelessMainWindow(QtWidgets.QMainWindow):
    ...
    isDrag: bool = False
    isPress: bool = False

    def __init__(self, parent: QtWidgets.QWidget = None) -> None:
        self.installEventFilter(self)
        ...

    def paintEvent(self, event: QtGui.QMouseEvent) -> Any:
        ...

    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
        self.isPress = False
        self.pressPos = event.pos()
        super().mouseReleaseEvent(event)

    def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
        self.isPress = True
        self.pressPos = event.pos()

        super().mousePressEvent(event)

    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
        if self.isPress or self.isDrag:
            self.move(
                event.globalX() - self.pressPos.x(), event.globalY() - self.pressPos.y()
            )
        super().mouseMoveEvent(event)

    def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> Any:
        if event.type() == QtCore.QEvent.Type.MouseButtonPress:
            self.isDrag = True
            return super().eventFilter(obj, event)
        if event.type() == QtCore.QEvent.Type.MouseButtonRelease:
            self.isDrag = False
            return super().eventFilter(obj, event)

installEventFiltereventFilterを有効します。
mousePressEventmouseReleaseEventではマウスが押されているのかそうでないかをself.isPressに定義し、
eventFilterではMouseの位置情報やイベント情報を元に処理、windowを移動するイベントの際はmouseMoveEventに処理を書いています。

次に、リサイズを定義していきます。自前でリサイズ計算を定義してもよいのですがQtではQtWidgets.QSizeGripと呼ばれるトップレベルのウィンドウのサイズを変更するためのウィジェットが用意されているのでこちらを使用しましょう。
こちらは若干複雑ですがざっくり、四隅、つまり、topLeftやbottomRightなどの角はQtWidgets.QSizeGripを使用します。そして、四辺、つまりTopやRightなどの辺に関してはQtWidgets.QWidgetを継承したCornerGripというクラスを使用していきます

まず、CornerGripを定義します

class CornerGrip(QtWidgets.QWidget):
    cursor_factory = {
        QtCore.Qt.LeftEdge: QtCore.Qt.SizeHorCursor,
        QtCore.Qt.TopEdge: QtCore.Qt.SizeVerCursor,
        QtCore.Qt.RightEdge: QtCore.Qt.SizeHorCursor,
        QtCore.Qt.BottomEdge: QtCore.Qt.SizeVerCursor,
    }
    resizeFunc: Any
    mousePosition: QtCore.QPoint = None

    def __init__(
        self, parent: QtWidgets.QWidget = None, edge: QtCore.Qt.SizeAllCursor = None
    ):
        super().__init__(parent)

        cursor = self.cursor_factory.get(edge)
        self.setCursor(cursor)
        self.setResizeFunction(edge)

    def setResizeFunction(self, edge) -> None:
        if edge == QtCore.Qt.LeftEdge:
            self.resizeFunc = self.resizeLeft
        elif edge == QtCore.Qt.TopEdge:
            self.resizeFunc = self.resizeTop
        elif edge == QtCore.Qt.RightEdge:
            self.resizeFunc = self.resizeRight
        elif edge == QtCore.Qt.BottomEdge:
            self.resizeFunc = self.resizeBottom

    @cached_property
    def window(self) -> QtWidgets.QWidget:
        return super().window()

    def resizeLeft(self, delta):
        width = max(self.window.minimumWidth(), self.window.width() - delta.x())
        geo = self.window.geometry()
        geo.setLeft(geo.right() - width)
        self.window.setGeometry(geo)

    def resizeTop(self, delta):
        height = max(self.window.minimumHeight(), self.window.height() - delta.y())
        geo = self.window.geometry()
        geo.setTop(geo.bottom() - height)
        self.window.setGeometry(geo)

    def resizeRight(self, delta):
        width = max(self.window.minimumWidth(), self.window.width() + delta.x())
        self.window.resize(width, self.window.height())

    def resizeBottom(self, delta):
        height = max(self.window.minimumHeight(), self.window.height() + delta.y())
        self.window.resize(self.window.width(), height)

    def mousePressEvent(self, event: QtGui.QMouseEvent) -> Any:
        if event.button() == QtCore.Qt.LeftButton:
            self.mousePosition = event.pos()

    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> Any:
        if self.mousePosition:
            delta = event.pos() - self.mousePosition
            self.resizeFunc(delta)

    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> Any:
        self.mousePosition = None

CornerGripはedgeを定義することで定義したedgeに合わせて、対応したリサイズの関数を呼び出すように定義しています。

そして、四隅と四辺のGripを持ったFrameGripsを定義します

class FrameGrips:
    topLeft: QtWidgets.QSizeGrip
    topRight: QtWidgets.QSizeGrip
    bottomRight: QtWidgets.QSizeGrip
    buttomLeft: QtWidgets.QSizeGrip
    left: CornerGrip
    top: CornerGrip
    right: CornerGrip
    bottom: CornerGrip
    parent: QtWidgets.QWidget
    __qss: str = "background-color: transparent;"

    def __init__(self, parent: QtWidgets.QWidget = None) -> None:
        self.parent = parent
        self.topLeft = QtWidgets.QSizeGrip(parent)
        self.topRight = QtWidgets.QSizeGrip(parent)
        self.bottomRight = QtWidgets.QSizeGrip(parent)
        self.buttomLeft = QtWidgets.QSizeGrip(parent)
        self.left = CornerGrip(parent, edge=QtCore.Qt.LeftEdge)
        self.top = CornerGrip(parent, edge=QtCore.Qt.TopEdge)
        self.right = CornerGrip(parent, edge=QtCore.Qt.RightEdge)
        self.bottom = CornerGrip(parent, edge=QtCore.Qt.BottomEdge)

        self.topLeft.setStyleSheet(self.__qss)
        self.topRight.setStyleSheet(self.__qss)
        self.bottomRight.setStyleSheet(self.__qss)
        self.buttomLeft.setStyleSheet(self.__qss)
        self.left.setStyleSheet(self.__qss)
        self.top.setStyleSheet(self.__qss)
        self.right.setStyleSheet(self.__qss)
        self.bottom.setStyleSheet(self.__qss)

それぞれのウィジェットは透明になるように定義しておきました。
そして、FramelessMainWindow内にFrameGripsを呼び出し、リサイズできるように定義します。


class FramelessMainWindow(QtWidgets.QMainWindow):
    pos: QtCore.QPoint
    radius: int = 15
    backgroundColorCode: str = "#333333"
    backgroundColor = QtGui.QColor(backgroundColorCode)

    gradient: QtGui.QLinearGradient = QtGui.QLinearGradient()
    gradient.setColorAt(0.0, "#54354e")
    gradient.setColorAt(1.0, "#6a86c7")
    isDrag: bool = False
    isPress: bool = False
    cornerLength = 10
    frameGrips: FrameGrips

    def __init__(self, parent: QtWidgets.QWidget = None) -> None:
        super().__init__(parent)
        self.setAutoFillBackground(True)
        self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
        self.installEventFilter(self)
        self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
        self.frameGrips = FrameGrips(self)
        self.setContentsMargins(*[self.gripSize] * 4)

    @property
    def gripSize(self):
        return self.cornerLength

    def setGripSize(self, size):
        if size == self.cornerLength:
            return
        self.cornerLength = max(2, size)
        self.setContentsMargins(*[self.gripSize] * 4)
        self.updateGrips()

    def updateGrips(self) -> None:
        outRect = self.rect()
        # an "inner" rect used for reference to set the geometries of size grips
        inRect = outRect.adjusted(
            self.gripSize, self.gripSize, -self.gripSize, -self.gripSize
        )
        # top left
        self.frameGrips.topLeft.setGeometry(
            QtCore.QRect(outRect.topLeft(), inRect.topLeft())
        )
        # top right
        self.frameGrips.topRight.setGeometry(
            QtCore.QRect(outRect.topRight(), inRect.topRight()).normalized()
        )
        # bottom right
        self.frameGrips.bottomRight.setGeometry(
            QtCore.QRect(inRect.bottomRight(), outRect.bottomRight())
        )
        # bottom left
        self.frameGrips.buttomLeft.setGeometry(
            QtCore.QRect(outRect.bottomLeft(), inRect.bottomLeft()).normalized()
        )

        # left edge
        self.frameGrips.left.setGeometry(
            0, inRect.top(), self.gripSize, inRect.height()
        )
        # top edge
        self.frameGrips.top.setGeometry(inRect.left(), 0, inRect.width(), self.gripSize)
        # right edge
        self.frameGrips.right.setGeometry(
            inRect.left() + inRect.width(), inRect.top(), self.gripSize, inRect.height()
        )
        # bottom edge
        self.frameGrips.bottom.setGeometry(
            self.gripSize, inRect.top() + inRect.height(), inRect.width(), self.gripSize
        )

    def resizeEvent(self, event):
        QtWidgets.QMainWindow.resizeEvent(self, event)
        self.updateGrips()

    def paintEvent(self, event: QtGui.QMouseEvent) -> Any:
        painter = QtGui.QPainter(self)
        painter.setRenderHint(QtGui.QPainter.Antialiasing)
        path = QtGui.QPainterPath()
        self.gradient.setStart(QtCore.QRectF(self.rect()).topLeft())
        self.gradient.setFinalStop(QtCore.QRectF(self.rect()).topRight())
        brush = QtGui.QBrush(self.gradient)
        painter.setBrush(brush)
        path.addRoundedRect(0, 0, self.width(), self.height(), self.radius, self.radius)
        painter.setClipPath(path)
        painter.fillPath(path, painter.brush())
        return super().paintEvent(event)

    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
        self.isPress = False
        self.pressPos = event.pos()
        super().mouseReleaseEvent(event)

    def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
        self.isPress = True
        self.pressPos = event.pos()

        super().mousePressEvent(event)

    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
        if self.isPress or self.isDrag:
            self.move(
                event.globalX() - self.pressPos.x(), event.globalY() - self.pressPos.y()
            )
        super().mouseMoveEvent(event)

    def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> Any:
        if event.type() == QtCore.QEvent.Type.MouseButtonPress:
            self.isDrag = True
            return super().eventFilter(obj, event)
        if event.type() == QtCore.QEvent.Type.MouseButtonRelease:
            self.isDrag = False
            return super().eventFilter(obj, event)

次回はUIのデザイン部分をしていきます。


イベントをフィルタリングする場合Trueを返し、それ以外の場合はFalseを返さないといけません。その処理を入れ忘れると# RuntimeWarning:が表示されます。
この例ではreturn super().eventFilter(obj, event)スーパークラスを返すようにしています。

【MayaPySide】ちょっとおしゃれなUIメソッド【3日目】の記事でUIの見た目を設定していきます


MayaPython Advent Calendar 2017の8日目の記事はSEN_AさんのMaya Python API 2.0 を使ってCurveからclosestPointを取得するノードを作ろう!です