1. 程式人生 > >iOS —— 觸控事件傳遞及響應與手勢

iOS —— 觸控事件傳遞及響應與手勢

iOS 的事件分為三種,觸控事件(Touch Event)加速器事件(Motion Events)遠端遙控事件(Remote Events)。這些事件對應的類為UIResponder。本文只探究觸控事件。

Tip:在模擬器中,按住option可以兩根手指操作。同時按住option+shift可以移動兩根手指。


觸控事件的處理

觸控事件可以分為兩部分——傳遞和響應。

傳遞:系統把該事件傳到最適合響應的物件。

響應:最適合響應的物件可以不響應,轉給別的物件響應。

傳遞

我們偶爾會遇到顯示一個小列表選擇。這時候小列表超出了父view的範圍。這時點選B是達不到預期效果的。

image.png

下面還是通過一份Demo來學習。

如圖,父view(FirstView紅色),子view(SecondView黃色)。觸控點在A區域,父view響應;觸控點在B區域,子view響應;觸控點在C區域,預設子view是不響應的。我們來實現讓子view響應C區域事件。

image.png

傳遞步驟:

(有個有趣的地方,UIApplication和AppDelegate也繼承於UIResponder)

簡單地說,自下而上。UIResponder -> UIApplication -> UIWindow -> UIViewController -> UIView(父view一直遍歷到子view,同層的view按後新增的view先遍歷)。其遵循的規則如下:

  1. 自己是否能接收觸控事件? 不能接收的情況有三種 一、userInteractionEnabled = NO 二、 hidden = YES 三、 alpha = 0.0 ~ 0.01
  2. 觸控點是否在自己身上?
  3. 從後往前遍歷子控制元件,重複前兩個步驟。 若父控制元件不能接收觸控事件,不會傳遞給子控制元件。
  4. 如果沒有符合條件的子控制元件,那麼就自己最適合處理。

在https://juejin.im/post/5b614924f265da0f774ac7be借了兩張圖能更直觀地理解傳遞過程。

image.png

當事件傳遞給當前view時,當前view會呼叫- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event

方法。尋找最適合的view。

返回誰,誰就是最合適的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);
}
複製程式碼

QQ20190105-171313.gif

響應過程

  • 響應鏈

簡單地說,傳遞到最合適的view後,如果有實現touches方法那麼就由此 View 響應,如果沒有實現,那麼就會自下而上,傳遞給他的下一個響應者【子view -> 父view,控制器view -> 控制器-> UIWindow -> UIApplication -> AppDelegate】。

image.png

由這兩張圖,我們就可以知道每個UIResponder物件nextResponder指向誰。


通過touches方法,雖然能實現響應觸控事件,但對開發還是不友好,原因有以下三個:

  1. 要自定義view。
  2. 還要在實現檔案中實現touches方法,由此在讓外部監聽到實現檔案中的觸控事件,增強了耦合度。
  3. 不容易區分使用者的具體手勢行為。實現長按手勢都能折騰。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;
}
複製程式碼

參考