PyQt5 讓 GUI 動起來為元件綁定 Event 觸發 Function

前一章計算機介面為例,本篇將加入簡單的事件綁定,以實作計算機的基本功能。

產生 UI 的 SetupUi 函式中會把UI元件一一設定成 Attribute 給自己也就是 self 這個變數,因此接下來我們就可以對這些元件進行事件的綁定。

事件綁定(Event Binding)

將 Function Pointer 綁在某一元件的 Signal 上,需要用 connect。以下示範幾種寫法綁定兩種事件:

  1. 按鈕(數字鍵 b0, b1, …)被點擊 clicked 時將數字插入方程式文字列 lineEquation
  2. 方程式文字列 lineEquation 發生改變時 print 出當下的內容

方法一

最簡單的方法,就是一對一綁定。

def __init__(self):
    # ...
    self.b0.clicked.connect(self.addNumber0)
    self.lineEquation.textChanged.connect(self.equationChanged)

def addNumber0(self):
    self.lEquation.insert('0')

def equationChanged(self, text):
    print('Eq=', text)

方法二

如果想要寫通用一點的 Function,可以使用 lambda 帶入其他參數。

def __init__(self):
    # ...
    self.b0.clicked.connect(lambda: self.addNumber(0))
    self.lineEquation.textChanged.connect(lambda t: self.textChanged(t, 'Eq'))

def addNumber(self, number):
    self.lEquation.insert(str(number))

def textChanged(self, text, src):
    print(src, '=', text)

方法 N

結合方法二,但使用 dict 結構 mapping 相對應的值,另外也可以擅用 Python 內建的 getattr 取得 Class 內的 Attribute,綁定寫法完全是自由發揮~

def __init__(self):
    # ...
    btnName2Number = {
        'b0': 0,
        'b1': 1,
        # ...
    }
    for btn, num in btnName2Number.items():
        getattr(self, btn).clicked.connect(lambda: self.addNumber(num))
    self.lineEquation.textChanged.connect(lambda t: self.textChanged(t, 'Eq'))

def addNumber(self, number):
    self.lEquation.insert(str(number))

def textChanged(self, text, src):
    print(src, '=', text)
善用關鍵字「pyqt Qxxx」即可在官方文件中找到各元件可用的Signal,其中的Qxxx為元件的Class類別(可以在物件屬性頁中找到)。

自定義事件(Custom Event)

除了內建的Event之外,也可以客製化自己喜歡的事件。例如以下範例:

  1. [Line#2] 首先自訂一個名為 numberAddedEvent,並註冊該 Event 觸發時會帶的參數類型
  2. [Line#9] 自訂事件設定於 addNumber 完成時,將藉由 emit 函式來觸發
  3. [Line#5] 透過 connect 將事件綁定到 onNumberAdded 函式上然後顯示出來
class Main(QMainWindow, ui.Ui_MainWindow):
    numberAdded = pyqtSignal(int)
    def __init__(self):
        # ...
        self.numberAdded.connect(self.onNumberAdded)

    def addNumber(self, number):
        self.lEquation.insert(str(number))
        self.numberAdded.emit(number)

    def onNumberAdded(self, number):
        print('Insert', number)

不過還是建議大家優先熟悉內建的各種 Signal 啦~不然一旦掉進 Event 陷阱程式就會 Crash 惹。

事件處理(Event Handle)

事件一旦被觸發就會跳到綁定的函示執行,因此發生問題時常常會從莫名其妙的地方 Crash 掉,而這經常都是因為我們在無意之間觸發到了不預期的事件,因此使用上必須格外小心。

觸發時機

有一種陷阱就是觸發時機,例如我們想要在方程式文字框中一邊鍵入數值一邊計算總值,可能會這樣寫:

class Main(QMainWindow, ui.Ui_MainWindow):
    def __init__(self):
        # ...
        self.lineEquation.textChanged.connect(self.equationChanged)

    def equationChanged(self, text):
        print('Eq=', text)
        value = eval(text)
        self.lineTotal.setText(str(value))

接著會發現按「1 + 1」按一按就 Crash 了~因為 textChanged 這個事件會在編輯的過程中不斷被觸發,此時較好的做法是綁定 editingFinished,將只在完成編輯 blur 掉之後觸發。至於什麼事件適合怎麼使用,也只能 Try and Error XD

循環觸發(Event Loop)

另外有一種情況會發生循環被觸發的事件遞迴。例如想要在算完值之後將計算式清為 0,假設這樣寫:

class Main(QMainWindow, ui.Ui_MainWindow):
    def __init__(self):
        # ...
        self.lineEquation.textChanged.connect(self.equationChanged)
        self.lineTotal.textChanged.connect(self.totalChanged)

    def equationChanged(self, text):
        print('Eq=', text)
        value = eval(text)
        self.lineTotal.setText(str(value))

    def totalChanged(self, total):
        self.lineEquation.setText('0')

這時試著輸入數字你會發現數字根本輸入不進去,這是因為 setText 這個 method 也會同時觸發 textChanged 的事件,最後導致數值永遠為 0,會收斂的循環倒還好,有時一不小心就會踩到 Python 遞迴的次數上限而導致程式死當。

如果想要暴力解決循環觸發的問題,可以暫時把事件屏蔽起來以避開類似的問題:

self.lineEquation.blockSignals(False)
# ...
self.lineEquation.blockSignals(False)

不過此時你會發現,在計算總值寫入 lineTotal 之後就無法同時將計算時清為 0。這邊的例子也許有點爛,不過實際上很容易發生這種循環觸發的問題,此時就要回頭想想 GUI 的設計邏輯是否有問題,進一步思考能夠達成同樣目標的其他方法。

總結

雖然事件的處理上會有許多眉眉角角需要留意,但在 GUI 的實作中,Event Binding 還是不可或缺的好幫手。

最後在各函式內寫上適合的邏輯條件,就可以完成簡單的計算機功能囉~完成範例: