天天看點

iOS Touches事件處理知識總結觸摸事件事件的傳遞&響應手勢

iOS中有三類事件:UIEventTypeTouches觸摸事件、 UIEventTypeMotion “動作”事件,比如搖晃手機裝置、UIEventTypeRemoteControl遠端控制事件。還有一種在iOS9.0之後出現的UIEventTypePresses事件,和觸按實體按鈕有關。

三大類事件分别有一些子事件:

iOS Touches事件處理知識總結觸摸事件事件的傳遞&響應手勢

響應者對象:不過在ios中不是任何對象都可以處理事件,隻有繼承了UIResponder的對象才能接收、處理事件,比如UIApplication、UIViewController、UIView、UIWindow。

觸摸事件

UIView是UIResponder的子類。UIResponder有以下四個方法處理觸摸事件,UIView可以重寫這些方法去自定義事件處理。

一根或者多根手指開始觸摸view(手指按下)
-(void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event

一根或者多根手指在view上移動(随着手指的移動,會持續調用該方法)
-(void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)event

一根或者多根手指離開view(手指擡起)
-(void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event

某個系統事件(例如電話呼入)打斷觸摸過程
-(void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent*)event
           

對于這四個觸摸事件處理方法的參數的說明:

  • 第一個參數:(NSSet)touches*
NSSet和 NSArray類似

但NSSet的差別在:

1.無序不重複(哈希)。與添加順序也沒有關系,也不能通過序号來取出某個元素;即使多次重複添加相同的元素,儲存的都隻有一個。

2.通過 anyObject方法來随機通路單個元素。

3.如果要通路NSSet 中的每個元素,通過for in循環周遊。

4.好處: 效率高。比如重用 Cell 的時候, 從緩存池中随便擷取一個就可以了, 無需按照指定順序來擷取; 當需要把資料存放到一個集合中, 然後判斷集合中是否有某個對象的時候

touches參數中存放的都是UITouch對象。

UITouch

當用一根手指觸摸螢幕時,會建立一個與手指相關聯的UITouch對象。如果兩根手指同時觸摸螢幕,則會調用一次touchesBegan方法,建立兩個UITouch對象(如果不是同時觸摸,調用兩次方法,每次的touches參數都隻有一個UITouch對象)。

判斷是否多點觸摸:NSSet有多少個UITouch對象元素。

UITouch儲存着跟本次手指觸摸相關的資訊,比如觸摸的位置、時間。當手指移動時,系統會更新同一個UITouch對象,使之能夠一直儲存該手指的觸摸位置。當手指離開螢幕時,系統會銷毀相應的UITouch對象。

比如,判斷單擊、輕按兩下或者多擊:tapCount屬性

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    UITouch * touch = touches.anyObject;//擷取觸摸對象    
    NSLog(@"%@",@(touch.tapCount));//短時間内的點選次數
}
           

UITouch常用方法:

-(CGPoint)locationInView:(UIView*)view;

傳回觸摸在參數view上的位置,該位置基于view的坐标系(以view的左上角為原點(0, 0));如果調用時傳入的view參數為nil的話,傳回的是觸摸點在UIWindow的位置

-(CGPoint)previousLocationInView:(UIView*)view;

前一個觸摸點的位置,參數同上

  • 第二個參數(UIEvent)event*

    每産生一個事件,就會産生一個UIEvent對象,UIEvent儲存事件産生的事件和類型。UIEvent還提供了相應的方法可以獲得在某個view上面的UITouch觸摸對象。

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

UIView無法與使用者互動的情況

  1. userInteractionEnabled= NO 如果父視圖不能與使用者互動, 那麼所有子控件也不能與使用者互動
  2. hidden= YES
  3. alpha= 0.0 ~ 0.01
  4. 子視圖的位置超出了父視圖的有效範圍, 那麼子視圖超出部分無法與使用者互動的
  5. UIImageView的userInteractionEnabled預設是NO,是以UIImageView以及它的子控件預設是不能接收觸摸事件的

事件的傳遞&響應

事件傳遞中UIWindow會根據不同的事件類型(3種),用不同的方式尋找initial object。比如Touch Event,UIWindow會首先試着把事件傳遞給事件發生的那個view,就是下文要說的hit-testview。對于Motion和Remote Event,UIWindow會把例如震動或者遠端控制的事件傳遞給目前的firstResponder

尋找響應者Hit-Test&Hit-Test View

iOS Touches事件處理知識總結觸摸事件事件的傳遞&amp;響應手勢

尋找響應消息.png

Hit-Test的目的就是找到手指點選到的最外層的那個view。它進行類似于探測的工作,判斷是否點選在某個視圖上。

Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point.
  • 什麼時候Hit-Test

    與Hit-Test 相關有兩個方法:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event; 
 - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
           

runloop

發生觸摸事件後,系統會将該事件加入到一個由UIApplication管理的事件隊列中;UIApplication會從事件隊列中取出最前面的事件并将其分發處理,通常,先發送事件給應用程式的主視窗UIWindow。UIWindow會調

hitTest:withEvent:

方法,(從後往前周遊subviews數組)找到點選的點在哪個subview,然後繼續調用subView的hitTest:withEvent:方法,直到在視圖繼承樹中找到一個最合适的子視圖來處理觸摸事件,該子視圖即為hit-test view。

這個view和它上面依附的手勢,都會和一個UITouch的對象關聯起來,這個UITouch會作為事件傳遞的參數之一。我們可以看到UITouch.h裡有一個view和gestureRecognizers的屬性,就是Hit-Test view和它的手勢。

  • ** hitTest:withEvent:如何找到最合适的控件來處理事件**

    1.判斷自己是否能接收觸摸事件(能否與使用者互動)

    2.觸摸點是否在自己身上? 調用

    pointInside:withEvent:

    3.從後往前周遊子控件數組,重複前面的兩個步驟 (從後往前:按照addsubview的順序,越晚添加的越先通路)

    4.如果沒有符合條件的子控件,那麼就自己最适合處理

    找到合适的視圖控件後,就會調用視圖控件的touches方法來作具體的事件處理。

要攔截事件傳遞,可以使用

pointInside:withEvent:

方法,在實作裡面直接

return NO;

即可,那麼hitTest:withEvent:方法傳回nil。又或者在

hitTest:withEvent:

直接return self;不傳遞給子視圖。

摘自網絡:hitTest:方法内部的參考實作
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    NSLog(@"%@----hitTest:", [self class]);
    // 如果控件不允許與使用者互動那麼傳回 nil
    if (self.userInteractionEnabled == NO || self.alpha <=  || self.hidden == YES) {
        return nil;
    }
    // 如果這個點不在目前控件中那麼傳回 nil
    if (![self pointInside:point withEvent:event]) {
        return nil;
    }
    // 從後向前周遊每一個子控件
    for (int i = (int)self.subviews.count - ; i >= ; i--) {
        // 擷取一個子控件
        UIView *lastVw = self.subviews[i];
        // 把目前觸摸點坐标轉換為相對于子控件的觸摸點坐标
        CGPoint subPoint = [self convertPoint:point toView:lastVw];
        // 判斷是否在子控件中找到了更合适的子控件
        UIView *nextVw = [lastVw hitTest:subPoint withEvent:event];
        // 如果找到了傳回
        if (nextVw) {
            return nextVw;
        }
    }
    // 如果以上都沒有執行 return, 那麼傳回自己(表示子控件中沒有"更合适"的了)
    return  self;
}
           

