iOS 點(diǎn)擊事件傳遞及響應(yīng)

前言:蘋果的官方文檔《Event Handling Guide for iOS》對事件處理做了非常詳盡清晰的解釋,建議大家仔細(xì)研讀

關(guān)于iOS的事件響應(yīng)機(jī)制網(wǎng)上講解文章不少,有的文章內(nèi)容少沒講全面,有的說的太多,一個概念反復(fù)說,傳遞和響應(yīng)混在一起講,不好理解,我綜合參考了幾篇文章總結(jié)了一下,覺得可以分為以下幾點(diǎn)來講

1. iOS中的事件介紹
2. 事件的產(chǎn)生和傳遞
3. 事件響應(yīng)
4. 實(shí)際項(xiàng)目中的應(yīng)用

1.iOS中的事件介紹

iOS中的事件可以分為3大類型:

  • 觸屏事件(例如點(diǎn)擊按鈕、通過手勢縮放圖片、拖動上下滾動頁面等)
  • 傳感器事件(例如搖一搖紅包、通過旋轉(zhuǎn)設(shè)備控制賽車方向、指南針等)
  • 遠(yuǎn)程控制事件(例如耳機(jī)的線控、外接手柄、遙控器等)

著重講解一下iOS處理觸屏事件,觸屏事件分為兩種方式:

  • 高級事件處理:利用UIKit提供的各種用戶控件或者手勢識別器來處理事件。
  • 低級事件處理:在UIView的子類中重寫觸屏回調(diào)方法,直接處理觸屏事件。

如果想監(jiān)聽一個view上面的觸摸事件,之前的做法是:

(1)自定義一個view。

(2)實(shí)現(xiàn)view的touches方法,在方法內(nèi)部實(shí)現(xiàn)具體處理代碼。

通過touches方法監(jiān)聽view觸摸事件,有很明顯的幾個缺點(diǎn):

(1)必須得自定義view。

(2)由于是在view內(nèi)部的touches方法中監(jiān)聽觸摸事件,因此默認(rèn)情況下,無法讓其他外界對象監(jiān)聽view的觸摸事件。

(3)不容易區(qū)分用戶的具體手勢行為。

iOS 3.2之后,蘋果推出了手勢識別功能(Gesture Recognizer),在觸摸事件處理方面,大大簡化了開發(fā)者的開發(fā)難度。

UIKit中我們常用的是UIControl類實(shí)例的addTarget:action:forControlEvents:方法維護(hù)控件目標(biāo)行為表,除了UIKit控件外,手勢識別器UIGestureRecognizer類的實(shí)例也是處理觸屏事件的好幫手,其內(nèi)部也使用目標(biāo)行為表。

為什么手勢和單擊事件只會響應(yīng)手勢?

UIGestureRecognizer 有個屬性cancelsTouchesInView,這個屬性默認(rèn)值是YES,即當(dāng)手勢識別成功后,會發(fā)送touchesCancelled消息給view來結(jié)束view的響應(yīng)。
如果cancelsTouchesInView為NO,那么gestureRecognizer和view都可以響應(yīng)

UIKit內(nèi)置了6種手勢識別器:

UITapGestureRecognizer:點(diǎn)擊(單擊、雙擊、三連擊等)手勢。
UIPinchGestureRecognizer:縮放手勢。
UIPanGestureRecognizer:拖拽手勢。
UISwipeGestureRecognizer:滑動手勢。
UIRotationGestureRecognizer:旋轉(zhuǎn)手勢。
UILongPressGestureRecognizer:長按手勢。

UIKit控件和手勢識別器屬于高級事件處理的范疇,這些不再多說,以下文字都是介紹的低級事件處理過程

UITouch

當(dāng)你用一根手指觸摸屏幕時, 會創(chuàng)建一個與之關(guān)聯(lián)的UITouch對象, 一個UITouch對象對應(yīng)一根手指. 在事件中可以根據(jù)NSSet中UITouch對象的數(shù)量得出此次觸摸事件是單指觸摸還是雙指多指等等


觸摸產(chǎn)生時所處的窗口
@property(nonatomic,readonly,retain) UIWindow *window;
觸摸產(chǎn)生時所處的視圖
@property(nonatomic,readonly,retain) UIView   *view;
短時間內(nèi)點(diǎn)按屏幕的次數(shù),可以根據(jù)tapCount判斷單擊、雙擊或更多的點(diǎn)擊
@property(nonatomic,readonly) NSUInteger      tapCount;
記錄了觸摸事件產(chǎn)生或變化時的時間,單位是秒
@property(nonatomic,readonly) NSTimeInterval  timestamp;
當(dāng)前觸摸事件所處的狀態(tài)
@property(nonatomic,readonly) UITouchPhase    phase;

