iOS開發中的事件處理

iOS開發中的事件處理

理論非原創,是對網上資料的整理以及Demo驗證

一. UIResponder

1.1 事件處理簡介

iOS中的事件:
在用戶使用app的過程中會產生各種各樣的事件

0-2.png

在iOS中不是任何對象都能處理事件,只有繼承了UIResponder的對象才能接收并處理事件。我們稱之為“響應者對象”;
我們熟悉的 UIApplication、 UIViewController 和所有繼承自UIView的UIKit類都直接或間接的繼承自UIResponder,所以它們的實例都是可以構成響應者鏈的響應者對象,都能夠接收并處理事件。需要注意的是Core Animation layers 不是responders

第一響應者是指定的第一個接收事件的對象,比如一個View對象是第一響應者.一個對象可以通過以下方法成為第一響應者:

  1. 重寫canBecomeFirstResponder 方法,返回yes
  2. 接收到一個 becomeFirstResponder的menssage,如果需要,也可以自己給自己發送message成為第一響應者.

注意:確保你的app指定一個對象成為第一響應者之前,這個對象圖像已經被繪畫出來.
舉個例子:通常你在重寫 viewDidAppear方法內,執行becomeFirstResponder方法.但是如果在viewWillAppear:中 指定 第一響應者,你的對象的圖像還沒有被建立好,所以becomeFirstResponder會返回NO

1.2 UIResponder

UIResponder是所有響應對象的基類,UIResponder內部提供了以下方法來處理事件:
觸摸事件

- (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;

遠程控制事件

- (void)remoteControlReceivedWithEvent:(UIEvent *)event;

UIView的觸摸事件處理
UIView是UIResponder的子類,可以實現下列4個方法處理不同的觸摸事件

一根或者多根手指開始觸摸view,系統會自動調用view的下面方法

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event

一根或者多根手指在view上移動,系統會自動調用view的下面方法(隨著手指的移動,會持續調用該方法)

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event

一根或者多根手指離開view,系統會自動調用view的下面方法

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event

觸摸結束前,某個系統事件(例如電話呼入)會打斷觸摸過程,系統會自動調用view的下面方法

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event

提示:touches中存放的都是UITouch對象

1.3 UITouch:

當用戶用一根手指觸摸屏幕時,會創建一個與手指相關聯的UITouch對象,一根手指對應一個UITouch對象

UITouch的作用:

保存著跟手指相關的信息,比如觸摸的位置、時間、階段
當手指移動時,系統會更新同一個UITouch對象,使之能夠一直保存該手指的觸摸位置。
當手指離開屏幕時,系統會銷毀相應的UITouch對象

UITouch的屬性:

觸摸產生時所處的窗口
@property(nonatomic,readonly,retain) UIWindow *window;

觸摸產生時所處的視圖
@property(nonatomic,readonly,retain) UIView *view;

短時間內點按屏幕的次數,可以根據tapCount判斷單擊、雙擊或更多的點擊

@property(nonatomic,readonly) NSUInteger tapCount;

記錄了觸摸事件產生或變化時的時間,單位是秒
@property(nonatomic,readonly) NSTimeInterval timestamp;

當前觸摸事件所處的狀態
@property(nonatomic,readonly) UITouchPhase phase;

UITouch的方法:
- (CGPoint)locationInView:(UIView *)view; 

返回值表示觸摸在view上的位置 
這里返回的位置是針對view的坐標系的(以view的左上角為原點(0, 0)) 
調用時傳入的view參數為nil的話,返回的是觸摸點在UIWindow的位置
- (CGPoint)previousLocationInView:(UIView *)view; 
該方法記錄了前一個觸摸點的位置

UITouch的phase有什么用?
觸摸事件在屏幕上有一個周期,即觸摸開始、觸摸點移動、觸摸結束,還有中途取消。而通過phase可以查看當前觸摸事件在一個周期中所處的狀態。phase是UITouchPhase類型的,這是一個枚舉配型,包含了

 UITouchPhaseBegan(觸摸開始)
 UITouchPhaseMoved(接觸點移動)
 UITouchPhaseStationary(接觸點無移動)
 UITouchPhaseEnded(觸摸結束)
 UITouchPhaseCancelled(觸摸取消)

1.4 UIEvent

UIEvent:每產生一個事件,就會產生一個UIEvent對象
UIEvent:稱為事件對象,記錄事件產生的時刻和類型
常見屬性

事件類型

@property(nonatomic,readonly) UIEventType type;
@property(nonatomic,readonly) UIEventSubtype subtype;
事件產生的時間

@property(nonatomic,readonly) NSTimeInterval timestamp;

UIEvent還提供了相應的方法可以獲得在某個view上面的觸摸對象(UITouch)

Touches和event參數:
一次完整的觸摸過程,會經歷3個狀態:

觸摸開始:- (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

4個觸摸事件處理方法中,都有NSSet *touches和UIEvent *event兩個參數

一次完整的觸摸過程中,只會產生一個事件對象,4個觸摸方法都是同一個event參數

如果兩根手指同時觸摸一個view,那么view只會調用一次touchesBegan:withEvent:方法,touches參數中裝著2個UITouch對象
如果這兩根手指一前一后分開觸摸同一個view,那么view會分別調用2次touchesBegan:withEvent:方法,并且每次調用時的touches參數中只包含一個UITouch對象

根據touches中UITouch的個數可以判斷出是單點觸摸還是多點觸摸

疑問:默認觸摸方法NSSet里面只能獲得一個UITouch對象,為什么?

UIView默認不支持多點觸控。也就是說不支持多只手指同時觸摸。

如何讓視圖接收多點觸摸?

需要設置它的multipleTouchEnabled屬性為YES,默認狀態下這個屬性值為NO,即視圖默認不接收多點觸摸。。

如何判斷用戶當前是雙擊還是單擊?

根據UITouch的tapCount屬性的值。tapCount表示短時間內輕擊屏幕的次數。因此可以根據tapCount判斷單擊、雙擊或更多的輕擊。

根據tapCount點擊的次數來設置當前視圖的背景色(雙擊改變背景顏色)
輕擊操作很容易引起歧義,比如當用戶點了一次之后,并不知道用戶是想單擊還是只是雙擊的一部分,或者點了兩次之后并不知道用戶是想雙擊還是繼續點擊。為了解決這個問題,一般可以使用“延遲調用”函數。

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    UITouch *touch = [touches anyObject];
    if(touch.tapCount != 2){ // 如果不是雙擊
        [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(setBackgroundColor:)  object:[UIColor orangeColor]];
    } else { // 延時1執行改變背景的方法
        [self performSelector:@selector(setBackgroundColor:) withObject:[UIColor orangeColor] afterDelay:1.0];
    }
}

提示:iPhone開發中,要避免使用雙擊事件!

NSObject類的cancelPreviousPerformRequestWithTarget:selector:object方法取消指定對象的方法調用。
官方對該方法解釋:
Cancels perform requests previously registered with performSelector:withObject:afterDelay:.
All perform requests are canceled that have the same target as aTarget, argument as anArgument, and selector as aSelector.
如果是帶參數,那取消時的參數也要一致,否則不能取消成功

細節

檢測tapCount可以放在touchesBegan也可以touchesEnded,不過一般后者更準確,因為touchesEnded可以保證所有的手指都已經離開屏幕,這樣就不會把輕擊動作和按下拖動等動作混淆。

不管是一個手指還是多個手指,輕擊操作都會使每個觸摸對象的tapCount加1,因此可以直接調用touches的anyObject方法來獲取任意一個觸摸對象然后判斷其tapCount的值即可。

二. 事件傳遞,響應者鏈條

簡介:

發生觸摸事件后,系統會將該事件加入到一個由UIApplication管理的事件 隊列中,UIApplication會從事件隊列中取出最前面的事件,并將事件分發下去以便處理,通常,先發送事件給應用程序的主窗口(keyWindow)

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

不接收用戶交互
userInteractionEnabled = NO

隱藏
hidden = YES

透明
alpha = 0.0 ~ 0.01

提示:UIImageView的userInteractionEnabled默認就是NO,因此UIImageView以及它的子控件默認是不能接收觸摸事件的

2.1 事件傳遞的詳細過程:

概念: iOS系統檢測到手指觸摸(Touch)操作時會將其打包成一個UIEvent對象,并放入當前活動Application的事件隊列,單例的UIApplication會從事件隊列中取出觸摸事件并傳遞給單例的UIWindow來處理,UIWindow對象首先會使用hitTest:withEvent:方法尋找此次Touch操作初始點所在的視圖(View),即需要將觸摸事件傳遞給其處理的視圖,這個過程稱之為hit-test view。

UIWindow實例對象會首先在它的內容視圖上調用hitTest:withEvent:,此方法會在其視圖層級結構中的每個視圖上調用pointInside:withEvent:(該方法用來判斷點擊事件發生的位置是否處于當前視圖范圍內,以確定用戶是不是點擊了當前視圖),如果pointInside:withEvent:返回YES,則繼續逐級調用,直到找到touch操作發生的位置,這個視圖也就是要找的hit-test view。

**hitTest:withEvent:方法的處理流程如下: **

  • 首先調用當前視圖的pointInside:withEvent:方法判斷觸摸點是否在當前視圖內;
  • 若返回NO,則hitTest:withEvent:返回nil;
  • 若返回YES,則向當前視圖的所有子視圖(subviews)發送hitTest:withEvent:消息,所有子視圖的遍歷順序是從最頂層視圖(top)一直到到最底層視圖(bottom),即從subviews數組的末尾向前遍歷,直到有子視圖返回非空對象或者全部子視圖遍歷完畢;
  • 若第一次有子視圖返回非空對象,則hitTest:withEvent:方法返回此對象,處理結束;
  • 如所有子視圖都返回NO,則hitTest:withEvent:方法返回自身(self)。


    1-1-0-1.png

假如用戶點擊藍色3view
下面結合上圖介紹hit-test view的流程:

  1. 白1是UIWindow的根視圖,因此,UIWindwo對象會首相對白1進行hit-test;

  2. 顯然用戶點擊的范圍是在白1的范圍內,因此, pointInside:withEvent:返回了YES,這時會繼續檢查白1的子視圖;

  3. 這時候會有兩個分支,綠色2紅色2

    點擊的范圍不再 綠2 內,因此 綠2 分支的 pointInside:withEvent:返回NO,對應的hitTest:withEvent:返回nil;

    點擊的范圍在 紅2 內,即 紅2 的 pointInside:withEvent:返回YES;

  4. 這時候有 藍3綠3 兩個分支:

    點擊的范圍不再 綠3 內,因此 綠3 的 pointInside:withEvent:返回NO,對應的hitTest:withEvent:返回nil;

    點擊的范圍在 藍3 內,即 藍3 的 pointInside:withEvent:返回YES,而由于再對 藍3 的子視圖進行hit-test時全部返回了nil(也可以理解由于藍3沒有子視圖),因此,藍3的 hitTest:withEvent:會將 藍3 返回,再往回回溯,就是 紅2 的 hitTest:withEvent:返回 藍3 --->> 白1 的hitTest:withEvent:返回 藍3

  5. 藍3是包含touch的所有view中層級最小的,所以他就是hit-test view

  6. 至此,本次點擊事件的第一響應者(藍3)就通過響應者鏈的事件分發邏輯成功的找到了。

這個處理流程有點類似二分搜索的思想,這樣能以最快的速度,最精確地定位出能響應觸摸事件的UIView。

本次事件的傳遞順序: UIApplication --> UIWindow -->白色BASEView1 -->紅色2 -->藍色3
上面找到了事件的第一響應者,接下來就該沿著尋找第一響應者的相反順序來處理這個事件,如果UIWindow單例和UIApplication都無法處理這一事件,則該事件會被丟棄。

說明:
  1. 如果最終 hit-test沒有找到第一響應者,或者第一響應者沒有處理該事件,則該事件會沿著響應者鏈向上回溯,如果UIWindow實例和UIApplication實例都不能處理該事件,則該事件會被丟棄;
  2. hitTest:withEvent:方法將會忽略隱藏(hidden=YES)的視圖,禁止用戶操作(userInteractionEnabled=YES)的視圖,以及alpha級別小于0.01(alpha<0.01)的視圖。如果一個子視圖的區域超過父視圖的bound區域(父視圖的clipsToBounds 屬性為NO,這樣超過父視圖bound區域的子視圖內容也會顯示),那么正常情況下對子視圖在父視圖之外區域的觸摸操作不會被識別,因為父視圖的pointInside:withEvent:方法會返回NO,這樣就不會繼續向下遍歷子視圖了。當然,也可以重寫pointInside:withEvent:方法來處理這種情況。
  3. 觸摸事件的傳遞是從父控件傳遞到子控件
  4. <font color=#FF0000>如果父控件不能接收觸摸事件,那么子控件就不能接收到觸摸事件</font>
  5. 如上圖情況,如果想在點擊黃4的時候,讓藍3成為第一響應者,而讓黃4不響應,可以有以下幾種方法:
    5.1 設置黃4隱藏(hidden=YES),禁止用戶操作(userInteractionEnabled=YES),alpha級別小于0.01(alpha<0.01)
    5.2 重寫黃4的pointInside:withEvent:方法, 返回NO
    5.3 重寫藍3的 hitTest:withEvent:方法 如果得到的view是黃4,返回self
示例:

點擊?綠色view:
UIApplication --> UIWindow -->白色BASEView -->綠色View


1-1-1-1.png

點擊半透明綠色view:


1-1-8-1.png

UIApplication --> UIWindow -->白色BASEView -->紅色 -->半透明綠色

點擊黃色View:


1-1-5-1.png

UIApplication --> UIWindow -->白色BASEView -->紅色 -->藍色 -->黃色

主窗口會在視圖層次結構中找到一個最合適的視圖來處理觸摸事件,但是這僅僅是整個事件處理過程的第一步 找到合適的視圖控件后,就會調用視圖控件的touches方法來作具體的事件處理
touchesBegan…
touchesMoved…
touchedEnded…
這些touches方法的默認做法是將事件順著響應者鏈條向上傳遞,將事件交給上一個響應者進行處理

如何找到最合適的控件來處理事件?
  1. 自己是否能接收觸摸事件?-->否-->事件傳遞到此結束
  2. 觸摸點是否在自己身上?-->否-->事件傳遞到此結束
  3. 從后往前遍歷子控件,重復前面兩個步驟
  4. 如果沒有符合條件的子控件,那么就自己最合適

2.2 定制hitTest:withEvent:方法

如果父視圖需要對對哪個子視圖可以響應觸摸事件做特殊控制,則可以重寫hitTest:withEvent:或pointInside:withEvent:方法。
這里有幾個例子:


2-2.png

在此例子中button,scrollview同為topView的子視圖,但scrollview覆蓋在button之上,這樣在在button上的觸摸操作返回的hit-test view為scrollview,button無法響應,可以修改topView的hitTest:withEvent:方法如下:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    UIView *result = [super hitTest:point withEvent:event];
    
    CGPoint buttonPoint = [_underButton convertPoint:point fromView:self];
    if ([_underButton pointInside:buttonPoint withEvent:event]) {
        return _underButton;
    }
    
    return result;
}

