iOS的事件有好幾種:Touch Events(觸摸事件)、Motion Events(運動事件,比如重力感應和搖一搖等)、Remote Events(遠程事件,比如用耳機上得按鍵來控制手機),其中最常用的應該就是Touch Events了,基本存在于每個app的每個地方,今天我們主要就講講它,至于其他兩個事件有興趣的可以自行查閱資料。
在網頁上當我們講到事件,我們會講到事件響應鏈,我們會講到事件的響應者和事件的傳遞方式(冒泡),那么在app上,其實也離不開這幾個問題,今天我們也重這幾個方面來介紹iOS的事件機制:?
1、響應鏈是什么時候怎樣構建的??
2、事件第一個響應者是怎么確定的?
?3、事件第一個響應者確定后,系統是怎樣傳遞事件的?
##響應鏈的構建
無論是哪種事件,其傳遞和響應都與響應鏈息息相關,那么響應鏈到底是一個什么樣的東西呢? 在UIKit中有一個類:UIResponder,我們可以看看頭文件的幾個屬性和方法:
UIResponder是所有可以響應事件的類的基類(從名字應該就可以看出來了),其中包括最常見的UIView和UIViewController甚至是UIApplication,所以我們的UIView和UIViewController都是作為響應事件的載體。
那么響應鏈跟這個UIResponder有什么關系呢?事實事件響應鏈的形成和事件的響應和傳遞,UIResponder都幫我們做了很多事。我們的app中,所有的視圖都是按照一定的結構組織起來的,即樹狀層次結構,每個view都有自己的superView,包括controller的topmost view(controller的self.view)。當一個view被add到superView上的時候,他的nextResponder屬性就會被指向它的superView,當controller被初始化的時候,self.view(topmost view)的nextResponder會被指向所在的controller,而controller的nextResponder會被指向self.view的superView,這樣,整個app就通過nextResponder串成了一條鏈,也就是我們所說的響應鏈。所以響應鏈就是一條虛擬的鏈,并沒有一個對象來專門存儲這樣的一條鏈,而是通過UIResponder的屬性串連起來的。如下圖:
##Hit-Testing View
文章開頭說到有iOS三種event類型,事件傳遞中UIWindow會根據不同的event,用不同的方式尋找initial object,initial object決定于當前的事件類型。比如Touch Event,UIWindow會首先試著把事件傳遞給事件發生的那個view,就是下文要說的hit-testview。對于Motion和Remote Event,UIWindow會把例如震動或者遠程控制的事件傳遞給當前的firstResponder。下面主要講Touch Event的hit-testview。
有了事件響應鏈,接下來的事情就是尋找響應事件的具體響應者了,我們稱著為:Hit-Testing View,尋找這個View的過程我們稱著為Hit-Test。
那么什么是Hit-Test呢,我們可以把它理解為一個探測器,通過這個探測器我們可以找到并判斷手指是否點擊在某個視圖上面,換句話說就是通過Hit-Test可以找到手指點擊到的處于屏幕最前面的那個UIView。
在解釋Hit-Test是怎么工作之前,先來看看它是什么時候被調用的。前面說Hit-Test是一個探測器,那么在代碼里面其實就是一個函數,UIView有如下兩個方法:
每當手指接觸屏幕,UIApplication接收到手指的事件之后,就會去調用UIWindow的hitTest:withEvent:,看看當前點擊的點是不是在window內,如果是則繼續依次調用subView的hitTest:withEvent:方法,直到找到最后需要的view。調用結束并且hit-test view確定之后,這個view和view上面依附的手勢,都會和一個UITouch的對象關聯起來,這個UITouch會作為事件傳遞的參數之一,我們可以看到UITouch頭文件里面有一個view和gestureRecognizers的屬性,就是hitTest view和它的手勢。
現在知道Hit-Test是什么時候調用了,那么接下來看看它是怎么工作的。Hit-Test是采用遞歸的方法從view層級的根節點開始遍歷,看看下面這張圖:
UIWindow有一個MianVIew,MainView里面有三個subView:view A、view B、view C,他們各自有兩個subView,他們層級關系是:view A在最下面,view B中間,view C最上(也就是addSubview的順序,越晚add進去越在上面),其中view A和view B有一部分重疊。如果手指在view B.1和view A.2重疊的上面點擊,按照上面說的遞歸方式,順序如下圖所示:
遞歸是向界面的根節點UIWindow發送hitTest:withEvent:消息開始的,從這個消息返回的是一個UIView,也就是手指當前位置最前面的那個 hittest view。 當向UIWindow發送hitTest:withEvent:消息時,hitTest:withEvent:里面所做的事,就是判斷當前的點擊位置是否在window里面,如果在則遍歷window的subview然后依次對subview發送hitTest:withEvent:消息(注意這里給subview發送消息是根據當前subview的index順序,index越大就越先被訪問)。如果當前的point沒有在view上面,那么這個view的subview也就不會被遍歷了。當事件遍歷到了view B.1,發現point在view B.1里面,并且view B.1沒有subview,那么他就是我們要找的hittest view了,找到之后就會一路返回直到根節點,而view B之后的view A也不會被遍歷了。
一圖勝千言:
注意hitTest里面是有判斷當前的view是否支持點擊事件,比如userInteractionEnabled、hidden、alpha等屬性,都會影響一個view是否可以相應事件,如果不響應則直接返回nil。 我們留意到還有一個pointInside:withEvent:方法,這個方法跟hittest:withEvent:一樣都是UIView的一個方法,通過他開判斷point是否在view的frame范圍內。如果這些條件都滿足了,那么遍歷就可以繼續往下走了,代碼表現大概如下:
###Hit-Test的應用
####一、擴大view的點擊區域 一個按鈕尺寸是10pt*10pt,如果要擴大按鈕的點擊區域(按鈕四周之外的10pt也可以響應按鈕的事件),可以怎么做呢?或許重寫hittest:withEvent:是個好辦法,hitest就是返回可以響應事件的view,如果我們在button的子類里面重寫它,在方法里面判斷如果point在button的frame之外的10pt內,就返回button自己。
####二、將事件傳遞給兄弟view 如上面第一個圖,如果需要是需要view A響應事件而不是B(即使點在重疊的部分),什么都不做的話,當點擊在重疊的時候,A是不能響應事件的,除非B的userInteractionEnabled為NO并且者B沒有任何事件的響應函數。這個時候通過重寫B的hittest可以解決這個問題,在B的hittest里面直接返回nil就行了。
####三、將事件傳遞給subview 如下圖,藍色的scrollView設置pagingEnabled使得image停止滾動后都會固定在居中的位置,如果在scrollView的左邊或者右邊活動,發現scrollView是無法滾動的,原因就是hittest里面沒有滿足pointInSide這個條件,scrollView的bound只有藍色的區域。這個時候重寫UIView的hittest:withEvent:,然后返回scrollView即可解決問題。
##事件的傳遞
有了響應鏈,并且找到了第一個響應事件的對象,接下來就是把事件發送個這個響應者了。 UIApplication中有個sendEvent:的方法,在UIWindow中同樣也可以發現一個同樣的方法。UIApplication是通過這個方法把事件發送給UIWindow,然后UIWindow通過同樣的接口,把事件發送給hit-testview。這個我們可以從Time Profiler里面得到證實:
當我點擊了WRBuyBookButton之后,UIWindow會通過一個私有方法,在里面會去調用按鈕的touchesBegan和touchesEnded方法,touchesBegan里面有設置按鈕的高亮等之類的動作,這樣就實現了事件的傳遞。而事件的響應,也就是按鈕上綁定的action,是在touchEnded里面通過調用UIApplication的sendAction:to:from:forEvent:方法來實現的,至于這個方法里面是怎么去響應action,就只能猜測了(可能是通過oc底層消息機制的相關接口 objc_msgSend 來發送消息實現的,可以參考message.h文件)。如果第一響應者沒有響應這個事件,那么就會根據響應鏈,把事件冒泡傳遞給nextResponder來響應。
注意這里是怎么把事件傳遞給nextResponder的呢?拿touch事件來說,UIResponder里面touch四個階段的方法里面,實際上是什么事都沒有做的,UIView繼承了它進行重寫,重寫的內容也是沒有什么東西,就是把事件傳遞給nextResponder,比如:[self.nextResponder touchesBegan:touches withEvent:event]。所以當一個view或者controller里面沒有重寫touch事件,那么這個事件就會一直傳遞下去,直到UIApplication,這也就是事件往上冒泡的原理。如果view重寫了touch方法,我們一般會看到的效果是,這個view響應了事件之后,事件就被截斷了(就像JavaScript里面調用e.stopPropagation()),它的nextResponder不會收到這個事件,即使重寫了nextResponder的touch方法。這個時候如果想事件繼續傳遞下去,可以調用[super touchesBegan:touches withEvent:event],不建議直接調[self.nextResponder touchesBegan:touches withEvent:event]。
##關于UIScrollView的事件
先說一個現象,我們平時加到UIScrollView(或者UITableView和UICollection)上面的UIButton,即使有設置highLighted的樣式,點擊的時候卻發現這個樣式老是不出來,但是按鈕的事件明明可以響應的,很詭異。
后來才知道,UIScrollView因為要滾動,所以對事件做了特殊的處理: 當UIScrollView接收到事件之后,會暫時劫持當前的事件300毫秒,如果300毫秒之后手指還沒有滾動,則認為你放棄滾動,放棄對事件的劫持并往下傳遞,但是從Time Profiler看到此時按鈕并不是調用自身的touch方法,而是調用自身綁定的手勢的touch事件,由于按鈕的highLighted樣式是寫在按鈕的touch方法上的,所以這個這個時候就看不到高亮了。但是長按按鈕缺可以讓按鈕有高亮的狀態,這個就不太清楚為什么了,因為從Time Profiler里面看按鈕的touchesBegan好像還是沒有被調。 如果300毫秒之內手指滾動了,則響應滾動的事件,事件就不會繼續傳給subView了,也就是不會繼續調用按鈕上手勢的touch方法了。
可以通過UIScrollView的一個屬性來解決這個問題:delaysContentTouches,意思是是否需要延遲處理事件的傳遞,默認是NO。把delaysContentTouches設置為YES之后,一切看起來挺好的,按鈕終于有高亮樣式了哈哈哈,但是發現另一個問題:如果手指點擊在按鈕上面并滾動UIScrollView,發現怎么也滾動不了。原因是當手指點擊UIScrollView并在滾動之前,如果subView接收并且可以響應事件(delaysContentTouches設置為YES),則事件響應鏈會在subView響應事件之后就截斷,即UIScrollView本身不會響應到此事件,不會發生滾動??梢栽O置canCancelContentTouches為YES來讓UIScrollView可以滾動,與之類似的還有一個touchesShouldCancelInContentView:接口,可以根據參數view來更方便的判斷是否需要cancel,如果有需要可以在UIScrollView的子類里面重寫這個接口。
這一塊里面的具體實現原理我們都不知道,水太深了,只能通過Time Profiler來看到一些大概的實現,我們也沒必要去深究,大方向理解就好了。真的有興趣的同學也可以去研究研究。
原文地址:http://zhoon.github.io/ios/2015/04/12/ios-event.html