UIEvent

每產(chǎn)生一個事件, 就對應(yīng)產(chǎn)生一個UIEvent. UIEvent記錄著該事件產(chǎn)生的時間, 事件的類型等等

UIEvent幾個重要的屬性 :


事件類型
@property(nonatomic,readonly) UIEventType     type;
@property(nonatomic,readonly) UIEventSubtype  subtype;
事件產(chǎn)生的時間
@property(nonatomic,readonly) NSTimeInterval  timestamp;

響應(yīng)者對象(UIResponder)

在iOS中不是任何對象都能處理事件, 只有繼承了UIResponder的對象才能接收并處理事件,我們稱為響應(yīng)者對象
UIApplication,UIViewController,UIView都繼承自UIResponder,因此他們都是響應(yīng)者對象, 都能夠接收并處理事件

繼承自UIResponder的類能處理事件是由于UIResponder內(nèi)部提供了以下方法

觸摸事件
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;

傳感器事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event;

遠(yuǎn)程控制事件
- (void)remoteControlReceivedWithEvent:(UIEvent *)event;


兩個UIView相關(guān)屬性:

  • multipleTouchEnabled:是否開啟多點(diǎn)觸控
  • exclusiveTouch :多個控件接受事件時的排他性

2. iOS中事件的產(chǎn)生和傳遞

1.發(fā)生觸摸事件后,系統(tǒng)會將該事件加入到一個由UIApplication管理的隊列事件中

2.UIApplication會從事件隊列中取出最前面的事件,并將事件分發(fā)下去以便處理,通常會先發(fā)送事件給應(yīng)用程序的主窗口(keyWindow)

3.主窗口會在視圖層次結(jié)構(gòu)中找到一個最合適的視圖來處理觸摸事件

事件的具體傳遞過程,如圖:

150807180781051.png

一般事件的傳遞是從父控件傳遞到子控件的

例如:

點(diǎn)擊了綠色的View,傳遞過程如下:UIApplication->Window->白色View->綠色View

點(diǎn)擊藍(lán)色的View,傳遞過程如下:UIApplication->Window->白色View->橙色View->藍(lán)色View

如果父控件接受不到觸摸事件,那么子控件就不可能接收到觸摸事件

( 重難點(diǎn))如何尋找最合適的view

應(yīng)用如何找到最合適的控件來處理事件?有以下準(zhǔn)則
1.首先判斷主窗口(keyWindow)自己是否能接受觸摸事件
2.觸摸點(diǎn)是否在自己身上
3.從后往前遍歷子控件,重復(fù)前面的兩個步驟(首先查找數(shù)組中最后一個元素)
4.如果沒有符合條件的子控件,那么就認(rèn)為自己最合適處理

詳述:
1.主窗口接收到應(yīng)用程序傳遞過來的事件后,首先判斷自己能否接手觸摸事件。如果能,那么在判斷觸摸點(diǎn)在不在窗口自己身上
2.如果觸摸點(diǎn)也在窗口身上,那么窗口會從后往前遍歷自己的子控件(遍歷自己的子控件只是為了尋找出來最合適的view)
3.遍歷到每一個子控件后,又會重復(fù)上面的兩個步驟(傳遞事件給子控件,1.判斷子控件能否接受事件,2.點(diǎn)在不在子控件上)
4.如此循環(huán)遍歷子控件,直到找到最合適的view,如果沒有更合適的子控件,那么自己就成為最合適的view。

注意:之所以會采取從后往前遍歷子控件的方式尋找最合適的view只是為了做一些循環(huán)優(yōu)化。因?yàn)橄啾容^之下,后添加的view在上面,降低循環(huán)次數(shù)。

UIView不能接收觸摸事件的三種情況:

  • 不接受用戶交互:userInteractionEnabled = NO;

  • 隱藏:hidden = YES;

  • 透明:alpha = 0.0~0.01

尋找最合適的view過程,如圖:

150807180781052.png

說明一下控件的添加順序:白1->綠2->橙2->藍(lán)3->紅3->黃4

這里點(diǎn)擊了橙色的那塊區(qū)域,事件傳遞判斷過程如下:

1.UIApplication從事件隊列中取出事件分發(fā)給UIWindow

2.UIWindow判斷自己是否能接受觸摸事件,可以

3.UIWindow判斷觸摸點(diǎn)是否在自己身上,是的。

4.UIWindow從后往前便利自己的子控件,取出白1

5.白1都滿足最上面兩個條件,遍歷子控件橙2

6.橙2都滿足最上面兩個條件,遍歷子控件,先取出紅3