要擴大view的點選區域,比如要擴大按鈕的點選區域(按鈕四周之外的10pt也可以響應按鈕的事件),可以怎麼做呢?或許重寫hittest:withEvent:是個好辦法,hitest就是傳回可以響應事件的view,在button的子類裡面重寫它,判斷如果point在button的frame之外的10pt内,就傳回button自己。

事件響應

什麼是第一響應者?簡單的講,第一響應者是一個UIWindow對象接收到一個事件後,第一個來響應的該事件的對象。

如果hit-test視圖不處理收到的事件消息,UIKit則将事件轉發到響應者鍊中的下一個響應者,看其是否能對該消息進行處理。

響應鍊:

iOS Touches事件處理知識總結觸摸事件事件的傳遞&amp;響應手勢

所有的視圖按照樹狀層次結構組織,每個view都有自己的superView,包括vc的self.view:

1.當一個view被添加到superView上的時候,它的nextResponder就會被指向它的superView;

2.當vc被初始化的時候,self.view(topmost view)的nextResponder會被指向所在的controller;

(概括前兩者就是:如果目前這個view是控制器的self.view,那麼控制器就是上一個響應者 如果目前這個view不是控制器的view,那麼父控件就是上一個響應者)

3.vc的nextResponder會被指向self.view的superView。

4.最頂級的vc的nextResponder指向UIWindow。

5.UIWindow的nextResponder指向UIApplication

