轉(zhuǎn)載: https://blog.csdn.net/qq871531334/article/details/82288933
版權(quán)聲明: https://blog.csdn.net/qq871531334/article/details/82288933
本文主要講解iOS觸摸事件的一系列機(jī)制,涉及的問題大致包括:
觸摸事件由觸屏生成后如何傳遞到當(dāng)前應(yīng)用谤民?
應(yīng)用接收觸摸事件后如何尋找最佳響應(yīng)者乐导?實現(xiàn)原理?
觸摸事件如何沿著響應(yīng)鏈流動窥浪?
響應(yīng)鏈、手勢識別器笛丙、UIControl之間對于觸摸事件的響應(yīng)有著什么樣的瓜葛漾脂?
事件的生命周期
當(dāng)指尖觸碰屏幕的那一刻,一個觸摸事件就在系統(tǒng)中生成了若债。經(jīng)過IPC進(jìn)程間通信符相,事件最終被傳遞到了合適的應(yīng)用拆融。在應(yīng)用內(nèi)歷經(jīng)峰回路轉(zhuǎn)的奇幻之旅后蠢琳,最終被釋放。大致經(jīng)過如下圖:
圖片來源(http://qingmo.me/2017/03/04/FlowOfUITouch/)
系統(tǒng)響應(yīng)階段
1.手指觸碰屏幕镜豹,屏幕感應(yīng)到觸碰后傲须,將事件交由IOKit處理。
2.IOKit將觸摸事件封裝成一個IOHIDEvent對象趟脂,并通過mach port傳遞給SpringBoad進(jìn)程泰讽。
3.SpringBoard進(jìn)程因接收到觸摸事件,觸發(fā)了主線程runloop的source1事件源的回調(diào)昔期。
此時SpringBoard會根據(jù)當(dāng)前桌面的狀態(tài)已卸,判斷應(yīng)該由誰處理此次觸摸事件。因為事件發(fā)生時硼一,你可能正在桌面上翻頁累澡,也可能正在刷微博。若是前者(即前臺無APP運行)般贼,則觸發(fā)SpringBoard本身主線程runloop的source0事件源的回調(diào)愧哟,將事件交由桌面系統(tǒng)去消耗;若是后者(即有app正在前臺運行)哼蛆,則將觸摸事件通過IPC傳遞給前臺APP進(jìn)程蕊梧,接下來的事情便是APP內(nèi)部對于觸摸事件的響應(yīng)了。
APP響應(yīng)階段
APP進(jìn)程的mach port接受到SpringBoard進(jìn)程傳遞來的觸摸事件腮介,主線程的runloop被喚醒肥矢,觸發(fā)了source1回調(diào)。
source1回調(diào)又觸發(fā)了一個source0回調(diào)叠洗,將接收到的IOHIDEvent對象封裝成UIEvent對象甘改,此時APP將正式開始對于觸摸事件的響應(yīng)靴迫。
source0回調(diào)內(nèi)部將觸摸事件添加到UIApplication對象的事件隊列中。事件出隊后楼誓,UIApplication開始一個尋找最佳響應(yīng)者的過程玉锌,這個過程又稱hit-testing,細(xì)節(jié)將在[尋找事件的最佳響應(yīng)者]一節(jié)闡述疟羹。另外主守,此處開始便是與我們平時開發(fā)相關(guān)的工作了。
尋找到最佳響應(yīng)者后榄融,接下來的事情便是事件在響應(yīng)鏈中的傳遞及響應(yīng)了参淫,關(guān)于響應(yīng)鏈相關(guān)的內(nèi)容詳見[事件的響應(yīng)及在響應(yīng)鏈中的傳遞]一節(jié)。事實上愧杯,事件除了被響應(yīng)者消耗涎才,還能被手勢識別器或是target-action模式捕捉并消耗掉。其中涉及對觸摸事件的響應(yīng)優(yōu)先級力九,詳見[事件的三徒弟UIResponder耍铜、UIGestureRecognizer、UIControl]一節(jié)跌前。
觸摸事件歷經(jīng)坎坷后要么被某個響應(yīng)對象捕獲后釋放棕兼,要么致死也沒能找到能夠響應(yīng)的對象,最終釋放抵乓。至此伴挚,這個觸摸事件的使命就算終結(jié)了。runloop若沒有其他事件需要處理灾炭,也將重歸于眠茎芋,等待新的事件到來后喚醒。
現(xiàn)在蜈出,你可以回答第一個問題了田弥。觸摸事件從觸屏產(chǎn)生后,由IOKit將觸摸事件傳遞給SpringBoard進(jìn)程掏缎,再由SpringBoard分發(fā)給當(dāng)前前臺APP處理皱蹦。
觸摸、事件眷蜈、響應(yīng)者
說了那么多沪哺,到底什么是觸摸、什么是事件酌儒、什么是響應(yīng)者辜妓?先簡單科普一下。
UITouch
源起觸摸
一個手指一次觸摸屏幕,就對應(yīng)生成一個UITouch對象籍滴。多個手指同時觸摸酪夷,生成多個UITouch對象。
多個手指先后觸摸孽惰,系統(tǒng)會根據(jù)觸摸的位置判斷是否更新同一個UITouch對象晚岭。若兩個手指一前一后觸摸同一個位置(即雙擊),那么第一次觸摸時生成一個UITouch對象勋功,第二次觸摸更新這個UITouch對象(UITouch對象的 tap count 屬性值從1變成2)坦报;若兩個手指一前一后觸摸的位置不同搀玖,將會生成兩個UITouch對象款侵,兩者之間沒有聯(lián)系。
每個UITouch對象記錄了觸摸的一些信息拴疤,包括觸摸時間骚揍、位置字管、階段、所處的視圖信不、窗口等信息嘲叔。
手指離開屏幕一段時間后,確定該UITouch對象不會再被更新將被釋放浑塞。
UIEvent
觸摸的目的是生成觸摸事件供響應(yīng)者響應(yīng)借跪,一個觸摸事件對應(yīng)一個UIEvent對象,其中的 type 屬性標(biāo)識了事件的類型(之前說過事件不只是觸摸事件)酌壕。
UIEvent對象中包含了觸發(fā)該事件的觸摸對象的集合,因為一個觸摸事件可能是由多個手指同時觸摸產(chǎn)生的歇由。觸摸對象集合通過 allTouches 屬性獲取卵牍。
UIResponder
每個響應(yīng)者都是一個UIResponder對象,即所有派生自UIResponder的對象沦泌,本身都具備響應(yīng)事件的能力糊昙。因此以下類的實例都是響應(yīng)者:
UIView
UIViewController
UIApplication
AppDelegate
響應(yīng)者之所以能響應(yīng)事件,因為其提供了4個處理觸摸事件的方法:
這幾個方法在響應(yīng)者對象接收到事件的時候調(diào)用谢谦,用于做出對事件的響應(yīng)释牺。關(guān)于響應(yīng)者何時接收到事件以及事件如何沿著響應(yīng)鏈傳遞將在下面章節(jié)說明。
尋找事件的最佳響應(yīng)者(Hit-Testing)
第一節(jié)講過APP接收到觸摸事件后回挽,會被放入當(dāng)前應(yīng)用的一個事件隊列中(PS為什么是隊列而不是棧没咙?很好理解因為觸摸事件必然是先發(fā)生先執(zhí)行,切合隊列FIFO的原則)千劈。
每個事件的理想宿命是被能夠響應(yīng)它的對象響應(yīng)后釋放祭刚,然而響應(yīng)者諸多,事件一次只有一個,誰都想把事件搶到自己碗里來涡驮,為避免紛爭暗甥,就得有一個先后順序,也就是得有一個響應(yīng)者的優(yōu)先級捉捅。因此這就存在一個尋找事件最佳響應(yīng)者(又稱第一響應(yīng)者 first responder)的過程撤防,目的是找到一個具備最高優(yōu)先級響應(yīng)權(quán)的響應(yīng)對象(the most appropriate responder object),這個過程叫做Hit-Testing棒口,那個命中的最佳響應(yīng)者稱為hit-tested view即碗。
本節(jié)要探討的問題是:
應(yīng)用接收到事件后,如何尋找最佳響應(yīng)者陌凳?底層如何實現(xiàn)剥懒?
尋找最佳響應(yīng)者過程中事件的攔截。
事件自下而上的傳遞
應(yīng)用接收到事件后先將其置入事件隊列中以等待處理合敦。出隊后初橘,application首先將事件傳遞給當(dāng)前應(yīng)用最后顯示的窗口(UIWindow)詢問其能否響應(yīng)事件。若窗口能響應(yīng)事件充岛,則傳遞給子視圖詢問是否能響應(yīng)保檐,子視圖若能響應(yīng)則繼續(xù)詢問子視圖。子視圖詢問的順序是優(yōu)先詢問后添加的子視圖崔梗,即子視圖數(shù)組中靠后的視圖夜只。事件傳遞順序如下:
事實上把UIWindow也看成是視圖即可,這樣整個傳遞過程就是一個遞歸詢問子視圖能否響應(yīng)事件過程蒜魄,且后添加的子視圖優(yōu)先級高(對于window而言就是后顯示的window優(yōu)先級高)扔亥。
具體流程如下:
UIApplication首先將事件傳遞給窗口對象(UIWindow),若存在多個窗口谈为,則優(yōu)先詢問后顯示的窗口旅挤。
若窗口不能響應(yīng)事件,則將事件傳遞其他窗口伞鲫;若窗口能響應(yīng)事件粘茄,則從后往前詢問窗口的子視圖。
重復(fù)步驟2秕脓。即視圖若不能響應(yīng)柒瓣,則將事件傳遞給上一個同級子視圖;若能響應(yīng)吠架,則從后往前詢問當(dāng)前視圖的子視圖芙贫。
視圖若沒有能響應(yīng)的子視圖了,則自身就是最合適的響應(yīng)者诵肛。
示例:
hit-testing 場景
視圖層級如下(同一層級的視圖越在下面屹培,表示越后添加):
現(xiàn)在假設(shè)在E視圖所處的屏幕位置觸發(fā)一個觸摸默穴,應(yīng)用接收到這個觸摸事件事件后,先將事件傳遞給UIWindow褪秀,然后自下而上開始在子視圖中尋找最佳響應(yīng)者蓄诽。事件傳遞的順序如下所示:
UIWindow將事件傳遞給其子視圖A
A判斷自身能響應(yīng)該事件,繼續(xù)將事件傳遞給C(因為視圖C比視圖B后添加媒吗,因此優(yōu)先傳給C)仑氛。
C判斷自身能響應(yīng)事件,繼續(xù)將事件傳遞給F(同理F比E后添加)闸英。
F判斷自身不能響應(yīng)事件锯岖,C又將事件傳遞給E。
E判斷自身能響應(yīng)事件甫何,同時E已經(jīng)沒有子視圖出吹,因此最終E就是最佳響應(yīng)者。
Hit-Testing的本質(zhì)
上面講了事件在響應(yīng)者之間傳遞的規(guī)則辙喂,視圖通過判斷自身能否響應(yīng)事件來決定是否繼續(xù)向子視圖傳遞捶牢。那么問題來了:視圖如何判斷能否響應(yīng)事件?以及視圖如何將事件傳遞給子視圖巍耗?
首先要知道的是秋麸,以下幾種狀態(tài)的視圖無法響應(yīng)事件:
不允許交互:userInteractionEnabled = NO
隱藏:hidden = YES 如果父視圖隱藏,那么子視圖也會隱藏炬太,隱藏的視圖無法接收事件
透明度:alpha < 0.01 如果設(shè)置一個視圖的透明度<0.01灸蟆,會直接影響子視圖的透明度。alpha:0.0~0.01為透明亲族。
hitTest:withEvent:
每個UIView對象都有一個 hitTest:withEvent: 方法炒考,這個方法是Hit-Testing過程中最核心的存在,其作用是詢問事件在當(dāng)前視圖中的響應(yīng)者孽水,同時又是作為事件傳遞的橋梁票腰。
hitTest:withEvent: 方法返回一個UIView對象,作為當(dāng)前視圖層次中的響應(yīng)者女气。默認(rèn)實現(xiàn)是:
若當(dāng)前視圖無法響應(yīng)事件,則返回nil
若當(dāng)前視圖可以響應(yīng)事件测柠,但無子視圖可以響應(yīng)事件炼鞠,則返回自身作為當(dāng)前視圖層次中的事件響應(yīng)者
若當(dāng)前視圖可以響應(yīng)事件,同時有子視圖可以響應(yīng)轰胁,則返回子視圖層次中的事件響應(yīng)者
一開始UIApplication將事件通過調(diào)用UIWindow對象的 hitTest:withEvent: 傳遞給UIWindow對象谒主,UIWindow的 hitTest:withEvent: 在執(zhí)行時若判斷本身能響應(yīng)事件,則調(diào)用子視圖的 hitTest:withEvent: 將事件傳遞給子視圖并詢問子視圖上的最佳響應(yīng)者赃阀。最終UIWindow返回一個視圖層次中的響應(yīng)者視圖給UIApplication霎肯,這個視圖就是hit-testing的最佳響應(yīng)者擎颖。
系統(tǒng)對于視圖能否響應(yīng)事件的判斷邏輯除了之前提到的3種限制狀態(tài),默認(rèn)能響應(yīng)的條件就是觸摸點在當(dāng)前視圖的坐標(biāo)系范圍內(nèi)观游。因此搂捧,hitTest:withEvent: 的默認(rèn)實現(xiàn)就可以推測了,大致如下:
值得注意的是 pointInside:withEvent: 這個方法懂缕,用于判斷觸摸點是否在自身坐標(biāo)范圍內(nèi)允跑。默認(rèn)實現(xiàn)是若在坐標(biāo)范圍內(nèi)則返回YES,否則返回NO搪柑。
現(xiàn)在我們在上述示例的視圖層次中的每個視圖類中添加下面3個方法來驗證一下之前的分析(注意 hitTest:withEvent: 和 pointInside:withEvent: 方法都要調(diào)用父類的實現(xiàn)聋丝,否則不會按照默認(rèn)的邏輯來執(zhí)行Hit-Testing):
單點觸摸視圖E,相關(guān)日志打印如下:
可以看到最終是視圖E先對事件進(jìn)行了響應(yīng)工碾,同時事件傳遞過程也和之前的分析一致弱睦。事實上單擊后從 [AView hitTest:withEvent:] 到 [EView pointInside:withEvent:] 的過程會執(zhí)行兩遍,兩次傳的是同一個touch渊额,區(qū)別在于touch的狀態(tài)不同况木,第一次是begin階段,第二次是end階段端圈。也就是說焦读,應(yīng)用對于事件的傳遞起源于觸摸狀態(tài)的變化。
Hit-Testing過程中的事件攔截(自定義事件流向)
實際開發(fā)中可能會遇到一些特殊的交互需求舱权,需要定制視圖對于事件的響應(yīng)矗晃。例如下面Tabbar的這種情況,中間的原型按鈕是底部Tabbar上的控件宴倍,而Tabbar是添加在控制器根視圖中的张症。默認(rèn)情況下我們點擊圖中紅色方框中按鈕的區(qū)域,會發(fā)現(xiàn)按鈕并不會得到響應(yīng)鸵贬。
hit-testing過程中事件攔截場景
分析一下原因其實很容易就能明白問題所在俗他。忽略不相關(guān)的控件,視圖層次如下:
點擊紅色方框區(qū)域后阔逼,生成的觸摸事件首先傳到UIWindow兆衅,然后傳到控制器的根視圖即RootView。RootView經(jīng)判斷可以響應(yīng)觸摸事件嗜浮,而后將事件傳給了子控件TabBar羡亩。問題就出在這里,因為觸摸點不在TabBar的坐標(biāo)范圍內(nèi)危融,因此TabBar無法響應(yīng)該觸摸事件畏铆,hitTest:withEvent: 直接返回了nil。而后RootView就會詢問TableView是否能夠響應(yīng)吉殃,事實上是可以的辞居,因此事件最終被TableView消耗楷怒。整個過程,事件根本沒有傳遞到圓形按鈕瓦灶。
有問題就會有解決策略鸠删。經(jīng)過分析,發(fā)現(xiàn)原因是hit-Testing的過程中倚搬,事件在傳遞到TabBar的時候沒能繼續(xù)往CircleButton傳冶共,因為點擊區(qū)域坐標(biāo)不在Tabbar的坐標(biāo)范圍內(nèi),因此Tabbar被識別成了無法響應(yīng)事件每界。既然如此捅僵,我們可以修改事件hit-Testing的過程,當(dāng)點擊紅色方框區(qū)域時讓事件流向原型按鈕眨层。
事件傳遞到TabBar時庙楚,TabBar的 hitTest:withEvent: 被調(diào)用,但是 pointInside:withEvent: 會返回NO趴樱,如此一來 hitTest:withEvent: 返回了nil馒闷。既然如此,可以重寫TabBard的 pointInside:withEvent: 叁征,判斷當(dāng)前觸摸坐標(biāo)是否在子視圖CircleButton的坐標(biāo)范圍內(nèi)纳账,若在,則返回YES捺疼,反之返回NO疏虫。這樣一來點擊紅色區(qū)域,事件最終會傳遞到CircleButton啤呼,CircleButton能夠響應(yīng)事件卧秘,最終事件就由CircleButton響應(yīng)了。同時點擊紅色方框以外的非TabBar區(qū)域的情況下官扣,因為TabBar無法響應(yīng)事件翅敌,會按照預(yù)期由TableView響應(yīng)。代碼如下:
這樣一來惕蹄,點擊紅色方框區(qū)域的按鈕就有效了蚯涮。
事件的響應(yīng)及在響應(yīng)鏈中的傳遞
經(jīng)歷Hit-Testing后,UIApplication已經(jīng)知道事件的最佳響應(yīng)者是誰了卖陵,接下來要做的事情就是:
將事件傳遞給最佳響應(yīng)者響應(yīng)
事件沿著響應(yīng)鏈傳遞
事件響應(yīng)的前奏
因為最佳響應(yīng)者具有最高的事件響應(yīng)優(yōu)先級恋昼,因此UIApplication會先將事件傳遞給它供其響應(yīng)。首先赶促,UIApplication將事件通過 sendEvent: 傳遞給事件所屬的window,window同樣通過 sendEvent: 再將事件傳遞給hit-tested view挟炬,即最佳響應(yīng)者鸥滨。過程如下:
以尋找事件的最佳響應(yīng)者一節(jié)中點擊視圖E為例嗦哆,在EView的 touchesBegan:withEvent: 上斷點查看調(diào)用棧就能看清這一過程:
touchesBegan調(diào)用棧
那么問題又來了。這個過程中婿滓,假如應(yīng)用中存在多個window對象老速,UIApplication是怎么知道要把事件傳給哪個window的?window又是怎么知道哪個視圖才是最佳響應(yīng)者的呢凸主?
其實簡單思考一下橘券,這兩個過程都是傳遞事件的過程,涉及的方法都是 sendEvent: 卿吐,而該方法的參數(shù)(UIEvent對象)是唯一貫穿整個經(jīng)過的線索旁舰,那么就可以大膽猜測必然是該觸摸事件對象上綁定了這些信息。事實上之前在介紹UITouch的時候就說過touch對象保存了觸摸所屬的window及view嗡官,而event對象又綁定了touch對象箭窜,如此一來,是不是就說得通了衍腥。要是不信的話磺樱,那就自定義一個Window類,重寫 sendEvent: 方法婆咸,捕捉該方法調(diào)用時參數(shù)event的狀態(tài)竹捉,答案就顯而易見了。
sendEvent
至于這兩個屬性是什么時候綁定到touch對象上的尚骄,必然是在hit-testing的過程中唄块差,仔細(xì)想想hit-testing干的不就是這個事兒嗎~
事件的響應(yīng)
前面介紹UIResponder的時候說過,每個響應(yīng)者必定都是UIResponder對象乖仇,通過4個響應(yīng)觸摸事件的方法來響應(yīng)事件憾儒。每個UIResponder對象默認(rèn)都已經(jīng)實現(xiàn)了這4個方法,但是默認(rèn)不對事件做任何處理乃沙,單純只是將事件沿著響應(yīng)鏈傳遞起趾。若要截獲事件進(jìn)行自定義的響應(yīng)操作,就要重寫相關(guān)的方法警儒。例如训裆,通過重寫 touchesMoved: withEvent: 方法實現(xiàn)簡單的視圖拖動。
每個響應(yīng)觸摸事件的方法都會接收兩個參數(shù)蜀铲,分別對應(yīng)觸摸對象集合和事件對象边琉。通過監(jiān)聽觸摸對象中保存的觸摸點位置的變動,可以時時修改視圖的位置记劝。視圖(UIView)作為響應(yīng)者對象变姨,本身已經(jīng)實現(xiàn)了 touchesMoved: withEvent: 方法,因此要創(chuàng)建一個自定義視圖(繼承自UIView)厌丑,重寫該方法定欧。
每個響應(yīng)者都有權(quán)決定是否執(zhí)行對事件的響應(yīng)渔呵,只要重寫相關(guān)的觸摸事件方法即可。
事件的傳遞(響應(yīng)鏈)
前面一直在提最佳響應(yīng)者砍鸠,之所以稱之為“最佳”扩氢,是因為其具備響應(yīng)事件的最高優(yōu)先權(quán)(響應(yīng)鏈頂端的男人)。最佳響應(yīng)者首先接收到事件爷辱,然后便擁有了對事件的絕對控制權(quán):即它可以選擇獨吞這個事件录豺,也可以將這個事件往下傳遞給其他響應(yīng)者,這個由響應(yīng)者構(gòu)成的視圖鏈就稱之為響應(yīng)鏈饭弓。
響應(yīng)者對于事件的操作方式:
響應(yīng)者對于事件的攔截以及傳遞都是通過 touchesBegan:withEvent: 方法控制的双饥,該方法的默認(rèn)實現(xiàn)是將事件沿著默認(rèn)的響應(yīng)鏈往下傳遞。
響應(yīng)者對于接收到的事件有3種操作:
不攔截示启,默認(rèn)操作兢哭。事件會自動沿著默認(rèn)的響應(yīng)鏈往下傳遞
攔截,不再往下分發(fā)事件夫嗓。重寫 touchesBegan:withEvent: 進(jìn)行事件處理迟螺,不調(diào)用父類的 touchesBegan:withEvent:
攔截,繼續(xù)往下分發(fā)事件舍咖。重寫 touchesBegan:withEvent: 進(jìn)行事件處理矩父,同時調(diào)用父類的 touchesBegan:withEvent: 將事件往下傳遞
響應(yīng)鏈中的事件傳遞規(guī)則:
每一個響應(yīng)者對象(UIResponder對象)都有一個 nextResponder 方法,用于獲取響應(yīng)鏈中當(dāng)前對象的下一個響應(yīng)者排霉。因此窍株,一旦事件的最佳響應(yīng)者確定了,這個事件所處的響應(yīng)鏈就確定了攻柠。
對于響應(yīng)者對象球订,默認(rèn)的 nextResponder 實現(xiàn)如下:
UIView。若視圖是控制器的根視圖瑰钮,則其nextResponder為控制器對象冒滩;否則,其nextResponder為父視圖浪谴。
UIViewController开睡。若控制器的視圖是window的根視圖,則其nextResponder為窗口對象苟耻;若控制器是從別的控制器present出來的篇恒,則其nextResponder為presenting view controller。
UIWindow凶杖。nextResponder為UIApplication對象胁艰。
UIApplication。若當(dāng)前應(yīng)用的app delegate是一個UIResponder對象,且不是UIView蝗茁、UIViewController或app本身醋虏,則UIApplication的nextResponder為app delegate。
上圖是官網(wǎng)對于響應(yīng)鏈的示例展示哮翘,若觸摸發(fā)生在UITextField上,則事件的傳遞順序是:
圖中虛線箭頭是指若該UIView是作為UIViewController根視圖存在的毛秘,則其nextResponder為UIViewController對象饭寺;若是直接add在UIWindow上的,則其nextResponder為UIWindow對象叫挟。
可以用以下方式打印一個響應(yīng)鏈中的每一個響應(yīng)對象艰匙,在最佳響應(yīng)者的 touchBegin:withEvent: 方法中調(diào)用即可(別忘了調(diào)用父類的方法)
以上一節(jié)原型按鈕的案例為例,重寫CircleButton的 touchBegin:withEvent:
點擊原型按鈕的任意區(qū)域抹恳,打印出的完整響應(yīng)鏈如下:
另外如果有需要员凝,完全可以重寫響應(yīng)者的 nextResponder 方法來自定義響應(yīng)鏈。
事件的三徒弟UIResponder奋献、UIGestureRecognizer、UIControl
iOS中,除了UIResponder能夠響應(yīng)事件瞳别,手勢識別器、UIControl同樣具備對事件的處理能力。當(dāng)這幾者同時存在于某一場景下的時候,事件又會有怎樣的歸宿呢乖订?
拋磚引玉
場景界面如圖:
手勢沖突場景
代碼不能再簡單:
然后我像往常一樣懷揣著吃奶的自信點擊了cell。what岂丘?奥帘?點不動寨蹋?已旧?點歪了嗎??再點,還是沒反應(yīng)!氧秘!我試著短按了一小會兒cell,依舊沒反應(yīng)!座硕!我不死心,長按了一會兒华匾,didSelectRowAtIndexPath終于調(diào)了,還算給點面子 - -萨西。然后我又點了下面的button有鹿,沒有任何問題。but what 葱跋?年局?
為了搞清楚狀況脑溢,我自定義了相關(guān)的控件類,均重寫了4個響應(yīng)觸摸事件的方法以打印日志(每個重寫的觸摸事件方法都調(diào)用了父類的方法以保證事件默認(rèn)傳遞邏輯)顶吮。
觀察各種情況下的日志現(xiàn)象:
現(xiàn)象一 快速點擊cell
現(xiàn)象二 短按cell
現(xiàn)象三 長按cell
現(xiàn)象四 點擊button
如果上面的現(xiàn)象依舊能讓你舒心地抿上一口咖啡社牲,那么恭喜你,本節(jié)的內(nèi)容已經(jīng)不適合你了悴了。如果覺得一臉懵逼搏恤,那就繼續(xù)往下看吧~
二師兄—手勢識別器
關(guān)于手勢識別器即 UIGestureRecognizer 本身的使用不是本文要所討論的內(nèi)容,按下不表湃交。此處要探討的是:手勢識別器與UIResponder的聯(lián)系熟空。
事實上,手勢分為離散型手勢(discrete gestures)和持續(xù)型手勢(continuous gesture)搞莺。系統(tǒng)提供的離散型手勢包括點按手勢(UITapGestureRecognizer)和輕掃手勢(UISwipeGestureRecognizer)息罗,其余均為持續(xù)型手勢。
兩者主要區(qū)別在于狀態(tài)變化過程:
離散型:
識別成功:Possible —> Recognized
識別失敳挪住:Possible —> Failed
持續(xù)型:
完整識別:Possible —> Began —> [Changed] —> Ended
不完整識別:Possible —> Began —> [Changed] —> Cancel
離散型手勢
先拋開上面的場景迈喉,看一個簡單的demo。
控制器的視圖上add了一個View記為YellowView温圆,并綁定了一個單擊手勢識別器挨摸。
單擊YellowView,日志打印如下:
從日志上看出YellowView最后Cancel了對觸摸事件的響應(yīng)捌木,而正常應(yīng)當(dāng)是觸摸結(jié)束后油坝,YellowView的 touchesEnded:withEvent: 的方法被調(diào)用才對。另外,期間還執(zhí)行了手勢識別器綁定的action 澈圈。我從官方文檔找到了這樣的解釋:
大致理解是彬檀,Window在將事件傳遞給hit-tested view之前,會先將事件傳遞給相關(guān)的手勢識別器并由手勢識別器優(yōu)先識別瞬女。若手勢識別器成功識別了事件窍帝,就會取消hit-tested view對事件的響應(yīng);若手勢識別器沒能識別事件诽偷,hit-tested view才完全接手事件的響應(yīng)權(quán)坤学。
一句話概括:手勢識別器比UIResponder具有更高的事件響應(yīng)優(yōu)先級!报慕!
按照這個解釋深浮,Window在將事件傳遞給hit-tested view即YellowView之前,先傳遞給了控制器根視圖上的手勢識別器眠冈。手勢識別器成功識別了該事件飞苇,通知Application取消YellowView對事件的響應(yīng)。
然而看日志蜗顽,卻是YellowView的 touchesBegan:withEvent: 先調(diào)用了布卡,既然手勢識別器先響應(yīng),不應(yīng)該上面的action先執(zhí)行嗎雇盖,這又怎么解釋忿等?事實上這個認(rèn)知是錯誤的。手勢識別器的action的調(diào)用時機(jī)(即此處的 actionTap)并不是手勢識別器接收到事件的時機(jī)崔挖,而是手勢識別器成功識別事件后的時機(jī)贸街,即手勢識別器的狀態(tài)變?yōu)閁IGestureRecognizerStateRecognized。因此從該日志中并不能看出事件是優(yōu)先傳遞給手勢識別器的虚汛,那該怎么證明Window先將事件傳遞給了手勢識別器匾浪?
要解決這個問題,只要知道手勢識別器是如何接收事件的卷哩,然后在接收事件的方法中打印日志對比調(diào)用時間先后即可蛋辈。說起來你可能不信,手勢識別器對于事件的響應(yīng)也是通過這4個熟悉的方法來實現(xiàn)的将谊。
需要注意的是冷溶,雖然手勢識別器通過這幾個方法來響應(yīng)事件,但它并不是UIResponder的子類尊浓,相關(guān)的方法聲明在 UIGestureRecognizerSubclass.h 中逞频。
這樣一來,我們便可以自定義一個單擊手勢識別器的類栋齿,重寫這幾個方法來監(jiān)聽手勢識別器接收事件的時機(jī)苗胀。創(chuàng)建一個UITapGestureRecognizer的子類襟诸,重寫響應(yīng)事件的方法,每個方法中調(diào)用父類的實現(xiàn)基协,并替換demo中的手勢識別器歌亲。另外需要在.m文件中引入 import,因為相關(guān)方法聲明在該頭文件中澜驮。
現(xiàn)在陷揪,再次點擊YellowView,日志如下:
很明顯杂穷,確實是手勢識別器先接收到了事件悍缠。之后手勢識別器成功識別了手勢,執(zhí)行了action耐量,再由Application取消了YellowView對事件的響應(yīng)飞蚓。
Window怎么知道要把事件傳遞給哪些手勢識別器?
之前探討過Application怎么知道要把event傳遞給哪個Window廊蜒,以及Window怎么知道要把event傳遞給哪個hit-tested view的問題玷坠,答案是這些信息都保存在event所綁定的touch對象上。手勢識別器也是一樣的劲藐,event綁定的touch對象上維護(hù)了一個手勢識別器數(shù)組,里面的手勢識別器毫無疑問是在hit-testing的過程中收集的樟凄。打個斷點看一下touch上綁定的手勢識別器數(shù)組:
Window先將事件傳遞給這些手勢識別器聘芜,再傳給hit-tested view。一旦有手勢識別器成功識別了手勢缝龄,Application就會取消hit-tested view對事件的響應(yīng)汰现。
持續(xù)型手勢
將上面Demo中視圖綁定的單擊手勢識別器用滑動手勢識別器(UIPanGestureRecognizer)替換。
在YellowView上執(zhí)行一次滑動:
日志打印如下:
在一開始滑動的過程中叔壤,手勢識別器處在識別手勢階段瞎饲,滑動產(chǎn)生的連續(xù)事件既會傳遞給手勢識別器又會傳遞給YellowView,因此YellowView的 touchesMoved:withEvent: 在開始一段時間內(nèi)會持續(xù)調(diào)用炼绘;當(dāng)手勢識別器成功識別了該滑動手勢時嗅战,手勢識別器的action開始調(diào)用,同時通知Application取消YellowView對事件的響應(yīng)俺亮。之后僅由滑動手勢識別器接收事件并響應(yīng)驮捍,YellowView不再接收事件。
另外脚曾,在滑動的過程中东且,若手勢識別器未能識別手勢,則事件在觸摸滑動過程中會一直傳遞給hit-tested view本讥,直到觸摸結(jié)束珊泳。讀者可自行驗證鲁冯。
手勢識別器的3個屬性
先總結(jié)一下手勢識別器與UIResponder對于事件響應(yīng)的聯(lián)系:
當(dāng)觸摸發(fā)生或者觸摸的狀態(tài)發(fā)生變化時,Window都會傳遞事件尋求響應(yīng)色查。
Window先將綁定了觸摸對象的事件傳遞給觸摸對象上綁定的手勢識別器薯演,再發(fā)送給觸摸對象對應(yīng)的hit-tested view。
手勢識別器識別手勢期間综慎,若觸摸對象的觸摸狀態(tài)發(fā)生變化涣仿,事件都是先發(fā)送給手勢識別器再發(fā)送給hit-test view。
手勢識別器若成功識別了手勢示惊,則通知Application取消hit-tested view對于事件的響應(yīng)好港,并停止向hit-tested view發(fā)送事件;
若手勢識別器未能識別手勢米罚,而此時觸摸并未結(jié)束钧汹,則停止向手勢識別器發(fā)送事件,僅向hit-test view發(fā)送事件录择。
若手勢識別器未能識別手勢拔莱,且此時觸摸已經(jīng)結(jié)束,則向hit-tested view發(fā)送end狀態(tài)的touch事件以停止對事件的響應(yīng)隘竭。
cancelsTouchesInView
默認(rèn)為YES塘秦。表示當(dāng)手勢識別器成功識別了手勢之后,會通知Application取消響應(yīng)鏈對事件的響應(yīng)动看,并不再傳遞事件給hit-test view尊剔。若設(shè)置成NO,表示手勢識別成功后不取消響應(yīng)鏈對事件的響應(yīng)菱皆,事件依舊會傳遞給hit-test view须误。
demo中設(shè)置: pan.cancelsTouchesInView = NO
滑動時日志如下:
即便滑動手勢識別器識別了手勢昌妹,Application也會依舊發(fā)送事件給YellowView冗尤。
delaysTouchesBegan
默認(rèn)為NO顷歌。默認(rèn)情況下手勢識別器在識別手勢期間铅协,當(dāng)觸摸狀態(tài)發(fā)生改變時逢防,Application都會將事件傳遞給手勢識別器和hit-tested view昔园;若設(shè)置成YES泥张,則表示手勢識別器在識別手勢期間忽匈,截斷事件船庇,即不會將事件發(fā)送給hit-tested view吭产。
設(shè)置 pan.delaysTouchesBegan = YES
日志如下:
因為滑動手勢識別器在識別期間,事件不會傳遞給YellowView鸭轮,因此期間YellowView的 touchesBegan:withEvent: 和 touchesMoved:withEvent: 都不會被調(diào)用臣淤;而后滑動手勢識別器成功識別了手勢,也就獨吞了事件窃爷,不會再傳遞給YellowView邑蒋。因此只打印了手勢識別器成功識別手勢后的action調(diào)用姓蜂。
delaysTouchesEnded
默認(rèn)為YES。當(dāng)手勢識別失敗時医吊,若此時觸摸已經(jīng)結(jié)束钱慢,會延遲一小段時間(0.15s)再調(diào)用響應(yīng)者的 touchesEnded:withEvent:;若設(shè)置成NO卿堂,則在手勢識別失敗時會立即通知Application發(fā)送狀態(tài)為end的touch事件給hit-tested view以調(diào)用 touchesEnded:withEvent: 結(jié)束事件響應(yīng)束莫。
大師兄—UIControl
UIControl是系統(tǒng)提供的能夠以target-action模式處理觸摸事件的控件,iOS中UIButton草描、UISegmentedControl览绿、UISwitch等控件都是UIControl的子類。當(dāng)UIControl跟蹤到觸摸事件時穗慕,會向其上添加的target發(fā)送事件以執(zhí)行action饿敲。值得注意的是,UIConotrol是UIView的子類逛绵,因此本身也具備UIResponder應(yīng)有的身份怀各。
關(guān)于UIControl,此處介紹兩點:
target-action執(zhí)行時機(jī)及過程
觸摸事件優(yōu)先級
target-action
target:處理交互事件的對象
action:處理交互事件的方式
UIControl作為能夠響應(yīng)事件的控件术浪,必然也需要待事件交互符合條件時才去響應(yīng)瓢对,因此也會跟蹤事件發(fā)生的過程。不同于UIControl以及UIGestureRecognizer通過 touches 系列方法跟蹤胰苏,UIControl有其獨特的跟蹤方式:
乍一看沥曹,這4個方法和UIResponder的那4個方法幾乎吻合,只不過UIControl只能接收單點觸控碟联,因此接收的參數(shù)是單個UITouch對象。這幾個方法的職能也和UIResponder一致僵腺,用來跟蹤觸摸的開始鲤孵、滑動、結(jié)束辰如、取消普监。不過,UIControl本身也是UIResponder琉兜,因此同樣有 touches 系列的4個方法凯正。事實上,UIControl的 Tracking 系列方法是在 touch 系列方法內(nèi)部調(diào)用的豌蟋。比如 beginTrackingWithTouch 是在 touchesBegan 方法內(nèi)部調(diào)用的廊散, 因此它雖然也是UIResponder,但 touches 系列方法的默認(rèn)實現(xiàn)和UIResponder本類還是有區(qū)別的梧疲。
當(dāng)UIControl跟蹤事件的過程中允睹,識別出事件交互符合響應(yīng)條件运准,就會觸發(fā)target-action進(jìn)行響應(yīng)。UIControl控件通過 addTarget:action:forControlEvents: 添加事件處理的target和action缭受,當(dāng)事件發(fā)生時胁澳,UIControl通知target執(zhí)行對應(yīng)的action。說是“通知”其實很籠統(tǒng)米者,事實上這里有個action傳遞的過程韭畸。當(dāng)UIControl監(jiān)聽到需要處理的交互事件時,會調(diào)用 sendAction:to:forEvent: 將target蔓搞、action以及event對象發(fā)送給全局應(yīng)用胰丁,Application對象再通過 sendAction:to:from:forEvent: 向target發(fā)送action。
因此败明,可以通過重寫UIControl的 sendAction:to:forEvent: 或 sendAction:to:from:forEvent: 自定義事件執(zhí)行的target及action隘马。
另外,若不指定target妻顶,即 addTarget:action:forControlEvents: 時target傳空酸员,那么當(dāng)事件發(fā)生時,Application會在響應(yīng)鏈上從上往下尋找能響應(yīng)action的對象讳嘱。官方說明如下:
觸摸事件優(yōu)先級
當(dāng)原本關(guān)系已經(jīng)錯綜復(fù)雜的UIGestureRecognizer和UIResponder之間又冒出一個UIControl幔嗦,又會摩擦出什么樣的火花呢?
簡單理解:UIControl會阻止父視圖上的手勢識別器行為沥潭,也就是UIControl處理事件的優(yōu)先級比UIGestureRecognizer高邀泉,但前提是相比于父視圖上的手勢識別器。
UIControl測試場景
預(yù)置場景:在BlueView上添加一個button钝鸽,同時給button添加一個target-action事件汇恤。
示例一:在BlueView上添加點擊手勢識別器
示例二:在button上添加手勢識別器
操作方式:單擊button
測試結(jié)果:示例一中,button的target-action響應(yīng)了單擊事件拔恰;示例二中因谎,BlueView上的手勢識別器響應(yīng)了事件。過程日志打印如下:
按鈕點擊
原因分析:點擊button后颜懊,事件先傳遞給手勢識別器财岔,再傳遞給作為hit-tested view存在的button(UIControl本身也是UIResponder,這一過程和普通事件響應(yīng)者無異)河爹。示例一中匠璧,由于button阻止了父視圖BlueView中的手勢識別器的識別,導(dǎo)致手勢識別器識別失斚陶狻(狀態(tài)為failed 枚舉值為5)夷恍,button完全接手了事件的響應(yīng)權(quán),事件最終由button響應(yīng)媳维;示例二中裁厅,button未阻止其本身綁定的手勢識別器的識別冰沙,因此手勢識別器先識別手勢并識別成功(狀態(tài)為ended 枚舉值為3),而后通知Application取消響應(yīng)鏈對事件的響應(yīng)执虹,因為 touchesCancelled 被調(diào)用拓挥,同時 cancelTrackingWithEvent 跟著調(diào)用,因此button的target-action得不到執(zhí)行袋励。
其他:經(jīng)測試侥啤,若示例一中的手勢識別器設(shè)置 cancelsTouchesInView 為NO,手勢識別器和button都能響應(yīng)事件茬故。也就是說這種情況下盖灸,button不會阻止父視圖中手勢識別器的識別。
結(jié)論:UIControl比其父視圖上的手勢識別器具有更高的事件響應(yīng)優(yōu)先級磺芭。
TODO:
上述過程中赁炎,手勢識別器在執(zhí)行touchesEnded時是根據(jù)什么將狀態(tài)置為ended還是failed的?即根據(jù)什么判斷應(yīng)當(dāng)識別成功還是識別失敿叵佟徙垫?
糾正
以上所述UIControl的響應(yīng)優(yōu)先級比手勢識別器高的說法不準(zhǔn)確,準(zhǔn)確地說只適用于系統(tǒng)提供的有默認(rèn)action操作的UIControl放棒,例如UIbutton姻报、UISwitch等的單擊,而對于自定義的UIControl间螟,經(jīng)驗證吴旋,響應(yīng)優(yōu)先級比手勢識別器低。讀者可自行驗證厢破,感謝 @閆仕偉 同學(xué)的糾正荣瑟。
撥云見日
現(xiàn)在,把膠卷回放到本章節(jié)開頭的場景摩泪。給你一杯咖啡的時間看看能不能解釋得通那幾個現(xiàn)象了褂傀,不說了泡咖啡去了...
我肥來了!
先看現(xiàn)象二加勤,短按 cell無法響應(yīng),日志如下:
這個日志和上面離散型手勢Demo中打印的日志完全一致同波。短按后鳄梅,BackView上的手勢識別器先接收到事件,之后事件傳遞給hit-tested view未檩,作為響應(yīng)者鏈中一員的GLTableView的 touchesBegan:withEvent: 被調(diào)用戴尸;而后手勢識別器成功識別了點擊事件,action執(zhí)行冤狡,同時通知Application取消響應(yīng)鏈中的事件響應(yīng)孙蒙,GLTableView的 touchesCancelled:withEvent: 被調(diào)用项棠。
因為事件被取消了,因此Cell無法響應(yīng)點擊挎峦。
再看現(xiàn)象三香追,長按cell能夠響應(yīng),日志如下:
長按的過程中坦胶,一開始事件同樣被傳遞給手勢識別器和hit-tested view透典,作為響應(yīng)鏈中一員的GLTableView的 touchesBegan:withEvent: 被調(diào)用;此后在長按的過程中顿苇,手勢識別器一直在識別手勢峭咒,直到一定時間后手勢識別失敗,才將事件的響應(yīng)權(quán)完全交給響應(yīng)鏈纪岁。當(dāng)觸摸結(jié)束的時候凑队,GLTableView的 touchesEnded:withEvent: 被調(diào)用,同時Cell響應(yīng)了點擊幔翰。
OK漩氨,現(xiàn)在回到現(xiàn)象一。按照之前的分析导匣,快速點擊cell才菠,講道理不管是表現(xiàn)還是日志都應(yīng)該和現(xiàn)象二一致才對。然而日志僅僅打印了手勢識別器的action執(zhí)行結(jié)果贡定。分析一下原因:GLTableView的 touchesBegan 沒有調(diào)用赋访,說明事件沒有傳遞給hit-tested view。那只有一種可能缓待,就是事件被某個手勢識別器攔截了蚓耽。目前已知的手勢識別器攔截事件的方法,就是設(shè)置 delaysTouchesBegan 為YES旋炒,在手勢識別器未識別完成的情況下不會將事件傳遞給hit-tested view步悠。然后事實上并沒有進(jìn)行這樣的設(shè)置,那么問題可能出在別的手勢識別器上瘫镇。
Window的 sendEvent: 打個斷點查看event上的touch對象維護(hù)的手勢識別器數(shù)組:
ScrollView延遲發(fā)送事件
捕獲可疑對象:UIScrollViewDelayedTouchesBeganGestureRecognizer 鼎兽,光看名字就覺得這貨脫不了干系。從類名上猜測铣除,這個手勢識別器大概會延遲事件向響應(yīng)鏈的傳遞谚咬。github上找到了該私有類的頭文件:
有一個_touchDelay變量,大概是用來控制延遲事件發(fā)送的尚粘。另外择卦,方法列表里有個 sendTouchesShouldBeginForDelayedTouches: 方法,聽名字似乎是在一段時間延遲后向響應(yīng)鏈傳遞事件用的。為一探究竟秉继,我創(chuàng)建了一個類hook了這個方法:
斷點看一下點擊cell后 hook_sendTouchesShouldBeginForDelayedTouches: 調(diào)用時的信息:
延遲的本質(zhì)
可以看到這個手勢識別器的 _touchDelay 變量中祈噪,保存了一個計時器,以及一個長得很像延遲時間間隔的變量m_delay∩屑現(xiàn)在辑鲤,可以推測該手勢識別器截斷了事件并延遲0.15s才發(fā)送給hit-tested view。為驗證猜測腌巾,我分別在Window的 sendEvent: 遂填,hook_sendTouchesShouldBeginForDelayedTouches: 以及TableView的 touchesBegan: 中打印時間戳,若猜測成立澈蝙,則應(yīng)當(dāng)前兩者的調(diào)用時間相差0.15s左右吓坚,后兩者的調(diào)用時間很接近。短按Cell后打印結(jié)果如下(不能快速點擊灯荧,否則還沒過延遲時間觸摸就結(jié)束了礁击,無法驗證猜測):
因為有兩個 UIScrollViewDelayedTouchesBeganGestureRecognizer,所以 hook_sendTouchesShouldBeginForDelayedTouches 調(diào)了兩次逗载,兩次的時間很接近哆窿。可以看到厉斟,結(jié)果完全符合猜測挚躯。
這樣就都解釋得通了。現(xiàn)象一由于點擊后擦秽,UIScrollViewDelayedTouchesBeganGestureRecognizer 攔截了事件并延遲了0.15s發(fā)送码荔。又因為點擊時間比0.15s短,在發(fā)送事件前觸摸就結(jié)束了感挥,因此事件沒有傳遞到hit-tested view缩搅,導(dǎo)致TableView的 touchBegin 沒有調(diào)用。而現(xiàn)象二触幼,由于短按的時間超過了0.15s硼瓣,手勢識別器攔截了事件并經(jīng)過0.15s后,觸摸還未結(jié)束置谦,于是將事件傳遞給了hit-tested view堂鲤,使得TableView接收到了事件。因此現(xiàn)象二的日志雖然和離散型手勢Demo中的日志一致媒峡,但實際上前者的hit-tested view是在觸摸后延遲了約0.15s左右才接收到觸摸事件的瘟栖。
至于現(xiàn)象四 ,你現(xiàn)在應(yīng)該已經(jīng)覺得理所當(dāng)然了才對丝蹭。
總結(jié)
觸摸發(fā)生時,系統(tǒng)內(nèi)核生成觸摸事件,先由IOKit處理封裝成IOHIDEvent對象奔穿,通過IPC傳遞給系統(tǒng)進(jìn)程SpringBoard镜沽,而后再傳遞給前臺APP處理。
事件傳遞到APP內(nèi)部時被封裝成開發(fā)者可見的UIEvent對象贱田,先經(jīng)過hit-testing尋找第一響應(yīng)者缅茉,而后由Window對象將事件傳遞給hit-tested view,并開始在響應(yīng)鏈上的傳遞男摧。
UIRespnder蔬墩、UIGestureRecognizer、UIControl耗拓,籠統(tǒng)地講拇颅,事件響應(yīng)優(yōu)先級依次遞增。
參考資料
史上最詳細(xì)的iOS之事件的傳遞和響應(yīng)機(jī)制-原理篇
Understanding Event Handling, Responders, and the Responder Chain