7.紅3不滿足條件2,取出藍(lán)3

8.藍(lán)3也不滿足條件2,最后最合適的控件是橙2

在事件傳遞尋找最合適的View時,底層到底干了哪些事?

尋找合適的View用到兩個重要方法:

  • hitTest:withEvent:

  • pointInside

hitTest:withEvent:方法

什么時候調(diào)用?

  • 只要事件一傳遞給一個控件,這個控件就會調(diào)用他自己的hitTest:withEvent:方法尋找合適的View
作用
尋找并返回最合適的view(能夠響應(yīng)事件的那個最合適的view)
注 意:不管這個控件能不能處理事件,也不管觸摸點(diǎn)在不在這個控件上,
事件都會先傳遞給這個控件,隨后再調(diào)用hitTest:withEvent:方法

hitTest:withEvent:底層調(diào)用流程:

123.png
底層具體實(shí)現(xiàn)如下 :
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    // 1.判斷當(dāng)前控件能否接收事件
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
    // 2. 判斷點(diǎn)在不在當(dāng)前控件
    if ([self pointInside:point withEvent:event] == NO) return nil;
    // 3.從后往前遍歷自己的子控件
    NSInteger count = self.subviews.count;
    for (NSInteger i = count - 1; i >= 0; i--) {
        UIView *childView = self.subviews[i];
        // 把當(dāng)前控件上的坐標(biāo)系轉(zhuǎn)換成子控件上的坐標(biāo)系
        CGPoint childP = [self convertPoint:point toView:childView];
        UIView *fitView = [childView hitTest:childP withEvent:event];
        if (fitView) { // 尋找到最合適的view
            return fitView;
        }
    }
    // 循環(huán)結(jié)束,表示沒有比自己更合適的view
    return self;
}

事件傳遞給窗口或控件的后,就調(diào)用hitTest:withEvent:方法尋找更合適的view。所以是,先傳遞事件,再根據(jù)事件在自己身上找更合適的view。不管子控件是不是最合適的view,系統(tǒng)默認(rèn)都要先把事件傳遞給子控件,經(jīng)過子控件調(diào)用自己的hitTest:withEvent:方法驗(yàn)證后才知道有沒有更合適的view。即便父控件是最合適的view了,子控件的hitTest:withEvent:方法還是會調(diào)用,不然怎么知道有沒有更合適的!即,如果確定最終父控件是最合適的view,那么該父控件的子控件的hitTest:withEvent:方法也是會被調(diào)用的。

hitTest:withEvent:方法忽略隱藏(hidden=YES)的視圖,禁止用戶操作(userInteractionEnabled=YES)的視圖,以及alpha級別小于0.01(alpha<0.01)的視圖。
如果一個子視圖的區(qū)域超過父視圖的bound區(qū)域(父視圖的clipsToBounds 屬性為NO,這樣超過父視圖bound區(qū)域的子視圖內(nèi)容也會顯示),那么正常情況下對子視圖在父視圖之外區(qū)域的觸摸操作不會被識別,因?yàn)楦敢晥D的pointInside:withEvent:方法會返回NO,這樣就不會繼續(xù)向下遍歷子視圖了。

** - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event **
該方法判斷觸摸點(diǎn)是否在控件身上, 是則返回YES, 否則返回NO

作用
可以使用以上兩個方法做到:
指鹿為馬(明明點(diǎn)擊的是B視圖, 卻由A視圖來響應(yīng)事件)
穿透某控件點(diǎn)擊被覆蓋的下一層控件
讓父控件frame之外的子控件響應(yīng)觸摸事件(下面實(shí)際應(yīng)用中有具體介紹)


3.事件響應(yīng)

上文介紹了事件的傳遞過程,找到合適的View之后就會調(diào)用該view的touches方法要進(jìn)行響應(yīng)處理具體的事件,找不到最合適的view,就不會調(diào)用touches方法進(jìn)行事件處理。

這里先介紹一下響應(yīng)者鏈條:響應(yīng)者鏈條其實(shí)就是很多響應(yīng)者對象(繼承自UIResponder的對象)一起組合起來的鏈條稱之為響應(yīng)者鏈條

一般默認(rèn)做法是控件將事件順著響應(yīng)者鏈條向上傳遞,將事件交給上一個響應(yīng)者進(jìn)行處理 (即調(diào)用super的touches方法)。

那么如何判斷當(dāng)前響應(yīng)者的上一個響應(yīng)者是誰呢?有以下兩個規(guī)則:

1.判斷當(dāng)前是否是控制器的View,如果是控制器的View,上一個響應(yīng)者就是控制器

2.如果不是控制器的View,上一個響應(yīng)者就是父控件

