PyQt5桌面应用系列

定制控件-界面设计

前面定制控件那里,我们主要讲了QPainter这个类,这个类是用来绘制图形的,但是我们在实际的开发中,很多时候需要绘制的不是图形,而是界面。这个时候,我们就需要用到QWidget这个类了。

QWidget是Qt中所有界面控件的基类,它提供了一些基本的功能,比如绘图、事件处理、布局管理等。我们可以通过继承QWidget来实现自己的界面控件。

要实现一个自定义的界面,就需要做下面几件事情:

  1. 继承QWidget类,实现自己的界面控件
  2. 重写paintEvent函数,实现绘图
  3. 重写resizeEvent函数,实现界面大小变化时的处理,或者设置sizePolicy和sizeHint函数,实现界面大小的控制
  4. 重写mousePressEvent、mouseMoveEvent、mouseReleaseEvent函数,实现鼠标事件的处理
  5. 重写keyPressEvent函数,实现键盘事件的处理
  6. 重写timerEvent函数,实现定时器事件的处理
  7. 重写其他事件处理函数,比如focusInEvent、focusOutEvent、enterEvent、leaveEvent等
  8. 重写函数showEvent、hideEvent、closeEvent等
  9. 重写函数setGeometry、setFixedSize、setMinimumSize、setMaximumSize等
  10. 重写函数setStyleSheet、setCursor、setToolTip等
  11. 重写setFocusPolicy、setFocus、clearFocus等

如果只是比较简单的界面,则只需要考虑前面几项。

本文目标

这里我们以一个电池电量显示控件为例,来讲解如何实现一个自定义的界面控件。

功能分析

这个控件,我们希望有两个方面的功能:

  1. 显示一个按百分比描述的量,例如电量,体现一种焦虑感;
  2. 通过鼠标交互来设置一个百分比显示的量,例如设置电量,体现一种控制感。
  3. 能够提供别的控件来设置这个百分比,例如滑块,体现一种便捷感。
  4. 能够构成动画,例如电量充电,体现一种活力感。

大概就如下面的动画所示:

PyQt5桌面应用开发(18):自定义控件界面设计与实现-LMLPHP

功能设计

按照上面的功能分析,需要实现如下几个内容:

  1. 按照区域和当前值(0-100)来绘制方形图案;
  2. 在控件中心显示一个数值;
  3. 提供设置值的接口(pyqtSlot);
  4. 提供值发生改变的信号(pyqtSignal);
  5. 提供设置值的动画效果(pyqtproperty)。

1,2是界面显示的内容,3,4是控件的功能,5是控件的附加功能。后面三项功能的技术,在前面的文章中已经讲过了,这里就不再赘述了。

绘制方形图案

这里我们需要绘制一个方形的图案,这个图案的大小是根据控件的大小来自适应的,而且图案的颜色也是根据当前值来自适应的。这里我们需要用到QPainter的几个函数:

  1. QPainter::fillRect:填充矩形
  2. QPainter::setBrush:设置画刷
  3. QPainter::setPen:设置画笔
  4. QPainter::setFont: 设置字体
  5. QPainter::drawText: 绘制字符串