這就形成了響應鍊。并沒有一個對象來專門存儲這樣的一條鍊,而是通過UIResponder的串連起來的。

對于touches方法的描述:

The default implementation of this method does nothing. However immediate UIKit subclasses of UIResponder, particularly UIView, forward the message up the responder chain. To forward the message to the next responder, send the message to super (the superclass implementation); do not send the message directly to the next responder. For example,

[super touchesBegan:touches withEvent:event];

If you override this method without calling super (a common use pattern), you must also override the other methods for handling touch events, if only as stub (empty) implementations.

touches方法實際上什麼事都沒做,UIView繼承了它進行重寫,就是把事件傳遞給nextResponder,相當于

[self.nextResponder touchesBegan:touches withEvent:event]

。是以當一個view沒有重寫touch事件,那麼這個事件就會一直傳遞下去,直到UIApplication。如果重寫了touch方法,這個view響應了事件之後,事件就被攔截了,它的nextResponder不會收到這個事件。這個時候如果想事件繼續傳遞下去,可以調用

[super touchesBegan:touches withEvent:event]

,不建議直接調

[self.nextResponder touchesBegan:touches withEvent:event]

調用

[super touches...]

(實際運作打斷點檢視:之後父類響應touches,一直傳遞下去,最後UIResponse來響應touches,然後再由下一個響應者響應touches;前提是它們都重寫了touches方法,以及調用

[super touches...]

附上一個響應鍊傳送門

不過UIScrollview的touches響應又是另一回事。

響應鍊事件傳遞(向上傳遞):

1.如果view的控制器存在,就傳遞給控制器;如果控制器不存在,則将其傳遞給它的父視圖

2.在視圖層次結構的最頂級視圖,如果也不能處理收到的事件或消息,則其将事件或消息傳遞給window對象進行處理

3.如果window對象也不處理,則其将事件或消息傳遞給UIApplication對象

4.如果UIApplication也不能處理該事件或消息,則将其丢棄

總結:

監聽事件的基本流程:

1> 當應用程式啟動以後建立 UIApplication 對象

2> 然後啟動“消息循環”監聽所有的事件

3> 當使用者觸摸螢幕的時候, "消息循環"監聽到這個觸摸事件

4> "消息循環" 首先把監聽到的觸摸事件傳遞了 UIApplication 對象

5> UIApplication 對象再傳遞給 UIWindow 對象

6> UIWindow 對象再傳遞給 UIWindow 的根控制器(rootViewController)

7> 控制器再傳遞給控制器所管理的 view

8> 控制器所管理的 View 在其内部搜尋看本次觸摸的點在哪個控件的範圍内

9> 找到某個控件以後(調用這個控件的 touchesXxx 方法), 再一次向上傳回, 最終傳回給"消息循環"

10> "消息循環"知道哪個按鈕被點選後, 在搜尋這個按鈕是否注冊了對應的事件, 如果注冊了, 那麼就調用這個"事件處理"程式。(一般就是執行控制器中的"事件處理"方法)

手勢

手勢識别和觸摸事件是兩個獨立的事,不要混淆。

通過touches方法監聽view觸摸事件,有很明顯的幾個缺點:必須得自定義view、由于是在view内部的touches方法中監聽觸摸事件,是以預設情況下,無法讓其他外界對象監聽view的觸摸事件、不容易區分使用者的具體手勢行為。

iOS3.2之後, 把觸摸事件做了封裝, 對常用的手勢進行了處理, 封裝了6種常見的手勢

UITapGestureRecognizer(敲擊)

UILongPressGestureRecognizer(長按)

UISwipeGestureRecognizer(輕掃)

UIRotationGestureRecognizer(旋轉)

UIPinchGestureRecognizer(捏合,用于縮放)

UIPanGestureRecognizer(拖拽)

下面談幾個在項目中遇到的問題:

關于手勢和touch的互相影響

tap的cancelsTouchesInView方法

“A Boolean value affecting whether touches are delivered to a view when a gesture is recognized.”也就是說,可以通過設定這個布爾值,來設定手勢被識别時觸摸事件是否被傳送到視圖。

當值為YES(預設值)的時候,系統會識别手勢,并取消觸摸事件;為NO的時候,手勢識别之後,系統将觸發觸摸事件。

  • 把手勢添加到btn上
- (void)viewDidLoad { 
   UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(, , , )];
   button.backgroundColor = [UIColor redColor];
   [self.view addSubview:button];
   [button addTarget:self action:@selector(btnAction:) forControlEvents:UIControlEventTouchUpInside];
   UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(tapAction:)];
   tap.cancelsTouchesInView = NO;
   [button addGestureRecognizer:tap];
}