2.3 響應者鏈條

響應者:繼承了UIResponder的對象就是響應者

響應者鏈條:

  1. 由多個響應者對象連接起來的鏈條叫做響應者鏈條
  2. 什么是上一個響應者?
    2.1. 如果當前這個view是控制器的view,控制器就是上一個響應者
    2.2. 如果當前這個view不是控制器的view,那么父控件就是上一個響應者
  3. 利用響應者鏈條可以讓多個控件處理同一個 "觸摸事件"
    3.1. 在最后適合的控件里調用super的touchesBegan方法,這樣就將事件傳給上一個響應,上一個響應者也可以處理事件了

響應者鏈條,總的來說是:

  1. 它是一種事件處理機制,由多個響應者對象連接起來的鏈條,使得事件可以沿著這些對象進行傳遞。
  2. 如果一個響應者對象不能處理某個事件或動作消息,則將該事件消息重新發送給鏈中的上一個響應者。
  3. 消息沿著響應者鏈向上、向更高級別的對象傳遞,直到最終被處理。

事件的完整處理過程:

  1. 先將事件對象由上往下傳遞(由父控制傳給子控件),找到最適合的控件來處理

  2. 調用最合適的控件的touches...方法

  3. 如果調用了[super touch…],就會將事件順著響應都鏈往上傳遞,傳遞給上一個響應者

  4. 接著上一個響應者就會調用的touches...方法

  5. 如果沒有找到最適合的控件來處理事件,則將事件傳回來窗口,窗口不處理事件,將事件傳給UIApplication

  6. 如果Applicatoin不能處理事件,則將其丟棄

