天天看點

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

概覽

iPhone的成功很大一部分得益于它多點觸摸的強大功能,喬布斯讓人們認識到手機其實是可以不用按鍵和手寫筆直接操作的,這不愧為一項偉大的設計。今天我們就針對iOS的觸摸事件(手勢操作)、運動事件、遠端控制事件等展開學習:

iOS事件簡介

觸摸事件

手勢識别

運動事件

遠端控制事件

iOS事件

在iOS中事件分為三類:

觸摸事件:通過觸摸、手勢進行觸發(例如手指點選、縮放) 

運動事件:通過加速器進行觸發(例如手機晃動) 

遠端控制事件:通過其他遠端裝置觸發(例如耳機控制按鈕)

下圖是蘋果官方對于這三種事件的形象描述:

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

在iOS中并不是所有的類都能處理接收并事件,隻有繼承自UIResponder類的對象才能處理事件(如我們常用的UIView、UIViewController、UIApplication都繼承自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 NS_AVAILABLE_IOS(3_0);

運動開始時執行;

- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event NS_AVAILABLE_IOS(3_0);

運動結束後執行;

- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event NS_AVAILABLE_IOS(3_0);

運動被意外取消時執行;

- (void)remoteControlReceivedWithEvent:(UIEvent *)event NS_AVAILABLE_IOS(4_0);

接收到遠端控制消息時執行;

三類事件中觸摸事件在iOS中是最常用的事件,這裡我們首先介紹觸摸事件。

在下面的例子中定義一個KCImage,它繼承于UIView,在KCImage中指定一個圖檔作為背景。定義一個視圖控制器KCTouchEventViewController,并且在其中聲明一個KCImage變量,添加到視圖控制器中。既然UIView和UIViewController都繼承于UIResponder,那麼也就就意味着所有的UIKit控件和視圖控制器均能接收觸摸事件。首先我們在KCTouchEventViewController中添加觸摸事件,并利用觸摸移動事件來移動KCImage,具體代碼如下:

現在運作程式:

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

上面示例中我們用到了UITouch類,當執行觸摸事件時會将這個對象傳入。在這個對象中包含了觸摸的所有資訊:

window:觸摸時所在的視窗 

view:觸摸時所在視圖 

tapCount:短時間内點選的次數 

timestamp:觸摸産生或變化的時間戳 

phase:觸摸周期内的各個狀态 

locationInView:方法:取得在指定視圖的位置 

previousLocationInView:方法:取得移動的前一個位置

從上面運作效果可以看到無論是選擇KCImage拖動還是在界面其他任意位置拖動都能達到移動圖檔的效果。既然KCImage是UIView當然在KCImage中也能觸發相應的觸摸事件,假設在KCImage中定義三個對應的事件:

此時如果運作程式會發現如果拖動KCImage無法達到預期的效果,但是可以發現此時會調用KCImage的觸摸事件而不會調用KCTouchEventViewController中的觸摸事件。如果直接拖拽其他空白位置則可以正常拖拽,而且從輸出資訊可以發現此時調用的是視圖控制器的觸摸事件。這是為什麼呢?要解答這個問題我們需要了解iOS中事件的處理機制。

在iOS中發生觸摸後,事件會加入到UIApplication事件隊列(在這個系列關于iOS開發的第一篇文章中我們分析iOS程式原理的時候就說過程式運作後UIApplication會循環監聽使用者操作),UIApplication會從事件隊列取出最前面的事件并分發處理,通常先分發給應用程式主視窗,主視窗會調用hitTest:withEvent:方法(假設稱為方法A,注意這是UIView的方法),查找合适的事件觸發視圖(這裡通常稱為“hit-test view”):

在頂級視圖(key window的視圖)上調用pointInside:withEvent:方法判斷觸摸點是否在目前視圖内;

如果傳回NO,那麼A傳回nil;

如果傳回YES,那麼它會向目前視圖的所有子視圖(key window的子視圖)發送hitTest:withEvent:消息,周遊所有子視圖的順序是從subviews數組的末尾向前周遊(從界面最上方開始向下周遊)。

如果有subview的hitTest:withEvent:傳回非空對象則A傳回此對象,處理結束(注意這個過程,子視圖也是根據pointInside:withEvent:的傳回值來确定是傳回空還是目前子視圖對象的。并且這個過程中如果子視圖的hidden=YES、userInteractionEnabled=NO或者alpha小于0.1都會并忽略);

如果所有subview周遊結束仍然沒有傳回非空對象,則A傳回頂級視圖;

上面的步驟就是點選檢測的過程,其實就是查找事件觸發者的過程。觸摸對象并非就是事件的響應者(例如上面第一個例子中沒有重寫KCImage觸摸事件時,KCImge作為觸摸對象,但是事件響應者卻是UIViewController),檢測到了觸摸的對象之後,事件到底是如何響應呢?這個過程就必須引入一個新的概念“響應者鍊”。

什麼是響應者鍊呢?我們知道在iOS程式中無論是最後面的UIWindow還是最前面的某個按鈕,它們的擺放是有前後關系的,一個控件可以放到另一個控件上面或下面,那麼使用者點選某個控件時是觸發上面的控件還是下面的控件呢,這種先後關系構成一個鍊條就叫“響應者鍊”。在iOS中響應者鍊的關系可以用下圖表示:

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

當一個事件發生後首先看initial view能否處理這個事件,如果不能則會将事件傳遞給其上級視圖(inital view的superView);如果上級視圖仍然無法處理則會繼續往上傳遞;一直傳遞到視圖控制器view controller,首先判斷視圖控制器的根視圖view是否能處理此事件;如果不能則接着判斷該視圖控制器能否處理此事件,如果還是不能則繼續向上傳遞;(對于第二個圖視圖控制器本身還在另一個視圖控制器中,則繼續交給父視圖控制器的根視圖,如果根視圖不能處理則交給父視圖控制器處理);一直到window,如果window還是不能處理此事件則繼續交給application(UIApplication單例對象)處理,如果最後application還是不能處理此事件則将其丢棄。

這個過程大家了解起來并不難,關鍵問題是在這個過程中各個對象如何知道自己能不能處理該事件呢?對于繼承UIResponder的對象,其不能處理事件有幾個條件:

userInteractionEnabled=NO 

hidden=YES 

alpha=0~0.01 

沒有實作開始觸摸方法(注意是touchesBegan:withEvent:而不是移動和結束觸摸事件)

當然前三點都是針對UIView控件或其子控件而言的,第四點可以針對UIView也可以針對視圖控制器等其他UIResponder子類。對于第四種情況這裡再次強調是對象中重寫了開始觸摸方法,則會處理這個事件,如果僅僅寫了移動、停止觸摸或取消觸摸事件(或者這三個事件都重寫了)沒有寫開始觸摸事件,則此事件該對象不會進行處理。

相信到了這裡大家對于上面點選圖檔為什麼不能拖拽已經很明确了。事實上通過前面的解釋大家應該可以猜到即使KCImage實作了開始拖拽方法,如果在KCTouchEventViewController中設定KCImage對象的userInteractionEnabled為NO也是可以拖拽的。

注意:上面提到hitTest:withEvent:可以指定觸發事件的視圖,這裡就不再舉例說明,這個方法重寫情況比較少,一般用于自定義手勢,有興趣的童鞋可以通路:Event Delivery: The Responder Chain。

通過前面的内容我們可以看到觸摸事件使用起來比較容易,但是對于多個手指觸摸并進行不同的變化操作就要複雜的多了。例如說如果兩個手指捏合,我們雖然在觸摸開始、移動等事件中可以通過UITouchs得到兩個觸摸對象,但是我們如何能判斷使用者是用兩個手指捏合還是橫掃或者拖動呢?在iOS3.2之後蘋果引入了手勢識别,對于使用者常用的手勢操作進行了識别并封裝成具體的類供開發者使用,這樣在開發過程中我們就不必再自己編寫算法識别使用者的觸摸操作了。在iOS中有六種手勢操作:

手勢

說明

UITapGestureRecognizer

點按手勢

UIPinchGestureRecognizer

捏合手勢

UIPanGestureRecognizer

拖動手勢

UISwipeGestureRecognizer

輕掃手勢,支援四個方向的輕掃,但是不同的方向要分别定義輕掃手勢

UIRotationGestureRecognizer

旋轉手勢

UILongPressGestureRecognizer

長按手勢

所有的手勢操作都繼承于UIGestureRecognizer,這個類本身不能直接使用。這個類中定義了這幾種手勢共有的一些屬性和方法(下表僅列出常用屬性和方法):

名稱

屬性

@property(nonatomic,readonly) UIGestureRecognizerState state;

手勢狀态

@property(nonatomic, getter=isEnabled) BOOL enabled;

手勢是否可用

@property(nonatomic,readonly) UIView *view;

觸發手勢的視圖(一般在觸摸執行操作中我們可以通過此屬性獲得觸摸視圖進行操作)

@property(nonatomic) BOOL delaysTouchesBegan;

手勢識别失敗前不執行觸摸開始事件,預設為NO;如果為YES,那麼成功識别則不執行觸摸開始事件,失敗則執行觸摸開始事件;如果為NO,則不管成功與否都執行觸摸開始事件;

方法

- (void)addTarget:(id)target action:(SEL)action;

添加觸摸執行事件

- (void)removeTarget:(id)target action:(SEL)action;

移除觸摸執行事件

- (NSUInteger)numberOfTouches;

觸摸點的個數(同時觸摸的手指數)

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

在指定視圖中的相對位置

- (CGPoint)locationOfTouch:(NSUInteger)touchIndex inView:(UIView*)view;

觸摸點相對于指定視圖的位置

- (void)requireGestureRecognizerToFail:(UIGestureRecognizer *)otherGestureRecognizer;

指定一個手勢需要另一個手勢執行失敗才會執行

代理方法

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;

一個控件的手勢識别後是否阻斷手勢識别繼續向下傳播,預設傳回NO;如果為YES,響應者鍊上層對象觸發手勢識别後,如果下層對象也添加了手勢并成功識别也會繼續執行,否則上層對象識别後則不再繼續傳播;

這裡着重解釋一下上表中手勢狀态這個對象。在六種手勢識别中,隻有一種手勢是離散手勢,它就是UITapGestureRecgnier。離散手勢的特點就是一旦識别就無法取消,而且隻會調用一次手勢操作事件(初始化手勢時指定的觸發方法)。換句話說其他五種手勢是連續手勢,連續手勢的特點就是會多次調用手勢操作事件,而且在連續手勢識别後可以取消手勢。從下圖可以看出兩者調用操作事件的次數是不同的:

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

在iOS中将手勢狀态分為如下幾種:

對于離散型手勢UITapGestureRecgnizer要麼被識别,要麼失敗,點按(假設點按次數設定為1,并且沒有添加長按手勢)下去一次不松開則此時什麼也不會發生,松開手指立即識别并調用操作事件,并且狀态為3(已完成)。 

但是連續手勢要複雜一些,就拿旋轉手勢來說,如果兩個手指點下去不做任何操作,此時并不能識别手勢(因為我們還沒旋轉)但是其實已經觸發了觸摸開始事件,此時處于狀态0;如果此時旋轉會被識别,也就會調用對應的操作事件,同時狀态變成1(手勢開始),但是狀态1隻有一瞬間;緊接着狀态變為2(因為我們的旋轉需要持續一會),并且重複調用操作事件(如果在事件中列印狀态會重複列印2);松開手指,此時狀态變為3,并調用1次操作事件。

為了大家更好的了解這個狀态的變化,不妨在操作事件中列印事件狀态,會發現在操作事件中的狀态永遠不可能為0(預設狀态),因為隻要調用此事件說明已經被識别了。前面也說過,手勢識别從根本還是調用觸摸事件而完成的,連續手勢之是以會發生狀态轉換完全是由于觸摸事件中的移動事件造成的,沒有移動事件也就不存在這個過程中狀态變化。

大家通過蘋果官方的分析圖再了解一下上面說的内容:

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

在iOS中添加手勢比較簡單,可以歸納為以下幾個步驟:

建立對應的手勢對象; 

設定手勢識别屬性【可選】; 

附加手勢到指定的對象; 

編寫手勢操作方法;

為了幫助大家了解,下面以一個圖檔檢視程式示範一下上面幾種手勢,在這個程式中我們完成以下功能:

如果點按圖檔會在導航欄顯示圖檔名稱;

如果長按圖檔會顯示删除按鈕,提示使用者是否删除;

如果捏合會放大、縮小圖檔;

如果輕掃會切換到下一張或上一張圖檔;

如果旋轉會旋轉圖檔;

如果拖動會移動圖檔;

具體布局草圖如下:

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

為了顯示導覽列,我們首先将主視圖控制器KCPhotoViewController放入一個導航控制器,然後在主視圖控制器中放一個UIImage用于展示圖檔。下面是主要代碼:

運作效果:

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

在上面示例中需要強調幾點:

UIImageView預設是不支援互動的,也就是userInteractionEnabled=NO ,是以要接收觸摸事件(手勢識别),必須設定userInteractionEnabled=YES(在iOS中UILabel、UIImageView的userInteractionEnabled預設都是NO,UIButton、UITextField、UIScrollView、UITableView等預設都是YES)。 

輕掃手勢雖然是連續手勢但是它的操作事件隻會在識别結束時調用一次,其他連續手勢都會調用多次,一般需要進行狀态判斷;此外輕掃手勢支援四個方向,但是如果要支援多個方向需要添加多個輕掃手勢。

細心的童鞋會發現在上面的示範效果圖中當切換到下一張或者上一張圖檔時并沒有輕掃圖檔而是在空白地方輕掃完成,原因是如果我輕掃圖檔會引起拖動手勢而不是輕掃手勢。換句話說,兩種手勢發生了沖突。

沖突的原因很簡單,拖動手勢的操作事件是在手勢的開始狀态(狀态1)識别執行的,而輕掃手勢的操作事件隻有在手勢結束狀态(狀态3)才能執行,是以輕掃手勢就作為了犧牲品沒有被正确識别。我們理想的情況當然是如果在圖檔上拖動就移動圖檔,如果在圖檔上輕掃就翻動圖檔。如何解決這個沖突呢?

在iOS中,如果一個手勢A的識别部分是另一個手勢B的子部分時,預設情況下A就會先識别,B就無法識别了。要解決這個沖突可以利用- (void)requireGestureRecognizerToFail:(UIGestureRecognizer *)otherGestureRecognizer;方法來完成。正是前面表格中UIGestureRecognizer的最後一個方法,這個方法可以指定某個手勢執行的前提是另一個手勢失敗才會識别執行。也就是說如果我們指定拖動手勢的執行前提為輕掃手勢失敗就可以了,這樣一來當我們手指輕輕滑動時系統會優先考慮輕掃手勢,如果最後發現該操作不是輕掃,那麼就會執行拖動。隻要将下面的代碼添加到添加手勢之後就能解決這個問題了(注意為了更加清晰的區分拖動和輕掃[模拟器中拖動稍微快一點就識别成了輕掃],這裡将長按手勢的前提設定為拖動失敗,避免示範拖動時長按手勢會被識别):

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

我們知道在iOS的觸摸事件中,事件觸發是根據響應者鍊進行的,上層觸摸事件執行後就不再向下傳播。預設情況下手勢也是類似的,先識别的手勢會阻斷手勢識别操作繼續傳播。那麼如何讓兩個有層次關系并且都添加了手勢的控件都能正确識别手勢呢?答案就是利用代理的-(BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer方法。這個代理方法預設傳回NO,會阻斷繼續向下識别手勢,如果傳回YES則可以繼續向下傳播識别。

下面的代碼控制示範了當在圖檔上長按時同時可以識别控制器視圖的長按手勢(注意其中我們還控制了隻有在UIImageView中操作的手勢才能向下傳遞,如果不控制則所有控件都可以向下傳遞)

前面我們主要介紹了觸摸事件以及由觸摸事件引出的手勢識别,下面我們簡單介紹一下運動事件。在iOS中和運動相關的有三個事件:開始運動、結束運動、取消運動。

監聽運動事件對于UI控件有個前提就是監聽對象必須是第一響應者(對于UIViewController視圖控制器和UIAPPlication沒有此要求)。這也就意味着如果監聽的是一個UI控件那麼-(BOOL)canBecomeFirstResponder;方法必須傳回YES。同時控件顯示時(在-(void)viewWillAppear:(BOOL)animated;事件中)調用視圖控制器的becomeFirstResponder方法。當視圖不再顯示時(在-(void)viewDidDisappear:(BOOL)animated;事件中)登出第一響應者身份。

由于視圖控制器預設就可以調用運動開始、運動結束事件在此不再舉例。現在不妨假設我們現在在開發一個搖一搖找人的功能,這裡我們就自定義一個圖檔展示控件,在這個圖檔控件中我們可以通過搖晃随機切換界面圖檔。代碼比較簡單:

KCImageView.m

KCShakeViewController.m

運作效果(下圖示範時使用了模拟器搖晃操作的快捷鍵,沒有使用滑鼠操作):

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

在今天的文章中還剩下最後一類事件:遠端控制,遠端控制事件這裡主要說的就是耳機線控操作。在前面的事件清單中,大家可以看到在iOS中和遠端控制事件有關的隻有一個- (void)remoteControlReceivedWithEvent:(UIEvent *)event NS_AVAILABLE_IOS(4_0);事件。要監聽到這個事件有三個前提(視圖控制器UIViewController或應用程式UIApplication隻有兩個)

啟用遠端事件接收(使用[[UIApplication sharedApplication] beginReceivingRemoteControlEvents];方法)。 

對于UI控件同樣要求必須是第一響應者(對于視圖控制器UIViewController或者應用程式UIApplication對象監聽無此要求)。 

應用程式必須是目前音頻的控制者,也就是在iOS 7中通知欄中目前音頻播放程式必須是我們自己開發程式。

基于第三點我們必須明确,如果我們的程式不想要控制音頻,隻是想利用遠端控制事件做其他的事情,例如模仿iOS7中的按音量+鍵拍照是做不到的,目前iOS7給我們的遠端控制權限還僅限于音頻控制(當然假設我們确實想要做一個和播放音頻無關的應用但是又想進行遠端控制,也可以隐藏一個音頻播放操作,拿到遠端控制操作權後進行遠端控制)。

運動事件中我們也提到一個枚舉類型UIEventSubtype,而且我們利用它來判斷是否運動事件,在枚舉中還包含了我們運程控制的子事件類型,我們先來熟悉一下這個枚舉(從遠端控制子事件類型也不難發現它和音頻播放有密切關系):

這裡我們将遠端控制事件放到視圖控制器(事實上很少直接添加到UI控件,一般就是添加到UIApplication或者UIViewController),模拟一個音樂播放器。

1.首先在應用程式啟動後設定接收遠端控制事件,并且設定音頻會話保證背景運作可以播放(注意要在應用配置中設定允許多任務)

2.在視圖控制器中添加遠端控制事件并音頻播放進行控制

運作效果(真機截圖):

iOS-觸摸事件、手勢識别、搖晃事件、耳機線控

注意:

為了模拟一個真實的播放器,程式中我們啟用了背景運作模式,配置方法:在info.plist中添加UIBackgroundModes并且添加一個元素值為audio。 

即使利用線控進行音頻控制我們也無法監控到耳機增加音量、減小音量的按鍵操作(另外注意模拟器無法模拟遠端事件,請使用真機調試)。 

子事件的類型跟目前音頻狀态有直接關系,點選一次播放/暫停按鈕究竟是【播放】還是【播放/暫停】狀态切換要看目前音頻處于什麼狀态,如果處于停止狀态則點選一下是播放,如果處于暫停或播放狀态點選一下是暫停和播放切換。 

上面的程式已在真機調試通過,無論是線控還是點選應用按鈕都可以控制播放或暫停。

Write the code ,change the world!

iOS