- (void)tapAction:(UITapGestureRecognizer *)sender {
   NSLog(@"tapAction");
}

- (void)btnAction:(UIButton *)btn {
   NSLog(@"btnAction");
}
           

當cancelsTouchesInView為NO的時候,點選按鈕,會先後觸發“tapAction:”和“btnAction:”方法;而當cancelsTouchesInView為YES的時候,隻會觸發“tapAction:”方法。

  • 把手勢添加到btn的父view上即

    [self.view addGestureRecognizer:tap];

    cancelsTouchesInView=NO,點選按鈕,會先後觸發“tapAction:”和“btnAction:”方法;cancelsTouchesInView=YES,隻會觸發按鈕方法不會觸發手勢。
  • 但如果不是btn而是别的控件,把手勢添加到控件的父view上

    項目中用到的是collectionView,cancelsTouchesInView=NO,點選collectionViewCell,先後觸發手勢和Cell,cancelsTouchesInView=YES隻會觸發手勢。

對于UIButton,UISlider等繼承自UIControl的控件,都會先響應觸摸事件,進而阻止手勢事件。手勢可以了解為是“特殊的層”。對于TableView,CollectionView這種弱點選事件,系統優先響應手勢,如果要響應Cell點選事件就要實作代理方法

實作手勢的代理方法對手勢進行攔截。

called before touchesBegan:withEvent: is called on the gesture recognizer for a new touch. return NO to prevent the gesture recognizer from seeing this touch

判斷,手勢的觸擊方法是否在控件區域,如果是,則傳回NO,禁用手勢。否則傳回YES.

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch{
//    NSLog(@"%d",[touch.view isKindOfClass:[UIButton class]]);
    if ([touch.view.superview isKindOfClass:[UICollectionViewCell class]]) {//如果點選的是UICollectionViewCell,touch.view是collectionViewCell的contentView,contentView的父view才是collectionCell
        return NO;
    }else if ([touch.view isKindOfClass:[UIButton class]]) {
        return NO;
    }
    return YES;
}
           

其他:

項目上沒遇到且目前還沒有深入了解,先po連結友善以後查:

丢一個傳送門講Gesture Recognizers與事件分發路徑的關系:

http://blog.csdn.net/chun799/article/details/8194893

手勢的3個混淆屬性 cancelsTouchesInView/delaysTouchesBegan/delaysTouchesEnded: http://www.mamicode.com/info-detail-868542.html

補充

對于UIControl類型的控件,一個給定的事件,UIControl會調用

- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event

來将action message轉發到UIApplication對象,再由UIApplication對象調用其

sendAction:to:fromSender:forEvent:

方法來将消息分發到指定的target上,如果沒有指定target(即nil),則會将事件分發到響應鍊上第一個想處理消息的對象上。而如果UIControl子類想監控或修改這種行為的話,則可以重寫```sendAction: to: forEvent:``。

将外部添加的Target-Action放在控件内部來處理事件,實作如下:

// Btn.m
- (void)sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
  // 将事件傳遞到對象本身來處理
    [super sendAction:@selector(handleAction:) to:self forEvent:event];
}
 
- (void)handleAction:(id)sender { 
    NSLog(@"handle Action");
}
 
// ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad]; 
    self.view.backgroundColor = [UIColor whiteColor]; 
    Btn *btn = [[Btn alloc]initWithFrame:CGRectMake(, , , )];
    btn.backgroundColor = [UIColor yellowColor];
    [btn addTarget:self action:@selector(btnclick:) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:btn];
}
- (IBAction)btnclick:(id)sender {
    NSLog(@"click");
}
           

最後處理事件的Selector是Btn的handleAction:方法,而不是ViewController的btnclick:方法。

另外,sendAction:to:forEvent:實際上也被UIControl的另一個方法所調用,即sendActionsForControlEvents:。這個方法的作用是發送與指定類型相關的所有行為消息。我們可以在任意位置(包括控件内部和外部)調用控件的這個方法來發送參數controlEvents指定的消息。在我們的示例中,在ViewController.m中作了如下測試:

- (void)viewDidLoad {
    // ...
    [btn addTarget:self action:@selector(btnclick:) forControlEvents:UIControlEventTouchUpInside];
    [btn sendActionsForControlEvents:UIControlEventTouchUpInside];
}
           

沒有點選btn,觸發了UIControlEventTouchUpInside事件,并執行handleAction:方法。