響應鏈中事件的傳遞遵循特殊的派送軌道

一個初始對象為hit-test view 或者first responder,如果這個對象不處理事件,UIKit會將這個事件傳遞給響應鏈中的next responder,每一個responder決定是自己處理該事件,還是將事件傳遞給自己的next responder.這個過程一直持續,直到事件被處理或者沒有更多的responder了.

當iOS 檢查出一個事件并查到事件的第一響應者(具有代表性的是個view),響應者鏈隊列開始

響應者鏈條示意圖:

1-2.png

左邊的app,事件遵循以下流程:

  1. initial view 嘗試處理事件或信息.如果它不能處理這個事件,將事件傳遞給它的superview,因為它不是它的view controller里view層級的最高層.
  2. superview 嘗試處理事件或信息.如果它不能處理這個事件,將事件傳遞給它的superview,因為它仍舊不是它的view controller里view層級的最高層.
  3. topmost view 是view controller的view層級的最高層,它嘗試處理事件或信息.如果它不能處理這個事件,將事件傳遞給它的view controller.
  4. view controller 嘗試處理事件或信息.如果它不能處理這個事件,將事件傳遞給window.
  5. 如果window object 不能處理事件,它直接把事件傳遞給singleton app object
  6. 如果app object 不能處理事件,它會丟棄這個事件

右邊的app遵循略微不同的路線,但是所有的事件event傳遞遵循下邊的啟發法:

  1. A view 將他的事件在它的view controller的 view層級 向上傳遞,知道到達topmost view最高層view
  2. topmost view 把事件傳遞給它的 view controller
  3. view controller 把事件傳遞給 topmost view的superview
    重復1-3步驟,直到事件傳遞給root view controller
  4. root view controller把事件傳遞給 window object
  5. window 把事件傳遞給 app object
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容