这里首先,是从QWidget得到一个QPainter对象,然后设置画刷和画笔,最后调用fillRect函数来绘制矩形;然后利用setFont设置字体,通过drawText绘制字符串。

    def paintEvent(self, e):
        painter = QPainter(self)

        brush = QBrush()
        brush.setColor(self._background_color)
        brush.setStyle(Qt.SolidPattern)
        rect = QtCore.QRect(0, 0, painter.device().width(), painter.device().height())
        painter.fillRect(rect, brush)

        # Get current state.
        vmin, vmax = self._minimum, self._maximum
        value = self.value

        # Define our canvas.
        d_height = painter.device().height() - (self._padding * 2)
        d_width = painter.device().width() - (self._padding * 2)

        # Draw the bars.
        step_size = d_height / self.n_steps
        bar_height = step_size * self._bar_solid_percent
        bar_spacer = step_size * (1 - self._bar_solid_percent) / 2

        # Calculate the y-stop position, from the value in range.
        pc = (value - vmin) / (vmax - vmin)
        n_steps_to_draw = int(pc * self.n_steps)

        for n in range(n_steps_to_draw):
            brush.setColor(QtGui.QColor(self.steps[n]))
            rect = QtCore.QRectF(
                self._padding,
                self._padding + d_height - ((1 + n) * step_size) + bar_spacer,
                d_width,
                bar_height
            )
            painter.fillRect(rect, brush)

        # draw text in the midddle of the bar
        painter.setPen(QColor('white'))
        rect = QRectF(
            self._padding,
            self._padding,
            d_width - self._padding,
            d_height - self._padding
        )
        # change font size to 20
        font = painter.font()
        font.setPointSize(18)
        font.setFamily("Arial")
        font.setBold(True)
        painter.setFont(font)
        painter.drawText(rect, Qt.AlignCenter, f"{value}%")
        painter.end()

具体的位置和尺寸计算都比较简单,只需要注意x和y方向坐标轴分别是向右和向下的,而不是向上和向右的。

鼠标交互

鼠标交互主要是实现点击设置相应的值。这里我们需要重写mousePressEvent和mouseMovement函数,来实现鼠标点击事件的处理。值得注意的是后者只有在鼠标按键被按下后才会出发(前提是setMouseTracking(False),当然这是默认值)。

    def mouseMoveEvent(self, e):
        self._calculate_clicked_value(e)

    def mousePressEvent(self, e):
        self._calculate_clicked_value(e)

事件和属性

信号、槽和属性的使用,这里就不再赘述了,可以参考前面的文章。

唯一值得注意的是:信号的触发(emit)最好是只在定义该信号的类中发生,这是Qt5官方文档中的建议。这里也是这样实现的,所有对value的修改都是通过property来实现的,而不是直接修改_value的值。只有一个地方触发了信号,也只有在那一个地方,修改了_value的值。

    # signal and slots framework
    valueChanged = pyqtSignal(int)

    @pyqtSlot(int)
    def setValue(self, value):
        self.value = value

    @pyqtProperty(int)
    def value(self):
        return self._value

    @value.setter
    def value(self, value):
        self._value = value
        self.update()
        self.valueChanged.emit(value)

完整代码

import sys

from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import Qt, pyqtSlot, pyqtProperty, pyqtSignal, QRectF, QSize, QPropertyAnimation
from PyQt5.QtGui import QColor, QBrush, QPainter
from PyQt5.QtWidgets import QWidget, QSizePolicy, QApplication


