wxpython - 布局和事件
這章主要記錄布局器Sizer以及事件的用法。
// 目前還需要記錄的:Sizer的Add方法加空白,Sizer的Layout,Sizer的Remove如何有效
■ 布局
之前介紹的所有組件,如果不把它們的pos寫死的話,頁面上它們會互相重疊,導致沒法看。而Sizer就是一個很好的優化布局的工具,通過此可以靈活地管理組件之間的相對位置。
Sizer大概的可以被分成GridSizer(網格布局)和BoxSizer(線性布局).Sizer的用法概括起來就是創建Sizer之后將其關聯到一個對象(通常是Panel)。然后用諸如Add,Insert等方法為Sizer添加組件。調用Fit等方法可以讓Sizer自動計算子部件尺寸的大小。去除一個部件和Sizer的關聯可以用Sizer.Detach方法。//這個存疑,嘗試了各種各樣的方法,包括了窗口Refresh等等,效果都不好
● GridSizer 網格布局
構造方法中有四個參數決定了這個布局地大致樣式,分別是rows,cols,vgap,hgap分別表示網格行數,列數,兩相鄰列間相隔像素數,兩相鄰行間相隔像素數。
在用Add方法添加組件的時候默認是先填滿一行再開始填下一行。簡單GridSizer嚴格遵守網格布局的含義,當窗口大小發生變化時,其網格的大小也會相應變化,導致加在網格中的組件大小或間距也會發生相應的變化。GridSizer更加適合表格作業が,其改進版的FlexGridSizer可以更加靈活的設置大小和縮放的關系。
網格布局舉例;
testSizer = GridSizer(rows=2,cols=2) totalPanel = Panel(self,-1) text1 = StaticText(totalPanel,-1,"Text1:") btn1 = Button(totalPanel,-1,"Button1") text2 = StaticText(totalPanel,-1,"Text2:") btn2 = Button(totalPanel,-1,"Button") testSizer.Add(text1,flag=ALIGN_CENTER) testSizer.Add(btn1,flag=ALIGN_CENTER) testSizer.Add(text2,flag=ALIGN_CENTER) testSizer.Add(btn2,flag=EXPAND) totalPanel.SetSizer(testSizer)
界面效果:
● FlexGridSizer
其構造方法和GridSizer是類似的。但它和GridSizer的區別在於,在默認情況下FGS的網格不會隨着窗口的大小變化而變化。而所謂不默認的情況,就是用Sizer對象調用AddGrowableRow(x,m)/AddGrowableCol(y,n)。這兩個方法的意思是使得某一行或某一列的網格會隨着窗口大小而變化。比如AddGrowableRow(1)是說讓第二行會隨着窗口縱向長度變化而變化,可以添加第二個參數來決定這行變化的權重。比如AddGrowableRow(0,1);AddGrowableRow(1,2)的意思就是把第一行和第二行都設置為可縱向延伸,且窗口延伸一個單位,第一行延伸三分之一while第二行延伸三分之二個單位。同理AddGrowableCol就是控制列在橫向方向的延伸和權重的。(權重這個概念在后面的BoxSizer中還會再提到)
注意:這里所說的可延伸都是只網格的可延伸,而具體到這個網格里面的組件可不可延伸擴展,,就要看Sizer在Add時的flag了。比如說flag設置為ALIGN_CENTER的話,網格延伸了但是組件始終保持在網格的正中間。EXPAND的話組件始終填滿整個網格等等。
反過來說,在Sizer.Add組件時如果設置了flag=EXPAND但是這個組件所在的行列都沒有被設置成Growable的話就是白瞎,窗口變動無法引起網格變動自然也無法引起組件的變動了。
● GridBagSizer
GS和FGS在添加時都是默認先填滿第一行,填滿第一行之后再轉到第二行。而GBS可以指定在哪行哪列的網格添加組件。
GBS的構造方法沒有rows和cols參數,最終Sizer會以幾行幾列的方式呈現取決於添加的靠近右下角的那個組件有多右下角= =
GBS在Add的時候必須指出兩個參數,pos=(x,y)以及size=(m,n)意思是組件填充在第x+1行和第y+1列的網格以及該組件將占據m行n列的位置(有點像xlrd,xlwt里面的合並單元格屬性)
● BoxSizer
BS是一個水平行or垂直列,具體是哪個通過構造方法的參數是wx.HORIZONTAL還是wx.VERTICAL來決定。對於HORIZONTAL,其可延伸方向就是縱向,對於VERTICAL自然就是橫向了。需要注意的是對於一種BS,只能在一個方向延伸。在某個BS中Add一個ALIGN_CENTER的組件でも,對於非延伸方向上的拖動窗口,組件不會有反應。
BS的Add方法中有proportion參數。proportion參數類似於上面提到的AddGrowableRow的第二個參數類似,就是表明了窗口在可延伸方向上占的延伸權重。通常可以把組件添加到幾個HORIZONTAL的sizer里,再把這些sizer作為組件添加到一個VERTICAL的sizer里面去,形成一個看起來比較舒服的頁面。注意,如果需要有窗口延伸時組件也延伸的效果的話就需要讓那幾個HORIZONTAL的sizer也要有flag=EXPAND,因為sizer和panel一樣也是有大小的,如果不設置EXPAND,這些sizer的大小也是不會變的。
關於BS的Add,還有一點常用的,就是flag=LEFT|RIGHT|TOP|BOTTOM|ALL,border=xx 通過flag=選項中的一個或幾個,並且配合border的數值,調整被Add的元素始終保持指定的幾條邊和周圍的相距border數值個像素的位置。因為是常用的就不啰嗦了。
● 其他
Sizer有個Fit(window)的方法,調用之后可以讓窗口根據sizer調整窗口大小,使窗口剛好能夠放下sizer中的所有組件
■ 事件
人和組件元素的一些互動會觸發事件,事件在wx中以以下方式表示:
wx.EVT_BUTTON 按下按鈕的事件
常用的事件還有:
EVT_SIZE 由於用戶干預或由程序實現,當一個窗口大小發生改變時發送給窗口。
EVT_MOVE 由於用戶干預或由程序實現,當一個窗口被移動時發送給窗口。
EVT_CLOSE 當一個框架被要求關閉時發送給框架。除非關閉是強制性的,否則可以調用event.Veto(true)來取消關閉。
EVT_PAINT 無論何時當窗口的一部分需要重繪時發送給窗口。
EVT_CHAR 當窗口擁有輸入焦點時,每產生非修改性(Shift鍵等等)按鍵時發送。
EVT_IDLE 這個事件會當系統沒有處理其它事件時定期的發送。
EVT_LEFT_DOWN 鼠標左鍵按下。
EVT_LEFT_UP 鼠標左鍵抬起。
EVT_LEFT_DCLICK 鼠標左鍵雙擊。
EVT_MOTION 鼠標在移動。
EVT_SCROLL 滾動條被操作。這個事件其實是一組事件的集合,如果需要可以被單獨捕捉。
EVT_MENU 菜單被選中。
此外還可以自定義事件。可以參考http://blog.csdn.net/tingsking18/article/details/4033639
● Bind方法
僅僅有事件還是不夠的,必須要把引起事件的組件和事件處理結合起來,這里用到的就是Bind函數
Bind隸屬於wx.EvtHandler,是所有可顯示對象(包括Frame在內的絕大多數組件)的父類,所以 這些組件都可以調用Bind。允許一個組件Bind多個事件,此時事件的處理函數中可以寫event.Skip(True)來標識某個事件處理函數對改事件不做處理而直接跳過。
Bind的定義是Bind(綁定事件類型,事件處理器,事件源對象),但是通常可以通過某個組件來調用Bind比如btn.Bind(綁定事件類型,事件處理器)就好了。事件類型就是上面那一大票,而事件處理器傳遞的是個函數對象。這個函數對象要求有且只有一個沒有默認值的參數,event。這樣的話就引起了一個問題,當我想要傳遞一些額外的參數給事件處理函數時該怎么辦?比如:
#這樣子是運行正常的 btn.Bind(EVT_BUTTON,test) def test(event): print "Hello" #但這樣會報參數個數不對的錯 btn.Bind(EVT_BUTON,test) def test(event,name): print "Hello,%s"%(name) #而在Bind的時候又不能直接給test加參數。。
這種情況其實可以歸類為一個更普遍一點的問題,就是如何為一個(在定義時沒有給出參數默認值的)函數對象參數默認值?
解決方法:用lambda:
btn.Bind(EVT_BUTTON,lambda evt,name="Frank":test(evt,name)) def test(event,name): print "Hello,%s"%(name)
通過lambda為函數本身再做一層包裝而返回一個包裝后的函數對象(感覺有點像裝飾器的意思)
● 事件處理函數的event參數
如上所說,每個被Bind到組件上的方法必須要有event這個參數,這個event參數抽象的就是事件本身。可以調用一些這樣的方法來獲取更多關於事件的信息:
event.GetEventObject() 獲取引起事件的那個組件的對象
■ 自定義事件 & 多線程支持
在wx中有這樣一種比較難過的場景,就是用戶觸發了一定的事件之后,后台函數要處理很久這個事件,在其處理完成之前用戶看到的主窗口界面是被阻塞,卡着不動的(甚至會出現未響應的提示)。一般而言用戶是無法忍受這樣的等待的。
自然我們就想到是不是可以當用戶觸發了事件之后,開啟一個后台函數的子線程,讓它去處理然后保持主界面沒有什么變化,在過一段時間后,后台處理完了再發送消息會主界面告訴用戶處理已經完成。但是這樣也有一個障礙,就是如何實現主線程和子線程之間的通訊呢?因為在后台處理完成的時候還要發回信息讓主線程感知到。
一個簡單的解決方法就是結合自定義事件和多線程。下面是一個我寫的實例,它是一個通過ssh連接某台機器並驗證賬號密碼的界面。在用戶點擊了連接后,因為ssh連接需要一定時間,所以用戶要等很久,於是整合進了多線程。把它分成幾部分來看:
第一部分(自定義事件相關)
import sys, paramiko, ConfigParser, os from MyExceptions import * from MainFrame import MainFrame
# 上面這些都是輔助的模塊,不是這篇的重點可以忽略 # from wx import * from threading import Thread LOGIN_EVENT_ID = NewId() # 所有事件都有一個固有的ID,自定義事件也不例外。這句為我自定義的事件分配一個ID(wx內部用的一個ID)。 class LoginEvent(PyEvent): """自定義一個事件類,用PyEvent做基類,在其構造方法中通過SetEventType來為這類事件分配一個ID。 message參數是為了每次構建事件時都可以通過這個message指出這個事件的一些信息,方便事件處理函數來做出相應的操作 """ def __init__(self, message): PyEvent.__init__(self) self.SetEventType(LOGIN_EVENT_ID) self.data = message
第二部分(自定義線程)
class LoginThread(Thread): """自定義一個繼承自Thread類的類,構造方法中一般有個wxObject用來指代一個組件對象(大多數情況是Frame本身) 重載run方法,做具體的操作,在操作中適當的地方用wx.PostEvent方法手動引發一個事件,事件源是wxObject,事件是前面自定義事件類的一個實例 """ def __init__(self, server, user, password, wxObject): Thread.__init__(self) self.server = server self.user = user self.password = password self.wxObject = wxObject def run(self): try: ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh.connect(self.server, 22, self.user, self.password, timeout=15) PostEvent(self.wxObject, LoginEvent("success")) except TimeOutException: PostEvent(self.wxObject, LoginEvent(u"連接超時")) except AuthenticationException: PostEvent(self.wxObject, LoginEvent(u"密碼驗證失敗")) except SocketError: PostEvent(self.wxObject, LoginEvent(u"Socket錯誤導致連接失敗")) except Exception, e: PostEvent(self.wxObject, LoginEvent(str(e)))
這個自定義線程類通過重載run方法來寫明線程的具體操作(詳情見threading和Queue的那一篇),構造方法中出現了一個wxObject供PostEvent這個方法用。這個方法是個比較有意思的,它的作用就是手動觸發事件。比如在這里,ssh連接失敗或成功都會觸發事件,由於自定義事件LoginEvent中有data這個屬性,所以還可以對不同的登錄失敗原因給出說明。這里要注意,那就是登錄成功也一定要觸發事件,否則就沒辦法通過事件這個渠道來實現線程間通信了。
第三部分(界面的構造):
class MyFrame(Frame): def __init__(self): #詳細的構造方法略去# self.btn.Bind(EVT_BUTTON,self._login) #點擊按鈕確定,開始登錄操作 self.Connect(-1,-1,LOGIN_EVENT_ID,self._loginHandler) # Connect方法和xxx.Bind很像,就是把一個對象和某種事件聯系起來,並給予一個處理事件的函數, # 和Bind不同的是Connect方法用的是EVENT的ID來綁定的。EVENT的ID可以通過EVT_BUTTON.typeID來查看,別的wx自帶的event,在獲取到其ID之后也可以轉化成Connect方法來做 # 這個self應該要和后文中自定義線程類初始化時傳進去的wxObject參數一致(保證出事件的組件和監聽的組件是一樣的 def _login(self,event): #略過了一些凍結界面的操作,比如self.btn.Disable()等。這樣的話按下登錄之后可以保證用戶不會在有線程在登錄中的時候再按登錄出現混亂 #還有做一些輸入值的檢查,獲取輸入值等等 t = LoginThread(server,user,password,self)
t.setDaemon(True) #設置t為守護進程的原因是如果用戶按下確定開始登錄,然后又想按取消的話,主線程可以在守護線程沒完成前就退出。否則按了取消還是要等登錄完成才有反應
t.start() # 開啟一個線程,當線程還在運行且沒有觸發你的自定義事件的這段時間里,主框架不會再被阻塞了。 # 且由於之前調用了Disable,主框架內部的組件也是不可互動的。當線程觸發一個事件(在這個例子里就是登錄出錯或者成功了)那么就調用之前在框架的構造方法里提到的綁定自定義事件的處理函數 # 然后在_loginHandler里面對事件的data屬性做個判斷,如果是成功的data那么就做成功之后該做的操作,如果是失敗了就做失敗之后的操作。 def _loginHandler(self,event): if event.data != "success": #之前自定義事件類中的輔助信息參數data的價值在這里體現出來了。可以對觸發的事件做判斷,根據判斷結果確定要做的是失敗處理還是成功處理 MessageBox(event.data,u"登錄錯誤") self.okButton.SetLabel(u"確定") self.okButton.Enable() for item in [self.serverInput, self.userInput, self.passInput]: item.SetEditable(True) #失敗的情況就把之前所有凍結的界面組件解凍,因為要期待用戶再輸入 else: server = self.serverInput.GetValue() user = self.userInput.GetValue() password = self.passInput.GetValue() self.Close() MainFrame(server, user, password).Show() #登錄成功的話就關閉這個窗口然后Show出新的下一個窗口就行了
這部分中需要小注意的一點是,原先不用多線程可能在開始登錄操作的語句后面會接上比如提示登錄成功等操作,但是用了多線程之后,線程開啟的start方法后面最好不要跟任何語句了。因為線程嘛,一旦開啟之后主線程也馬上開始往下走,會導致那個登錄成功馬上跳出來。理想的解決方法是把這種提示語句的條件包裝進線程的事件觸發里,然后在主框架的事件處理方法那里對相應的事件處理。這樣就可以實現所謂“線程結束后主框架的操作”了。
這樣就可以做到在登錄過程中,主界面不會處於未響應狀態,但是后台仍然正常在登錄了:(圖里IP不小心刪掉了一個數字,不要在意。。)
■ 在整個wx中加入事件之后可以看到,組件之間的關系復雜起來了。建議的程序構造方式是這樣子的:
每一個窗口都是一個類,不同窗口用不同類來表示
窗口中的組件寫在類的構造方法里,並且把需要進行取值等和其他方法互動操作的組件要設置成“self.組件”。與組件綁定的事件處理方法寫成“self.方法”的類方法。需要子窗口時把子窗口的構造方法中加個parent參數,然后在構造子窗口時把父窗口傳遞進去即可。