響應(yīng)過程如下圖:

150807180781053.png

touch響應(yīng):

  • 找到最合適的view會調(diào)用touches方法處理事件
  • touches默認(rèn)做法是把事件順著響應(yīng)者鏈條向上拋
//只要點(diǎn)擊控件,就會調(diào)用touchBegin,如果沒有重寫這個方法,自己處理不了觸摸事件
// 上一個響應(yīng)者可能是父控件
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ 
// 默認(rèn)會把事件傳遞給上一個響應(yīng)者,上一個響應(yīng)者是父控件,交給父控件處理
[super touchesBegan:touches withEvent:event]; 
// 注意不是調(diào)用父控件的touches方法,而是調(diào)用父類的touches方法
// super是父類 superview是父控件 
}

如果控制器也不響應(yīng)響應(yīng)touches方法,就交給UIWindow。如果UIWindow也不響應(yīng),交給UIApplication,如果都不響應(yīng)事件就作廢了。

用具體的例子來看下響應(yīng)過程,例子太簡單就不上demo了,看下截圖(3-2)

c50b227db9693bf974cf4f9e01eb9a98.jpg

(圖3-2)

控件的添加順序:紅1->藍(lán)2->綠2->黃3

屏幕快照 2016-08-28 下午1.56.37.png

綠色View實(shí)現(xiàn)了touch方法,如下


屏幕快照 2016-08-28 下午1.58.41.png

黃色view沒有實(shí)現(xiàn)touch方法,如下

屏幕快照 2016-08-28 下午1.58.52.png

當(dāng)點(diǎn)擊黃色區(qū)域時,由于黃色view沒有實(shí)現(xiàn)touch方法,就順著響應(yīng)鏈找到其父view(綠色View),綠色view實(shí)現(xiàn)了touch方法,便打印了-- touchGreen

最后總結(jié)來說一次完整的觸摸事件的傳遞響應(yīng)過程為:

UIApplication-->UIWindow-->遞歸找到最合適處理的控件-->控件調(diào)用touches方法-->判斷是否實(shí)現(xiàn)touches方法-->沒有實(shí)現(xiàn)默認(rèn)會將事件傳遞給上一個響應(yīng)者-->找到上一個響應(yīng)者-->找不到方法作廢

一句話總結(jié)整個過程是:觸摸或者點(diǎn)擊一個控件,然后這個事件會從上向下(從父->子)找最合適的view處理,找到這個view之后看他能不能處理,能就處理,不能就按照事件響應(yīng)鏈向上(從子->父)傳遞給父控件

事件的傳遞和響應(yīng)的區(qū)別:
事件的傳遞是從上到下(父控件到子控件),事件的響應(yīng)是從下到上(順著響應(yīng)者鏈條向上傳遞:子控件到父控件。

4 實(shí)際項(xiàng)目中的應(yīng)用

  • 情景1: 點(diǎn)擊子控件,讓父控件響應(yīng)事件;(點(diǎn)擊綠色View,紅色View響應(yīng))

分析:可通過兩種方式實(shí)現(xiàn)

(1)因?yàn)閔itTest:withEvent:方法的作用就是控件接收到事件后,判斷自己是否能處理事件,判斷點(diǎn)在不在自己的坐標(biāo)系上,然后返回最合適的view。所以,我們可以在hitTest:withEvent:方法里面強(qiáng)制返回父控件為最合適的view.


 #import "GreenView2.h"

@implementation GreenView2

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    return [self superview]; // return nil; 
    // 此處返回nil也可以。返回nil就相當(dāng)于當(dāng)前的view不是最合適的view
}

@end

(2) 讓誰響應(yīng),就直接重寫誰的touchesBegan: withEvent:方法


#import "RedView1.h"

@implementation RedView1

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
  
  NSLog(@"-- touchRed touchesBegan");
}

@end

  • 情景2:點(diǎn)擊子控件,父控件和子控件都響應(yīng)事件

分析:事件的響應(yīng)是順著響應(yīng)者鏈條向上傳遞的,即從子控件傳遞給父控件,touch方法默認(rèn)不處理事件,而是把事件順著響應(yīng)者鏈條傳遞給上一個響應(yīng)者。這樣我們就可以依托這個原理,讓一個事件多個控件響應(yīng)


#import "GreenView2.h"

@implementation GreenView2

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
  NSLog(@"-- touchGreen");
  [super touchesBegan:touches withEvent:event];
}

參考資料:

史上最詳細(xì)的iOS之事件的傳遞和響應(yīng)機(jī)制

iOS中的事件傳遞和響應(yīng)者鏈條

自實(shí)現(xiàn)UIKit框架

iOS觸摸事件處理詳解

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容