iOS —— 觸控事件傳遞及響應與手勢
iOS 的事件分為三種,觸控事件(Touch Event)
、加速器事件(Motion Events)
、遠端遙控事件(Remote Events)
。這些事件對應的類為UIResponder。本文只探究觸控事件。
Tip:在模擬器中,按住option可以兩根手指操作。同時按住option+shift可以移動兩根手指。
觸控事件的處理
觸控事件可以分為兩部分——傳遞和響應。
傳遞:系統把該事件傳到最適合響應的物件。
響應:最適合響應的物件可以不響應,轉給別的物件響應。
傳遞
我們偶爾會遇到顯示一個小列表選擇。這時候小列表超出了父view的範圍。這時點選B是達不到預期效果的。
下面還是通過一份Demo來學習。
如圖,父view(FirstView紅色),子view(SecondView黃色)。觸控點在A區域,父view響應;觸控點在B區域,子view響應;觸控點在C區域,預設子view是不響應的。我們來實現讓子view響應C區域事件。
傳遞步驟:
(有個有趣的地方,UIApplication和AppDelegate也繼承於UIResponder)
簡單地說,自下而上。UIResponder -> UIApplication -> UIWindow -> UIViewController -> UIView(父view一直遍歷到子view,同層的view按後新增的view先遍歷)。其遵循的規則如下:
- 自己是否能接收觸控事件? 不能接收的情況有三種 一、userInteractionEnabled = NO 二、 hidden = YES 三、 alpha = 0.0 ~ 0.01
- 觸控點是否在自己身上?
- 從後往前遍歷子控制元件,重複前兩個步驟。 若父控制元件不能接收觸控事件,不會傳遞給子控制元件。
- 如果沒有符合條件的子控制元件,那麼就自己最適合處理。
在https://juejin.im/post/5b614924f265da0f774ac7be借了兩張圖能更直觀地理解傳遞過程。
當事件傳遞給當前view時,當前view會呼叫- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
返回誰,誰就是最合適的view,響應事件呼叫touches方法。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
return [super hitTest:point withEvent:event];
}
複製程式碼
Demo中,點選了C區域,傳遞給控制器view後,滿足1.2條件,然後傳遞給紅色view。紅色view滿足1,但不滿足2所以不符合。最終控制器view成為最適合的view。因此我們還要修改 觸控點是否在自己身上的方法,來讓事件傳遞給黃色view。
在紅色view中實現該方法後,就能滿足條件2,從而把事件傳遞給黃色view。
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGPoint secondViewPoint = [self convertPoint:point toView:self.secondView];
if ([self.secondView pointInside:secondViewPoint withEvent:event]) {
return YES;
}
return [super pointInside:point withEvent:event];
}
複製程式碼
最終,黃色view能滿足條件1.2,且沒有更適合的子控制元件,所以成為了最適合的view。Demo的目的也就達成了。
響應
先了解有關的類,然後通過一個Demo來熟悉它們的使用。
UIResponder
@interface UIResponder : NSObject <UIResponderStandardEditActions>
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
- (nullable UIResponder*)nextResponder;
@property(nonatomic, readonly) BOOL canBecomeFirstResponder; // default is NO
- (BOOL)canBecomeFirstResponder; // default is NO
- (BOOL)becomeFirstResponder;
@property(nonatomic, readonly) BOOL canResignFirstResponder; // default is YES
- (BOOL)canResignFirstResponder; // default is YES
- (BOOL)resignFirstResponder;
@property(nonatomic, readonly) BOOL isFirstResponder;
- (BOOL)isFirstResponder;
// 觸控事件方法
// 手指觸控
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
// 觸控時移動
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
// 手指離開螢幕
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
// 觸控狀態下被系統事件(如電話等打斷)
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches NS_AVAILABLE_IOS(9_1);
@end
複製程式碼
觸控事件方法中有兩個引數(NSSet<UITouch *> *)touches和(UIEvent *)event。
UITouch
- UITouch物件記錄 觸控的位置、時間、階段。
- 一根手指對應一個UITouch物件。
- 手指移動時,系統會更新同一個UITouch物件。
- 手指離開螢幕時,UITouch物件被銷燬。
@interface UITouch : NSObject
// 觸控產生時所處的視窗
@property (nonatomic, readonly, retain) UIWindow *window;
// 觸控產生時所處的檢視
@property (nonatomic, readonly, retain) UIView *view;
// 短時間內點按螢幕的次數
@property (nonatomic, readonly) NSUInteger tapCount;
// 記錄了觸控事件產生或變化的時間,單位:秒
@property (nonatomic, readonly) NSTimeInterval timestamp;
// 當前觸控事件所處的狀態
@property (nonatomic, readonly) UITouchPhase phase;
typedef NS_ENUM(NSInteger, UITouchPhase) {
UITouchPhaseBegan, //(觸控開始)
UITouchPhaseMoved, // (接觸點移動)
UITouchPhaseStationary, // (接觸點無移動)
UITouchPhaseEnded, // (觸控結束)
UITouchPhaseCancelled, // (觸控取消)
};
// 返回觸控在view上的位置
// 相對view的座標系
// 如果引數為nil,返回的是在UIWindow的位置
- (CGPoint)locationInView:(nullable UIView *)view;
// 返回上一個觸控點的位置
- (CGPoint)previousLocationInView:(nullable UIView *)view;
@end
複製程式碼
UIEvent
每產生一個事件,就會產生一個UIEvent物件。記錄事件產生的時刻和型別。本文探究的都是觸控事件。
@interface UIEvent : NSObject
// 事件型別
@property(nonatomic,readonly) UIEventType type NS_AVAILABLE_IOS(3_0);
@property(nonatomic,readonly) UIEventSubtype subtype NS_AVAILABLE_IOS(3_0);
// 事件產生的事件
@property(nonatomic,readonly) NSTimeInterval timestamp;
@end
複製程式碼
接下來通過一個Demo來熟悉上面提到的類。
新建一個view,在.m檔案中打入以下程式碼,手指拖拽著該view移動。
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
NSLog(@"%s", __func__);
// 因為只有一根手指,所以用anyObject
UITouch *touch = [touches anyObject];
// 獲取上一點
CGPoint previousPoint = [touch previousLocationInView:self];
// 獲取當前點
CGPoint currentPoint = [touch locationInView:self];
// 計算偏移量
CGFloat offsetX = currentPoint.x - previousPoint.x;
CGFloat offsetY = currentPoint.y - previousPoint.y;
// view平移
self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY);
}
複製程式碼
響應過程
- 響應鏈
簡單地說,傳遞到最合適的view後,如果有實現touches方法那麼就由此 View 響應,如果沒有實現,那麼就會自下而上,傳遞給他的下一個響應者【子view -> 父view,控制器view -> 控制器-> UIWindow -> UIApplication -> AppDelegate】。
由這兩張圖,我們就可以知道每個UIResponder物件
的nextResponder
指向誰。
通過touches方法,雖然能實現響應觸控事件,但對開發還是不友好,原因有以下三個:
- 要自定義view。
- 還要在實現檔案中實現touches方法,由此在讓外部監聽到實現檔案中的觸控事件,增強了耦合度。
- 不容易區分使用者的具體手勢行為。實現長按手勢都能折騰。UITouch如何判斷長擊啊??
所以蘋果推出了UIGestureRecognizer手勢識別器。對常用的手勢進行了封裝。
手勢
手勢識別和觸控事件是兩個獨立的概念。
UIGestureRecognizer簡介
typedef NS_ENUM(NSInteger, UIGestureRecognizerState) {
UIGestureRecognizerStatePossible,
UIGestureRecognizerStateBegan,
UIGestureRecognizerStateChanged,
UIGestureRecognizerStateEnded,
UIGestureRecognizerStateCancelled,
UIGestureRecognizerStateFailed,
UIGestureRecognizerStateRecognized = UIGestureRecognizerStateEnded
};
@interface UIGestureRecognizer : NSObject
@property(nonatomic,readonly) UIGestureRecognizerState state;
@property(nullable,nonatomic,weak) id <UIGestureRecognizerDelegate> delegate;
@end
複製程式碼
UIGestureRecognizer是一個抽象類,使用它的子類才能處理具體的手勢。
UITapGestureRecognizer(敲擊)
UILongPressGestureRecognizer(長按)
UISwipeGestureRecognizer(輕掃)
UIRotationGestureRecognizer(旋轉)
UIPinchGestureRecognizer(捏合,用於縮放)
UIPanGestureRecognizer(拖拽)
複製程式碼
手勢的使用
- 點按手勢
UITapGestureRecognizer *tapGes = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tap:)];
[self.view addGestureRecognizer:tapGes];
複製程式碼
- 長按手勢。
- (void)viewDidLoad {
[super viewDidLoad];
// 建立手勢
UITapGestureRecognizer *longPressGes = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(longPressGes:)];
// 新增手勢
[view addGestureRecognizer:longPressGes];
}
// 長按手勢分狀態,長按移動時,也會呼叫
- (void)longPressGes:(UILongPressGestureRecoginzer *)longPressGes {
if (longPressGes.state == UIGestureRecognizerStateBegan) {// 長按開始
} else if (longPressGes.state == UIGestureRecognizerStateChanged) {// 長按移動
} else if (longPressGes.state == UIGestureRecognizerStateEnded) {// 長按結束
}
複製程式碼
- 輕掃手勢 預設是向右輕掃手勢。如果要向左輕掃,需要設定輕掃方向。
UISwipeGestureRecognizer *swipe = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(swipeGes:)];
//注意點:一個輕掃手勢只能對應一個方向,不要用或。
// 要多個方向就建立多個手勢。
swipe.direction = UISwipeGestureRecognizerDirectionLeft;
/*
typedef NS_OPTIONS(NSUInteger, UISwipeGestureRecognizerDirection) {
UISwipeGestureRecognizerDirectionRight = 1 << 0,
UISwipeGestureRecognizerDirectionLeft = 1 << 1,
UISwipeGestureRecognizerDirectionUp = 1 << 2,
UISwipeGestureRecognizerDirectionDown = 1 << 3
};
*/
[self.view addGestureRecognizer:swipe];
複製程式碼
- 拖拽手勢
上面的Demo中提到的平移,需要獲取上一個點和當前點計算偏移量。拖拽手勢內部有方法能直接獲取相對於最原始的點的偏移量。
- (void)viewDidLoad {
[super viewDidLoad];
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
[self.view addGestureRecognizer:pan];
}
- (void)pan:(UIPanGestureRecognizer *)pan {
// 獲取偏移量
CGPoint transP = [pan translationInView:self.view];
self.view.transform = CGAffineTransformTranslate(self.view, transP.x, transP.y);
// 清0
[pan setTranslation:CGPointMake(0, 0) inView:self.view];
}
複製程式碼
- 旋轉手勢
同理,旋轉手勢內部有方法能直接獲取相對於最原始的點的旋轉量。
- (void)viewDidLoad {
[super viewDidLoad];
UIRotationGestureRecognizer *rotation = [[UIRotationGestureRecognizer alloc] initWithTarget:self action:@selector(rotation:)];
[self.view addGestureRecognizer:rotation];
}
- (void)rotation:(UIRotationGestureRecognizer *)rotationGes {
// 獲取旋轉角度(已經是弧度)
CGFloat rotation = rotationGes.rotation;
self.view.transform = CGAffineTransformRotate(self.view.transform, rotation);
// 清0
[rotationGes setRotation:0.f];
}
複製程式碼
- 捏合手勢
同理,捏合手勢內部有方法能直接獲取相對於最原始的縮放比例。
- (void)viewDidLoad {
[super viewDidLoad];
UIPinchGestureRecognizer *rotation = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(pinch:)];
[self.view addGestureRecognizer:rotation];
}
- (void)pinch:(UIPinchGestureRecognizer *)pinchGes {
// 放大,縮小
CGFloat scale = pinchGes.scale;
self.view.transform = CGAffineTransformScale(self.view.transform, scale, scale);
// 清0
[pinchGes setScale:0];
}
複製程式碼
手勢的常用代理方法
// 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
// 在touchesBegan之前,是否允許該手勢接收事件
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch;
// called before pressesBegan:withEvent: is called on the gesture recognizer for a new press. return NO to prevent the gesture recognizer from seeing this press
// 在touchesBegan之前,是否允許該手勢接收事件
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceivePress:(UIPress *)press;
// 是否允許同時支援多個手勢
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;
複製程式碼
大家應該試過視訊左邊手勢調亮度,右邊調音量。就可以在代理方法中實現。
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
// 獲取當前的點
CGPoint curP = [touch locationInView:view];
// 判斷在左邊還是右邊
if (curP.x > view.bounds.size.width * 0.5) {// 在左邊
} else {// 在右邊
}
return YES;
}
複製程式碼
手勢預設是不能同時進行的(例如上面的旋轉和捏合手勢),如果要同時識別,需要實現代理方法。
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return YES;
}
複製程式碼