class PowerBar(QWidget):
    """
    Custom Qt Widget to show a power bar.
    Demonstrating compound and custom-drawn widget.
    """

    def __init__(self, steps=5, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._value = 0
        self._minimum = 0
        self._maximum = 100

        self.setSizePolicy(
            QSizePolicy.MinimumExpanding,
            QSizePolicy.MinimumExpanding
        )

        if isinstance(steps, list):
            # list of colors.
            self.n_steps = len(steps)
            self.steps = steps

        elif isinstance(steps, int):
            # int number of bars, defaults to red.
            self.n_steps = steps
            self.steps = ['red'] * steps

        else:
            raise TypeError('steps must be a list or int')

        self._bar_solid_percent = 0.8
        self._background_color = QColor('black')
        self._padding = 4.0  # n-pixel gap around edge.

    def paintEvent(self, e):
        painter = QPainter(self)

        brush = QBrush()
        brush.setColor(self._background_color)
        brush.setStyle(Qt.SolidPattern)
        rect = QtCore.QRect(0, 0, painter.device().width(), painter.device().height())
        painter.fillRect(rect, brush)

        # Get current state.
        vmin, vmax = self._minimum, self._maximum
        value = self.value

        # Define our canvas.
        d_height = painter.device().height() - (self._padding * 2)
        d_width = painter.device().width() - (self._padding * 2)

        # Draw the bars.
        step_size = d_height / self.n_steps
        bar_height = step_size * self._bar_solid_percent
        bar_spacer = step_size * (1 - self._bar_solid_percent) / 2

        # Calculate the y-stop position, from the value in range.
        pc = (value - vmin) / (vmax - vmin)
        n_steps_to_draw = int(pc * self.n_steps)

        for n in range(n_steps_to_draw):
            brush.setColor(QtGui.QColor(self.steps[n]))
            rect = QtCore.QRectF(
                self._padding,
                self._padding + d_height - ((1 + n) * step_size) + bar_spacer,
                d_width,
                bar_height
            )
            painter.fillRect(rect, brush)

        # draw text in the midddle of the bar
        painter.setPen(QColor('white'))
        rect = QRectF(
            self._padding,
            self._padding,
            d_width - self._padding,
            d_height - self._padding
        )
        # change font size to 20
        font = painter.font()
        font.setPointSize(18)
        font.setFamily("Arial")
        font.setBold(True)
        painter.setFont(font)
        painter.drawText(rect, Qt.AlignCenter, f"{value}%")
        painter.end()

    def sizeHint(self):
        return QSize(30, 120)

    def _trigger_refresh(self):
        self.update()

    def _calculate_clicked_value(self, e):
        min_val, max_val = self._minimum, self._maximum
        d_height = self.size().height() + (self._padding * 2)
        step_size = d_height / self.n_steps
        click_y = e.y() - self._padding - step_size / 2

        pc = (d_height - click_y) / d_height
        value = min_val + pc * (max_val - min_val)
        if value > self._maximum:
            value = self._maximum
        if value < self._minimum:
            value = self._minimum
        self.value = int(value)

    def mouseMoveEvent(self, e):
        self._calculate_clicked_value(e)

    def mousePressEvent(self, e):
        self._calculate_clicked_value(e)

    def setColor(self, color):
        self.steps = [color] * self.n_steps
        self.update()

    def setColors(self, colors):
        self.n_steps = len(colors)
        self.steps = colors
        self.update()

    def setBarPadding(self, i):
        self._padding = int(i)
        self.update()

    def setBarSolidPercent(self, f):
        self._bar_solid_percent = float(f)
        self.update()

    def setBackgroundColor(self, color):
        self._background_color = QColor(color)
        self.update()

    # signal and slots framework
    valueChanged = pyqtSignal(int)

    @pyqtSlot(int)
    def setValue(self, value):
        self.value = value

    @pyqtProperty(int)
    def value(self):
        return self._value

    @value.setter
    def value(self, value):
        self._value = value
        self.update()
        self.valueChanged.emit(value)


if __name__ == '__main__':
    app = QApplication([])
    volume = PowerBar([QColor(255, 255 - i, 0) for i in range(0, 255, 15)])

    # set volume window without minimize and maximize button
    volume.setWindowFlags(Qt.WindowCloseButtonHint)

    volume.resize(100, 500)
    anim = QPropertyAnimation(volume, b"value")
    anim.setDuration(5000)
    anim.setStartValue(0)
    anim.setKeyValueAt(0.8, 100)
    anim.setEndValue(0)

    volume.show()

    anim.finished.connect(lambda: volume.valueChanged.connect(lambda: volume.setWindowTitle(f"{volume.value}%")))

    anim.start()

    sys.exit(app.exec_())

在程序的主函数里面,展示了一个属性动画。并在属性动画完成之后,把值改变的信号连接到了窗口标题的改变上面。这样就可以实时显示变化(其实跟控件中间的字符串一样)。

最终实现的PowerBar,可以用来显示音量,电量等等,其构造函数可以输入一个颜色列表,也可以输入一个整数,表示颜色的数量。如果输入的是一个整数,那么默认的颜色是红色。

总结

  1. 本文介绍了重载paintEvent方法,绘制自定义控件的方法。
  2. 本文再次复习了QPropertyAnimation属性动画的实现方法。
  3. 自定义控件中需要注意的是,信号与槽函数是比较重要的外部接口。
05-25 11:37