blog

iOS レスポンダチェーン

指先が画面に触れた瞬間、タッチ イベントがシステム内で生成されます。IPC プロセス間通信の後、イベントは最終的に適切なアプリケーションに配信されます。アプリケーション内で山あり谷ありの素晴らしい旅を...

Mar 22, 2020 · 34 min. read
シェア

この記事では、iOSのタッチイベントに関する一連の仕組みについて、以下のような幅広い問題を取り上げながら解説しています:

  • タッチスクリーンで発生したタッチイベントを現在のアプリケーションに渡すには?

  • タッチイベントを受信するアプリケーションは、最適なレスポンダーを見つける方法は?どのように動作しますか?

  • タッチイベントはレスポンスチェーンに沿ってどのように流れますか?

  • タッチイベントへの応答に関するレスポンスチェーン、ジェスチャー認識機能、UIControlの関係は?

ヒント: iOSのイベントには、タッチイベントだけでなく、加速度センサーイベントやリモートコントロールイベントも含まれます。この記事では触れないので、この記事で言及するイベントはタッチイベントを指します。

指先が画面に触れると、システム内でタッチイベントが発生します。IPC プロセス間通信の後、イベントは最終的に適切なアプリケーションに配信されます。アプリケーション内での素晴らしい旅を経て、イベントは最終的に解放されます。大まかな流れを以下に示します:

1.指がスクリーンに触れると、スクリーンはタッチを感知し、処理のためにイベントをIOKitに渡します。

2.IOKitはIOHIDEventオブジェクトにカプセル化されたイベントに触れ、machポートを通してSpringBoadプロセスに渡されます。

machポートとは、プロセスが互いに通信するためのプロセスポートです。

SpringBoad.appはシステムプロセスであり、デスクトップシステムとして理解することができます。

3.SpringBoardプロセスは、タッチイベントを受信した結果として、メインスレッドのランループのsource1イベントソースへのコールバックをトリガします。

  1. APPプロセスmachポートがSpringBoardプロセスからタッチイベントを受信すると、メインスレッドランループが起動され、source1コールバックがトリガーされます。

  2. source1コールバックはsource0コールバックをトリガーし、受け取ったIOHIDEventオブジェクトをUIEventオブジェクトにカプセル化します。

  3. source0コールバックはタッチイベントをUIApplicationオブジェクトのイベントキューに追加します。イベントがキューから出た後、UIApplicationは最適なレスポンダを見つけるプロセスを開始します。このプロセスはヒットテストとしても知られており、詳細は[イベントに対する最適なレスポンダを見つける]のセクションで詳しく説明します。さらに、ここから通常の開発関連の作業が始まります。

  4. 最適なレスポンダが見つかったら、次のステップはレスポンスチェーンを通してイベントを渡すことです。実際には、レスポンダによって消費されるだけでなく、イベントはジェスチャ認識またはターゲットアクションパターンによってキャプチャされ、消費されることもあります。これには、[イベントの3つの弟子: UIResponder, UIGestureRecognizer, UIControl]のセクションで説明したように、タッチイベントに対するレスポンスの優先順位付けが含まれます。

  5. 紆余曲折を経て、タッチイベントは応答するオブジェクトに捕捉されて解放されるか、応答するオブジェクトが見つからずに消滅し、最終的に解放されます。この時点で、タッチイベントのミッションは終了です。runloopは、他に処理するイベントがなければスリープに戻り、新しいイベントがウェイクアップするのを待ちます。

これで最初の質問に答えることができます。タッチスクリーンからタッチイベントが生成された後、IOKitはタッチイベントをSpringBoardプロセスに渡し、SpringBoardはそれを現在のフォアグラウンドAPPに分配して処理します。

タッチとは何か、イベントとは何か、レスポンダとは何か?まずは簡単な科学です。

タッチの起源

  • 指がスクリーンに1回触れることで、UITouchオブジェクトが生成されます。複数の指が同時に触れると、複数のUITouchオブジェクトが生成されます。

  • 複数の指が連続してタッチすると、タッチした位置によって同じUITouchオブジェクトを更新するかどうかを判断します。2本の指が次々に同じ位置にタッチした場合、最初のタッチでUITouchオブジェクトが生成され、2回目のタッチでこのUITouchオブジェクトが更新されます。2本の指が次々に異なる位置にタッチした場合、2つのUITouchオブジェクトが生成され、両者の間に関連性はありません。

  • 各UITouchオブジェクトには、タッチの時間、位置、ステージ、ビュー、ウィンドウなどのタッチに関する情報が記録されます。

//タッチのさまざまな段階の状態
//たとえば、指が動かされると、phaseプロパティはUITouchPhaseMovedに更新され、指が画面から離れると、UITouchPhaseEndedに更新される。
typedef NS_ENUM(NSInteger, UITouchPhase) { 
 UITouchPhaseBegan, // whenever a finger touches the surface. 
 UITouchPhaseMoved, // whenever a finger moves on the surface. 
 UITouchPhaseStationary, // whenever a finger is touching the surface but hasn't moved since the previous event. 
 UITouchPhaseEnded, // whenever a finger leaves the surface. 
 UITouchPhaseCancelled, // whenever a touch doesn't end but we need to stop tracking (e.g. putting device to face)
};
  • 指がスクリーンから一定時間離れると、UITouchオブジェクトは更新されないと判断され、解放されます。

イベントの実際のボディ

  • タッチの目的は、レスポンダが応答するためのタッチイベントを生成することです。タッチイベントはUIEventオブジェクトに対応し、type属性はイベントのタイプを識別します。

  • タッチイベントは複数の指が同時にタッチすることで生成されるため、UIEvent オブジェクトにはイベントをトリガーしたタッチオブジェクトのコレクションが含まれます。タッチオブジェクトのコレクションはallTouchesプロパティで取得します。

野望を満たすためのすべて

すべてのレスポンダは UIResponder オブジェクトです。つまり、UIResponder から派生したすべてのオブジェクトはイベントに応答する機能を持ちます。したがって、以下のクラスのインスタンスはレスポンダです:

  • UIView

  • UIViewController

  • UIApplication

  • AppDelegate

レスポンダは、タッチイベントを処理するための4つのメソッドを提供するため、イベントに応答します:

//指が画面に触れ、タッチが始まる
- (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;

これらのメソッドは、レスポンダオブジェクトがイベントを受信したときに呼び出され、イベントに応答します。レスポンダがイベントを受信するときと、レスポンスチェーンに沿ってイベントが渡される方法については、以下のセクションで説明します。

最初の章では、アプリがタッチイベントを受信すると、現在のアプリのイベントキューに格納されることを説明しました。

各イベントの理想的な運命は、リリース後のオブジェクトレスポンスでそれに応答できることですが、レスポンダは多数存在し、イベントは一度に1つしかなく、自分の器にイベントをつかみたい人たちは、争いを避けるために、順序、つまりレスポンダの優先順位が必要です。したがって、イベントプロセスへの最高のレスポンダのための検索があります、目的は、最高の優先順位の応答を持つ応答オブジェクトを見つけることです、このプロセスは、ヒットテストビューと呼ばれる最高のレスポンダを打つヒットテストと呼ばれています。

このセクションで探求する質問は、次のとおりです:

  1. アプリケーションはイベントを受信したとき、どのようにして最良のレスポンダを見つけるのでしょうか?これはどのように実装されるのでしょうか?

  2. ベストレスポンダ探索中のイベントのインターセプト

アプリケーションはイベントを受信すると、まずそれをイベントキューに入れ、処理を待ちます。キューから出ると、アプリケーションはまず、現在のアプリケーションが最後に表示したウィンドウにイベントを渡し、イベントに応答できるかどうかを尋ねます。ウィンドウがイベントに応答できる場合は、サブビューにイベントを渡して応答できるかどうか尋ね、サブビューが応答できる場合は、続けてサブビューに尋ねます。サブビューに照会る順番は、後から追加されるサブビュー、つまりサブビュー配列で次に並んでいるサブビューに優先順位をつけるためです。イベント配信の順番は以下の通りです:

UIApplication  > UIWindow  > サブビュー> ...  >  

実際には、UIWindowもビューとみなすことができるので、配信プロセス全体は、イベントプロセスに応答できるサブビューを尋ねる再帰的なプロセスであり、優先順位の高いサブビューが追加された後です。

  1. UIApplicationは、まずイベントをウィンドウオブジェクトに渡し、ウィンドウが複数ある場合は、その後に表示されるウィンドウに照会ます。

  2. ウィンドウがイベントに応答できない場合は、他のウィンドウにイベントを渡し、ウィンドウがイベントに応答できる場合は、ウィンドウのサブビューを後ろから前に尋ねます。

  3. ステップ2を繰り返します。つまり、ビューが応答できない場合、同じレベルの前のサブビューにイベントを渡します。ビューが応答できる場合、現在のビューのサブビューに後ろから前に尋ねます。

  4. ビューが応答できる子ビューを持たない場合、それは最も適切なレスポンダです。

ヒットテストのシナリオ

ビュー階層は以下の通りです:

A
├── B
│ └── D
└── C
 ├── E
 └── F

ここで、Eビューの画面位置でタッチがトリガーされたとします。アプリケーションはタッチイベントを受信し、最初にUIWindowにイベントを渡し、次に下から順にサブビューの中から最適なレスポンダを探し始めます。イベントパスの順序を以下に示します:

  1. UIWindow は子ビュー A にイベントを渡します。

  2. A はイベントに応答できると判断し、C にイベントを渡します。

  3. Cはイベントに応答できると判断し、続けてイベントをFに渡します。

  4. Fはイベントに応答できないと判断し、CはイベントをEに渡します。

  5. E はイベントに応答できると判断し、同時に E にはもうサブビューがないので、最終的に E が最良のレスポンダとなります。

レスポンダ間でイベントを受け渡すルールは前述の通りですが、ビューはイベントに応答できるかどうかを判断することで、サブビューにイベントを受け渡し続けるかどうかを決定します。ビューがイベントに応答できるかどうかをどのように決定するのでしょうか?また、ビューはどのようにして子ビューにイベントを渡すのでしょうか?

最初に知っておくべきことは、以下の状態のビューはイベントに応答できないということです:

  • userInteractionEnabled = NO

  • : hidden = YES 親ビューが非表示の場合、子ビューも非表示になり、非表示のビューはイベントを受け取ることができません。

  • alpha < 0.01 ビューの透明度を < 0.01 に設定すると、子ビューの透明度に直接影響します。

すべてのUIViewオブジェクトは、hitTest:withEvent:メソッドを持っています。このメソッドは、Hit-Testingプロセスの中核をなす存在であり、その役割は、現在のビューでイベントのレスポンダーに尋ねると同時に、イベントを渡すためのブリッジとして機能します。

hitTest:withEvent:メソッドは現在のビュー階層のレスポンダとしてUIViewオブジェクトを返します。デフォルトの実装は

  • 現在のビューがイベントに応答できない場合、nilが返されます。

  • 現在のビューがイベントに応答できるが、イベントに応答できる子ビューが存在しない場合、現在のビュー階層のイベントレスポンダとして自身を返します。

  • 現在のビューがイベントに応答可能であり、応答可能な子ビューが存在する場合は、子ビュー階層のイベントレスポンダを返します。

はじめに、UIApplicationは、UIWindowオブジェクトのhitTest:withEvent:を呼び出してイベントをUIWindowオブジェクトに渡し、UIWindowのhitTest:withEvent:がUIWindowオブジェクトがイベントに応答できると判断した場合、子ビューのhitTest:withEvent:を呼び出してイベントをUIWindowオブジェクトに渡します。withEvent: はイベントを子ビューに渡し、子ビューに最適なレスポンダを要求します。最終的に UIWindow は、ヒットテストに最適なレスポンダであるビュー階層のレスポンダビューを UIApplication に返します。

ビューがイベントに応答できるかどうかを決定するシステムのロジックは、前述の3つの制限に加えて、タッチ ポイントが現在のビューの座標系内にあることです。したがって、hitTest:withEvent: のデフォルトの実装は次のように推測できます:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{ 
 //3ステートはイベントに応答できない
 if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil; 
 //タッチポイントが現在のビュー上にない場合、イベントに応答することはできない。
 if ([self pointInside:point withEvent:event] == NO) return nil; 
 //子ビューの配列を後ろから前に反復する
 int count = (int)self.subviews.count; 
 for (int i = count - 1; i >= 0; i--) { 
 // サブビューを取得する
 UIView *childView = self.subviews[i]; 
 // 座標系変換、現在のビュー上のタッチポイント座標を子ビュー上の座標に変換する。
 CGPoint childP = [self convertPoint:point toView:childView]; 
 //サブビュー階層で最適なレスポンスのビューを求める
 UIView *fitView = [childView hitTest:childP withEvent:event]; 
 if (fitView){ 
 //サブビューにより適切なものがあれば、それは
 return fitView; 
 } 
} 
 //サブビューの中でより適切なレスポンスビューが見つからなければ、それが最も適切なものである。
 return self;
}

注目すべきは、pointInside:withEvent:メソッドが、タッチ点が自身の座標系内にあるかどうかを判定するために使用されることです。デフォルトの実装では、タッチポイントが自身の座標の範囲内にある場合はYESを返し、そうでない場合はNOを返します。

上記の例のビュー階層の各ビュークラスに以下の3つのメソッドを追加し、解析を検証します:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{ 
 NSLog(@"%s",__func__); 
 return [super hitTest:point withEvent:event];
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{
 NSLog(@"%s",__func__); 
 return [super pointInside:point withEvent:event];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
 NSLog(@"%s",__func__);
}

関連するログは以下のように出力されます:

-[AView hitTest:withEvent:]
-[AView pointInside:withEvent:]
-[CView hitTest:withEvent:]
-[CView pointInside:withEvent:]
-[FView hitTest:withEvent:]
-[FView pointInside:withEvent:]
-[EView hitTest:withEvent:]
-[EView pointInside:withEvent:]
-[EView touchesBegan:withEvent:]

イベントが最終的にビューEで最初に応答され、イベントの受け渡し処理も解析と一致していることがわかります。実際、[AView hitTest:withEvent:]から[EView pointInside:withEvent:]までの処理はシングルクリック後に2回実行され、2回のパスは同じタッチですが、違いはタッチの状態が異なり、1回目は開始段階、2回目は終了段階です。つまり。

実際の開発では、特殊なインタラクション要件に遭遇し、イベントに対するビューの応答をカスタマイズする必要があるかもしれません。例えば、下のTabbarの場合、真ん中のプロトタイプボタンは下のTabbar上のコントロールで、Tabbarはコントローラのルートビューに追加されています。デフォルトでは、イメージの赤枠のボタンの領域をクリックしても、ボタンに反応がないことがわかります。

ヒットテストシナリオ中のイベントの遮断

原因を分析し、何が問題なのかを確認するのは本当に簡単です。無関係なコントロールは無視して、ビューの階層は次のようになります:

RootView
テーブルビュー
タブバー
 サークルボタン

赤枠部分をクリックした後、生成されたタッチイベントはまずUIWindowに渡され、次にコントローラのルートビュー、つまりRootViewに渡されます。RootViewはタッチイベントに応答できると判断され、次にサブコントロールのTabBarにイベントを渡しますが、ここで問題が発生します。タッチポイントがTabBarの座標範囲内にないため、TabBarはタッチイベントに応答できず、hitTest:withEvent: はnilを返し、RootViewはTableViewに応答できるかどうかを尋ねます。この間、丸いボタンにはイベントは一切渡されません。

問題があれば、それを解決する方策があります。解析の結果、hit-Testingの過程で、クリックされた部分の座標がTabbarの座標内にないため、Tabbarがイベントに反応できないと判断され、Tabbarにイベントを渡しても、丸ボタンにイベントが渡されなかったことが原因であることがわかりました。このため、赤枠部分がクリックされたときに、イベントをプロトタイプボタンに流すように、イベントヒット-テスト処理を修正することができます。

イベントがTabBarに渡されると、TabBarのhitTest: withEvent:が呼び出されますが、pointInside: withEvent:はNOを返すので、hitTest: withEvent:はnilを返します。 このようなケースなので、TabBardのpointInside: withEvent:を書き換えて、現在のタッチを判定することが可能です。withEvent:で、現在のタッチ座標が子ビューのCircleButtonの座標内にあるかどうかを判断し、ある場合はYESを返し、逆の場合はNOを返します。 このようにして、赤い部分をクリックすると、そのイベントは最終的にCircleButtonに渡され、CircleButtonはそのイベントに応答することができます。のレスポンスになります。同時に、ケースの非TabBar領域の外側の赤いボックスをクリックすると、TabBarはイベントに応答することはできませんので、TableViewの応答によって期待されます。コードは次のとおりです:

//TabBar
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{ 
 //タッチポイントの座標をCircleButtonの座標に変換する
 CGPoint pointTemp = [self convertPoint:point toView:_CircleButton]; 
 //タッチポイントがCricleButtonの場合、YESを返す。
 if ([_CircleButton pointInside:pointTemp withEvent:event]) { 
 return YES; 
 } 
 //そうでない場合は、デフォルトのアクション
 return [super pointInside:point withEvent:event];
}

これにより、赤枠部分のボタンをクリックすることが有効になります。

これで、2番目の質問にも答えることができます。また、もしプロジェクトが通常とは異なるイベント・レスポンス要件に遭遇しても、対応できるはずです。

Hit-Testingを経験した後、UIApplicationはすでにイベントのベストレスポンダが誰であるかを知っています:

  1. イベントを最良のレスポンダに渡すことです。

  2. イベントはレスポンスチェーンに沿って渡されます

最良のレスポンダはイベントレスポンスの優先順位が最も高いため、UIApplication はイベントを最初にそのレスポンダに渡します。まず、UIApplicationはイベントをsendEvent:を介して所属するウィンドウに渡し、ウィンドウはイベントをsendEvent:を介してヒットテストされたビュー、つまりベストレスポンダに渡します。プロセスは以下の通りです:

UIApplication  > UIWindow  > hit-tested view

イベントに対するベストレスポンダを見つけるセクションのヒットテストビューEの例を使って、EViewのtouchesBegan: withEvent:のコールスタックを壊し、コールスタックを見ることでプロセスを見ることができます:

touchesBegan コールスタック

ここでまた問題が発生します。アプリケーションに複数のウィンドウ・オブジェクトがある場合、UIApplicationはどのウィンドウにイベントを渡すべきか、また、ウィンドウはどのビューが最適なレスポンダであるかをどのように知るのでしょうか?

実際、単純に考えてみてください。これらの2つのプロセスは、イベントを渡すプロセスであり、メソッドがsendEvent:であり、メソッドのパラメータがプロセス全体を通して唯一の手がかりであり、この情報のバインディングでタッチイベントオブジェクトにバインドされていることを大胆に推測することができます。実際、UITouchの紹介の中で、タッチオブジェクトはタッチが属するウィンドウとビューを保持し、イベントオブジェクトはタッチオブジェクトにバインドされていると書きましたが、理にかなっていると思いませんか?信じられない場合は、Windowクラスをカスタマイズして、sendEvent:メソッドをオーバーライドし、そのメソッドが呼び出されたときのイベントパラメータの状態をキャプチャすれば、答えは明らかです。

sendEvent

この2つのプロパティがタッチ オブジェクトにバインドされるタイミングについては、ヒット テストの最中でなければなりません。

先ほど UIResponder を紹介したときに述べたように、すべてのレスポンダは UIResponder オブジェクトであり、4つのメソッドを通してタッチイベントに応答します。各 UIResponder オブジェクトはデフォルトでこれら 4 つのメソッドを実装していますが、デフォルトではイベントを処理せず、単にレスポンスチェーンに沿ってイベントを渡します。イベントをインターセプトしてカスタムレスポンスを実行したい場合は、メソッドをオーバーライドする必要があります。例えば、単純なビューのドラッグは touchesMoved: withEvent: メソッドをオーバーライドすることで実現できます。

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;

タッチイベントに応答する各メソッドは、タッチオブジェクトコレクションとイベントオブジェクトに対応する2つの引数を受け取ります。ビューの位置は、タッチオブジェクトに格納されたタッチポイントの位置の変化をリスニングすることで、随時変更することができます。レスポンダオブジェクトとしてのビューは、touchesMoved: withEvent:メソッド自体をすでに実装しているので、そのメソッドをオーバーライドするカスタムビューを作成します。

//MovedView
//touchesMovedメソッドをオーバーライドする
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ 
 //タッチオブジェクトを取得する
 UITouch *touch = [touches anyObject]; 
 //前のタッチポイントの位置を取得する
 CGPoint prePoint = [touch previousLocationInView:self]; 
 //現在のタッチポイントの位置を取得する
 CGPoint curPoint = [touch locationInView:self]; 
 //オフセットを計算する
 CGFloat offsetX = curPoint.x - prePoint.x; 
 CGFloat offsetY = curPoint.y - prePoint.y; 
 //相対位置オフセットビュー
 self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY);
}

各レスポンダは、関連するタッチイベントメソッドをオーバーライドするだけで、イベントに対する応答を実行するかどうかを決定する権利を持ちます。

ベストレスポンダとは、イベントに応答する優先順位が最も高いレスポンダのことです。最良のレスポンダは最初にイベントを受信し、そのイベントを絶対的に制御できます。イベントを自分のものにするか、他のレスポンダにイベントを渡すかを選択でき、レスポンダによって形成されるビューの連鎖をレスポンスチェーンと呼びます。

CircleButton --> CustomeTabBar --> UIView --> UIViewController --> UIViewControllerWrapperView --> UINavigationTransitionView --> UILayoutContainerView --> UINavigationController --> UIWindow --> UIApplication --> AppDelegate

レスポンダによるイベントの受信と受け渡しは、touchesBegan:withEvent: メソッドによって制御されます。

レスポンダは受け取ったイベントに対して3つのアクションを行います:

  • イベントは自動的にデフォルトのレスポンスチェーンに渡されます。イベントは自動的にデフォルトのレスポンスチェーンに渡されます。

  • インターセプトする、イベントをチェーンに流さない親クラスの touchesBegan:withEvent: を呼び出さずに、 touchesBegan:withEvent: をオーバーライドしてイベントを処理します。

  • をオーバーライドし、イベントを連鎖的に配り続けます。touchesBegan:withEvent:をイベント処理のために書き直し、親クラスのtouchesBegan:withEvent:を呼び出してイベントをイベントの連鎖の下に渡します。

すべてのレスポンダオブジェクトは、レスポンスチェイン内の現在のオブジェクトの次のレスポンダを取得する nextResponder メソッドを持っています。したがって、イベントに最適なレスポンダが決定されると、 そのイベントが属するレスポンスチェーンが決定されます。

レスポンダオブジェクトのデフォルトの nextResponder の実装は以下の通りです:

  • UIWindownextResponder は UIApplication オブジェクトです。

  • 認識成功: 可能> Recognized

上図は公式サイトのレスポンスチェーンの表示例で、UITextFieldにタッチが発生した場合、イベント配信の順序は次のようになります:

UITextField  > UIView  > UIView  > UIViewController  > UIWindow  > UIApplication  > UIApplicationDelegation

レスポンスチェイン内の各レスポンスオブジェクトを以下の方法で出力し、最適なレスポンダのtouchBegin:withEvent:メソッドで呼び出します。

- (void)printResponderChain{
 UIResponder *responder = self; 
 printf("%s",[NSStringFromClass([responder class]) UTF8String]); 
 while (responder.nextResponder) { 
 responder = responder.nextResponder; 
 printf(" --> %s",[NSStringFromClass([responder class]) UTF8String]); 
}}

例えば、前節のプロトタイプボタンの場合、CircleButton の touchBegin:withEvent: メソッドを書き換えます。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
 [self printResponderChain]; 
 [super touchesBegan:touches withEvent:event];
}

プロトタイプボタンの任意の領域をクリックすると、以下のようにレスポンスチェーンが出力されます:

CircleButton --> CustomeTabBar --> UIView --> UIViewController --> UIViewControllerWrapperView --> UINavigationTransitionView --> です。UILayoutContainerView --> UINavigationController --> UIWindow --> UIApplication --> AppDelegate

必要に応じて、レスポンダの nextResponder メソッドを完全にオーバーライドしてレスポンスチェーンをカスタマイズすることもできます。

これで、3つ目の問題も解決しました。

iOSでは、イベントに応答できるUIResponderの他に、ジェスチャレコグナイザとUIControlもイベントを処理する機能を持っています。これらが同時にシーンに存在する場合、イベントの行き先はどうなるでしょうか?

シーン・インターフェースを図に示します:

ジェスチャーの衝突シーン

コードはこれ以上ないほどシンプルです:

- (void)viewDidLoad { 
 [super viewDidLoad]; 
 //一番下はクリックジェスチャがバインドされたbackViewである。
 UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(actionTapView)]; 
 [_backView addGestureRecognizer:tap]; 
 //上記は通常のtableViewである
 _tableMain.tableFooterView = [UIView new]; 
 //tableViewの兄弟ボタンもある。
 [_button addTarget:self action:@selector(buttonTap) forControlEvents:UIControlEventTouchUpInside];
} 
- (void)actionTapView{ 
 NSLog(@"backview taped");
} 
- (void)buttonTap { 
 NSLog(@"button clicked!");
} 
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
 NSLog(@"cell selected!");
}

いつものように、自信満々でセルをクリックしました。動かない?曲がってる?もう一度タップしてみても、やはり動きません!セルを少し短押ししてみましたが、やはりダメ!セルを長押ししてみると、didSelectRowAtIndexPathがようやく機能しました。それから下のボタンをクリックしましたが、全く問題ありませんでした。

状況を把握するために、関連するコントロールクラスをカスタマイズし、タッチイベントに応答する4つのメソッドをオーバーライドしてログを出力しました。

いろいろな場合のログ現象を観察してみましょう:

現象1 セルを素早くクリックした場合

バックビューのテープ

現象2 セルを短く押す

backview taped

現象3 セルを長押し

-[GLTableView touchesBegan:withEvent:]
backview taped
-[GLTableView touchesCancelled:withEvent:]

現象4 クリックボタン

-[GLTableView touchesBegan:withEvent:]
-[GLTableView touchesEnded:withEvent:]
cell selected!

上記の現象を見てもまだコーヒーを一口飲んでしまうようなら、おめでとうございます。もしあなたが混乱しているようなら、この先を読んでください!

UIGestureRecognizerの使い方そのものは、この記事の主題ではありませんので、ここでは割愛します。ここで議論されるのは

実は、ジェスチャーには離散ジェスチャーと連続ジェスチャーがあります。システムが提供する個別のジェスチャーにはタップ・ジェスチャーとスワイプ・ジェスチャーがあり、それ以外は連続的なジェスチャーです。

両者の主な違いは、状態変化プロセスです:

  • 認識の失敗: 可能性あり> Failed

  • 認識失敗:可能→失敗

  • 完全認識:可能→開始→[変更]→終了

  • 不完全認識:可能→開始→[変更]→キャンセル

上記のシナリオはさておき、簡単なデモを見てみましょう。

コントローラのビューにYellowViewというラベルのViewが追加され、クリックジェスチャ認識器がバインドされています。

-[GLButton touchesBegan:withEvent:]
-[GLButton touchesEnded:withEvent:]
button clicked!

YellowViewをクリックすると、ログに次のように表示されます:

// LXFViewController
- (void)viewDidLoad {
 [super viewDidLoad]; 
 UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(actionTap)]; 
 [self.view addGestureRecognizer:tap];
}
- (void)actionTap{
 NSLog(@"View Taped");
}

通常、YellowViewのtouchesEnded:withEvent:メソッドはタッチが終了した後に呼び出されるはずです。また、この間にジェスチャーレコグナイザーのバインドアクションが実行されています。公式ドキュメントにこんな説明がありました:

ウィンドウはタッチイベントをジェスチャレコグナイザに配信してから、ジェスチャレコグナイザに接続されたヒットテストビューに配信します。 一般的に一般に、ジェスチャ認識器がマルチタッチ シーケンスのタッチ ストリームを分析し、そのジェスチャを認識しなかった場合、ビューは完全なタッチ イベントを受け取ります。一般に、ジェスチャ認識器がマルチタッチシーケンスのタッチのストリームを分析し、そのジェスチャを認識しない場合、ビューはタッチの完全なコンプリメントを受け取ります。ジェスチャ認識の通常のアクションのシーケンスは、cancelsTouchesInView、delaysTouchesBegan、delaysTouchesEndedプロパティのデフォルト値によって決定されるパスに従います。

一般的な考え方は、Windowがヒット テストされたビューにイベントを渡すときに、最初に関連するジェスチャ認識機能にイベントを渡し、ジェスチャ認識機能が最初にイベントを認識するというものです。ジェスチャ レコグナイザがイベントの認識に成功すると、イベントに対するヒット テスト ビューの応答はキャンセルされ、ジェスチャ レコグナイザがイベントの認識に失敗すると、イベントに対する応答はヒット テスト ビューに引き継がれます。

簡単に言うと

この説明によると、Windowはヒットテストされたビュー、つまりYellowViewにイベントを渡し、最初にコントローラのルートビューのジェスチャ認識器にイベントを渡します。ジェスチャレコグナイザはイベントを正常に認識し、イベントに対するYellowViewの応答をキャンセルするようにアプリケーションに通知します。

しかし、ログを見ると、最初に呼び出されるのはYellowViewのtouchesBegan:withEvent:です。 ジェスチャレコグナイザが最初に応答するので、上記のアクションが最初に実行されるべきではないでしょうか?実際、この認識は間違っています。実際、この認識は間違っています。 ジェスチャー認識器がアクションを実行するタイミングは、ジェスチャー認識器がイベントを受信したときではなく、イベントを正常に認識したとき、つまりジェスチャー認識器の状態がUIGesstureRecognizerStateRecognizedに変化したときです。したがって、イベントがジェスチャー認識器に最初に渡されることは、ログからは明らかではありません。

この問題を解決するには、ジェスチャ認識器がどのようにイベントを受け取るかがわかりさえすれば、イベントを受け取ったメソッドのログを表示して、呼び出し時間の順序を比較することができます。信じられないかもしれませんが、イベントに対するジェスチャー認識器の応答も、これら4つのおなじみのメソッドによって実現されています。

-[YellowView touchesBegan:withEvent:]
View Taped
-[YellowView touchesCancelled:withEvent:]

ジェスチャ認識器はこれらのメソッドを通じてイベントに応答しますが、UIResponder のサブクラスではなく、関連するメソッドは UIGestureRecognizerSubclass.h で宣言されていることに注意してください。

このように、クリック・ジェスチャー認識用のカスタム・クラスを作成し、これらのメソッドをオーバーライドして、ジェスチャー認識器がイベントを受信したときにリッスンすることができます。UITapGestureRecognizer のサブクラスを作成し、イベントに応答するメソッドをオーバーライドして、各メソッドで親クラスの実装を呼び出し、デモのジェスチャ認識器を置き換えます。また、関連するメソッドの宣言がそのヘッダー ファイルにあるため、.m ファイルに import を導入する必要があります。

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

さて、YellowViewをもう一度クリックすると、ログは以下のようになります:

// LXFTapGestureRecognizer (UITapGestureRecognizerから継承)
- (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent *)event{
 NSLog(@"%s",__func__); 
 [super touchesBegan:touches withEvent:event];
}
- (void)touchesMoved:(NSSet*)touches withEvent:(UIEvent *)event{
 NSLog(@"%s",__func__); 
 [super touchesMoved:touches withEvent:event];
}
- (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent *)event{
 NSLog(@"%s",__func__); 
 [super touchesEnded:touches withEvent:event];
}
- (void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent *)event{
 NSLog(@"%s",__func__); 
 [super touchesCancelled:touches withEvent:event];
}

最初にイベントを受信するのはジェスチャー認識機能であることがわかります。その後、ジェスチャー認識機能がジェスチャーを正常に認識し、アクションを実行し、アプリケーションはイベントに対するYellowViewの応答をキャンセルしました。

アプリケーションはどのWindowにイベントを渡すのか、Windowはどのヒットテストされたビューにイベントを渡すのかをどのように知っているのかという疑問について調べましたが、答えは、これらの情報はすべてイベントがバインドされているタッチオブジェクトに保存されているということです。イベントがバインドされているタッチ オブジェクトには、ジェスチャ レコグナイザの配列が保持されています。touch にバインドされているジェスチャ レコグナイザの配列を見てみましょう:

ジェスチャ レコグナイザがジェスチャを正常に認識すると、アプリケーションはイベントに対するヒット テスト ビューの応答をキャンセルします。

上記のデモのビューにバインドされているクリックジェスチャ認識器をスワイプジェスチャ認識器に置き換えてください。

-[LXFTapGestureRecognizer touchesBegan:withEvent:]
-[YellowView touchesBegan:withEvent:]
-[LXFTapGestureRecognizer touchesEnded:withEvent:]
View Taped
-[YellowView touchesCancelled:withEvent:]

YellowViewでスライドを実行します:

ログは次のように表示されます:

- (void)viewDidLoad {
 [super viewDidLoad];
 UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(actionPan)];
 [self.view addGestureRecognizer:pan];
}
- (void)actionPan{
 NSLog(@"View panned");
}

スライド処理の開始時、ジェスチャー認識器はジェスチャーを認識している段階にあり、スライドによって生成された連続イベントはジェスチャー認識器とYellowViewの両方に渡されるため、YellowViewのtouchesMoved: withEvent:は開始時に一定時間連続的に呼び出されます。ジェスチャ認識器がジェスチャの認識に成功すると、認識器のアクションが呼び出され、YellowViewのイベントへの応答をキャンセルするようアプリケーションに通知されます。その後、スライディングジェスチャー認識だけがイベントを受信して応答し、YellowViewはイベントを受信しなくなります。

また、ジェスチャー認識機能がスライド処理中にジェスチャーを認識できなかった場合、タッチが終了するまで、タッチスライド処理中にヒットテストされたビューにイベントが渡されます。このことは、読者自身で確認してください。

-[YellowView touchesBegan:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
View panned
-[YellowView touchesCancelled:withEvent:]
View pannedView pannedView panned...

ジェスチャー認識器とUIResponderのイベントレスポンスの関係をまとめてみましょう:

タッチが発生したとき、またはタッチの状態が変化したとき、Window はイベントを渡して応答を求めます。

  • Window はまず、タッチ オブジェクトにバインドされたイベントをタッチ オブジェクトにバインドされたジェスチャ レコグナイザに渡し、そのイベントをタッチ オブジェクトの対応するヒット テスト済みビューに送信します。

  • ジェスチャ レコグナイザがジェスチャを認識している間にタッチ オブジェクトのタッチ状態が変更されると、イベントはまずジェスチャ レコグナイザに送信され、次にヒット テストされたビューに送信されます。

  • ジェスチャが正常に認識されると、アプリケーションに通知してイベントに対するヒット テスト済みビューの応答をキャンセルし、ヒット テスト済みビューへのイベントの送信を停止します;

  • ジェスチャ認識が失敗し、この時点でタッチが終了しない場合、ジェスチャ認識へのイベント送信を停止し、ヒット テスト済みビューにのみイベントを送信します。

  • target-action実行タイミングと処理

デフォルト値はYESで、これはジェスチャ認識器がジェスチャの認識に成功したときに、応答チェーンをキャンセルしてイベントをヒット テスト ビューに渡さないようにアプリケーションに通知することを意味します。

デモでの設定: pan.cancelsTouchesInView = NO

スライド時のログは次のようになります:

@property(nonatomic) BOOL cancelsTouchesInView;
@property(nonatomic) BOOL delaysTouchesBegan;
@property(nonatomic) BOOL delaysTouchesEnded;

スワイプジェスチャ認識機能でジェスチャが認識されても、アプリケーションはYellowViewにイベントを送信します。

デフォルトはNOです。デフォルトでは、ジェスチャ認識機能がジェスチャを認識し、タッチ状態が変化すると、アプリケーションはジェスチャ認識機能とヒットテストされたビューの両方にイベントを渡します。YESに設定されている場合、ジェスチャ認識機能はジェスチャ認識中にイベントを切り捨てます。

pan.delaysTouchesBegan = YES を設定します。

ログは次のとおりです:

-[YellowView touchesBegan:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
View panned
-[YellowView touchesMoved:withEvent:]
View pannedView panned
-[YellowView touchesMoved:withEvent:]
View panned
-[YellowView touchesMoved:withEvent:]...

認識期間中はYellowViewにイベントが渡されないため、認識期間中はYellowViewのtouchesBegan:withEvent:もtouchesMoved:withEvent:も呼び出されず、ジェスチャーがスライダーによって正常に認識された後、イベントはキャプチャされ、再びYellowViewに渡されることはありません。そのため、ジェスチャー認識機能がジェスチャーを認識した後のアクション呼び出しのみが出力されます。

デフォルトがYESの場合、ジェスチャー認識が失敗したときに、タッチがすでに終了していれば、少し待ってからレスポンダのtouchesEnded:withEvent:を呼び出します。touchesEnded:withEvent:を呼び出してイベント応答を終了します。

If you specify nil for the target object, the control searches the responder chain for an object that defines the specified action method.

iOSのUIButton、UISegmentedControl、UISwitchなどのコントロールはUIControlのサブクラスです。UIControlがタッチイベントを追跡すると、それに追加されたターゲットにイベントを送信してアクションを実行します。UIConotrolはUIViewのサブクラスであるため、UIResponderのステータスを持っていることは注目に値します。

ここでは、UIControlに関する2つの点について説明します:

  1. タッチ イベントの優先順位

  2. タッチイベントの優先順位

  • target: インタラクションイベントを処理するオブジェクト

  • action: インタラクションイベントの処理方法

イベントに応答するコントロールであるUIControlは、インタラクションが条件を満たしたときにもイベントに応答する必要があるため、イベントのプロセスもトラッキングします。タッチによってイベントを追跡するUIControlやUIGestureRecognizerとは異なり、UIControlは独自の方法でイベントを追跡します:

View pannedView pannedView pannedView panned...

一見したところ、これら4つのメソッドはUIResponderのメソッドとほとんど同じですが、UIControlは1つのタッチしか受け取ることができないため、パラメータとして1つのUITouchオブジェクトを受け取ります。これらのメソッドの機能もUIResponderのものと同じで、タッチの開始、スワイプ、終了、キャンセルを追跡するために使用されます。ただし、UIControl自体もUIResponderなので、touchファミリーと同じ4つのメソッドを持っています。実際、UIControlのTrackingシリーズのメソッドは、touchシリーズのメソッドの中で呼び出されます。例えば、beginTrackingWithTouchはtouchesBeganメソッドの中で呼び出されるので、UIResponderであるにもかかわらず、touches系列のメソッドのデフォルトの実装はUIResponderクラスとは異なります。

UIControlがイベントをトラッキングし、イベントインタラクションが応答条件を満たしていると認識すると、ターゲットアクションをトリガーして応答します。UIControlコントロールは、addTarget:action:forControlEvents:を介してイベント処理のターゲットとアクションを追加し、イベントが発生すると、UIControlはイベントのターゲットを通知します。イベントが発生すると、UIControlは対応するアクションを実行するようにターゲットに通知します。これは非常に一般的な用語ですが、実際にはアクションの受け渡しがあります。UIControlは、処理する必要のあるインタラクションイベントをリスンすると、sendAction:to:forEvent:を呼び出して、ターゲット、アクション、イベントオブジェクトをグローバルアプリケーションに送信し、グローバルアプリケーションは、sendAction:to:from:forEvent:を介してターゲットにアクションを送信します。アプリケーションオブジェクトは、sendAction:to:from:forEvent:を使ってターゲットにアクションを送信します。

さらに、ターゲットを指定しない場合、つまりaddTarget:action:forControlEvents:でターゲットにnullを渡した場合、イベントが発生すると、アプリケーションはレスポンスチェーンのトップダウンからアクションに応答できるオブジェクトを探します。公式の説明は以下の通りです:

ターゲットオブジェクトに nil を指定すると、コントロールはレスポンダチェインから指定されたアクションメソッドを定義するオブジェクトを探します。

すでに複雑な関係にある UIGestureRecognizer と UIResponder の間に UIControl が飛び込んできた場合、どのような火花が散るのでしょうか?

iOS 6.0以降では、デフォルトのコントロール・アクションによって、ジェスチャ・レコグナイザの動作が重複しないようになっています。 たとえば、ボタンのデフォルト・アクションはシングル・タップです。ボタンの親ビューにシングルタップのジェスチャ認識機能がアタッチされていて、ユーザがボタンをタップした場合、ボタンのアクションメソッドはジェスチャー認識機能の代わりにタッチイベントを受け取ります。this " はジェスチャー認識機能にのみ適用されます。" " は、コントロールのデフォルトのアクションと重複するジェスチャ認識にのみ適用されます。

UIButton、UISwitch、UIStepper、UISegmentedControl、および UIPageControl への指一本によるシングルタップ。

UISlider のノブを、スライダと平行な方向に、指一本でスワイプすること。

UISliderのノブ上での、スライダーに平行な方向へのシングルフィンガー・スワイプ。 UISwitchのノブ上での、スイッチに平行な方向へのシングルフィンガー・パン・ジェスチャー。

つまり、UIControlはUIGestureRecognizerよりも高い優先度でイベントを処理しますが、親ビューのジェスチャ認識機能と比較した場合のみです。

UIControlのテストシナリオ

プリセットシナリオ:BlueViewにボタンを追加し、同時にボタンにtarget-actionイベントを追加します。

  • 例1: BlueViewにクリックジェスチャ認識機能を追加します。

  • 例2: ボタンにジェスチャー認識機能を追加します。

アクション:ボタンをクリック

テスト結果:例1では、ボタンのターゲットアクションがクリックイベントに応答し、例2では、BlueView上のジェスチャー認識器がイベントに応答します。プロセスログは以下のように出力されます:

- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event;
- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event;
- (void)endTrackingWithTouch:(nullable UITouch *)touch withEvent:(nullable UIEvent *)event;
- (void)cancelTrackingWithEvent:(nullable UIEvent *)event;

ボタンのクリック

// 
-[CLTapGestureRecognizer touchesBegan:withEvent:]
-[CLButton touchesBegan:withEvent:]
-[CLButton beginTrackingWithTouch:withEvent:]
-[CLTapGestureRecognizer touchesEnded:withEvent:]
 after called state = 5
-[CLButton touchesEnded:withEvent:]
-[CLButton endTrackingWithTouch:withEvent:]

理由分析:ボタンをクリックした後、イベントはまずジェスチャー認識器に渡され、次にヒットテストされたビューとして存在するボタンに渡されます。例1では、親ビューBlueViewのジェスチャー認識器がボタンを認識するのを妨げられ、ジェスチャー認識器がボタンを認識できず、ボタンがイベントに応答する権利を完全に引き継ぎ、最終的にボタンが応答します。例2では、ボタンのターゲットアクションがクリックイベントに応答し、例2では、BlueViewのジェスチャー認識器がイベントに応答します。例2では、ボタンはバインドされているジェスチャー認識器の認識をブロックしないため、ジェスチャー認識器はまずジェスチャーを認識し、正常に認識します。その後、touchesCancelledが呼び出され、cancelTrackingWithEventがその後に呼び出されるため、ジェスチャー認識器がジェスチャーを認識できないため、イベントに応答して応答チェーンをキャンセルするようにアプリケーションに通知します。の後にcancelTrackingWithEventが呼び出されるため、ボタンのターゲットアクションは実行されません。

その他: テストした結果、例1のジェスチャ認識器がcancelsTouchesInViewをNOに設定すると、ジェスチャ認識器とボタンの両方がイベントに応答できるようになりました。つまり、この場合、ボタンは親ビューのジェスチャ認識機能がジェスチャを認識するのを妨げません。

結論: UIControlは、親ビューのジェスチャ認識機能よりも高いイベント応答優先度を持ちます。

TODO

上記の処理において、ジェスチャ認識器がtouchsEndedを実行したときに、どのような根拠に基づいて状態を終了または失敗とするのでしょうか?つまり、認識に成功すべきか失敗すべきかの判断基準は何でしょうか?

上記の「UIControlのレスポンス優先度がジェスチャレコグナイザのレスポンス優先度より高い」という記述は正確ではなく、UIbuttonやUISwitchなどのデフォルトアクションを持つシステム提供のUIControlにのみ適用され、カスタムUIControlの場合、レスポンス優先度はジェスチャレコグナイザのそれよりも低いことが確認されています。読者の方はご自身で検証してみてください。訂正してくださった@YanShiwei氏に感謝します。

さて、この章の冒頭のシーンまでフィルムを巻き戻してください。あなたがそれらのいくつかの現象を説明できるかどうかを確認するためにコーヒーの時間を与える、コーヒーを作るとは言わない...

私は太っています!

最初に見て、セルの短いプレスが応答しない、ログは次のとおりです:

// 
-[CLTapGestureRecognizer touchesBegan:withEvent:]
-[CLButton touchesBegan:withEvent:]
-[CLButton beginTrackingWithTouch:withEvent:]
-[CLTapGestureRecognizer touchesEnded:withEvent:]
 after called state = 3ジェスチャーのトリガー
-[CLButton touchesCancelled:withEvent:]
-[CLButton cancelTrackingWithEvent:]

このログは、上記の離散ジェスチャーのデモで印刷されたものとまったく同じです。短く押すと、まずBackViewのジェスチャー認識器がイベントを受け取り、その後イベントがヒットテストビューに渡され、レスポンダチェインのメンバであるGLTableViewのtouchBegan:withEvent:が呼び出されます。アプリケーションはレスポンスチェーン内のイベントレスポンスをキャンセルし、GLTableViewのtouchCancelled:withEvent:が呼び出されます。

イベントがキャンセルされたので、Cellはクリックに応答できません。

もう一度見てみると、長押ししたCellは以下のログで応答できています:

-[GLTableView touchesBegan:withEvent:]
backview taped
-[GLTableView touchesCancelled:withEvent:]

長押しの間、まずイベントはジェスチャー認識とヒットテストビューに渡され、レスポンスチェーンのメンバーとしてGLTableViewのtouchesBegan:withEvent:が呼び出されます。レスポンスをレスポンスチェーンに完全に渡します。タッチが終了すると、GLTableViewのtouchesEnded: withEvent:が呼び出され、Cellがクリックに応答します。

さて、話を戻します。分析によると、セルを素早くクリックすると、パフォーマンスとログの両方が合理的に一致するはずです。しかし、ログにはジェスチャー認識機能の実行結果しか表示されません。理由を分析します: GLTableViewのtouchesBeganが呼び出されていない、つまり、イベントがヒットテストされたビューに渡されていない場合、可能性は1つしかありません。ジェスチャ レコグナイザがイベントをインターセプトする唯一の既知の方法は、delaysTouchesBegan を YES に設定して、ジェスチャ レコグナイザがイベントを認識し終わるまでイベントがヒット テスト ビューに渡されないようにすることです。

WindowのsendEvent: ブレークポイントで、イベント時にタッチ オブジェクトが保持するジェスチャ認識器の配列を確認できます:

ScrollView がイベントの送信を遅延

怪しいオブジェクトのキャプチャ: UIScrollViewDelayedTouchesBeganGestureRecognizer 、名前を見ただけで、これは抜け出せないと思います。クラス名から推測すると、このジェスチャー認識器は、おそらくレスポンスチェーンへのイベントの配信を遅延させています:

-[GLTableView touchesBegan:withEvent:]
-[GLTableView touchesEnded:withEvent:]
cell selected!

touchDelay変数があり、おそらく遅延イベント配信を制御するために使用されています。また、メソッドリストに sendTouchesShouldBeginForDelayedTouches: メソッドがあり、時間遅延後にレスポンスチェーンにイベントを配信するために使用されているようです。これを調べるために、このメソッドをフックするクラスを作りました:

@interface UIScrollViewDelayedTouchesBeganGestureRecognizer : UIGestureRecognizer {
 UIView* _client; 
 struct CGPoint { 
 float x; 
 float y; 
 }_startSceneReferenceLocation;
 UIDelayedAction * _touchDelay;
}
- (void).cxx_destruct;
- (id)_clientView;
- (void)_resetGestureRecognizer;
- (void)clearTimer;
- (void)dealloc;
- (void)sendDelayedTouches;
- (void)sendTouchesShouldBeginForDelayedTouches:(id)arg1;
- (void)sendTouchesShouldBeginForTouches:(id)arg1 withEvent:(id)arg2;
- (void)touchesBegan:(id)arg1 withEvent:(id)arg2;
- (void)touchesCancelled:(id)arg1 withEvent:(id)arg2;
- (void)touchesEnded:(id)arg1 withEvent:(id)arg2;
- (void)touchesMoved:(id)arg1 withEvent:(id)arg2;
@end

遅延の性質

このジェスチャレコグナイザは、_touchDelay変数に格納されたタイマーと、m_delayという遅延間隔のような変数を持っていることがわかります。TableViewのsendEvent:、hok_sendTouchesShouldBeginForDelayedTouches:、touchesBegan:です。 もし推測が正しければ、最初の2つの呼び出し時間は約0.15s離れていて、最後の2つの呼び出し時間は非常に近いはずです。Cellを短く押した結果は以下のようになります:

//TouchEventHook.m
+ (void)load{ 
 Class aClass = objc_getClass("UIScrollViewDelayedTouchesBeganGestureRecognizer");
 SEL sel = @selector(hook_sendTouchesShouldBeginForDelayedTouches:); 
 Method method = class_getClassMethod([self class], sel); 
 class_addMethod(aClass, sel, class_getMethodImplementation([self class], sel), method_getTypeEncoding(method)); 
 exchangeMethod(aClass, @selector(sendTouchesShouldBeginForDelayedTouches:), sel);
} 
- (void)hook_sendTouchesShouldBeginForDelayedTouches:(id)arg1{
 [self hook_sendTouchesShouldBeginForDelayedTouches:arg1];
} 
void exchangeMethod(Class aClass, SEL oldSEL, SEL newSEL) { 
 Method oldMethod = class_getInstanceMethod(aClass, oldSEL);
 Method newMethod = class_getInstanceMethod(aClass, newSEL);
 method_exchangeImplementations(oldMethod, newMethod);
}

これですべて納得。現象Iクリックにより、UISclolViewDelayedTouchesBeganGestureRecognizerがイベントをインターセプトし、0.15s遅れて送信されました。そして、クリックの時間が0.15秒より短いため、イベントが送信される前にタッチが終了し、ヒットテストされたビューにイベントが渡されず、その結果、TableViewのtouchBeginが呼び出されません。現象2については、短押の時間が0.15秒以上であったため、ジェスチャー認識器がイベントをインターセプトし、0.15秒経過してもタッチが終了していないため、ヒットテストビューにイベントが渡され、TableViewがイベントを受信しています。つまり、現象2のログは離散ジェスチャーのデモと同じですが、前者のヒットテストビューはタッチ後約0.15秒遅れてタッチイベントを受信しています。

現象4については、もう当たり前のことでしょう。

  • タッチが発生すると、システムカーネルはタッチイベントを生成し、それはまずIOKitによって処理され、IOHIDEventオブジェクトにカプセル化されます。

  • イベントはAPP内部で渡されると、開発者が見えるUIEventオブジェクトにカプセル化され、まずヒットテストを通過して最初のレスポンダを見つけ、次にWindowオブジェクトがヒットテストされたビューにイベントを渡し、レスポンスチェーンでの受け渡しを開始します。



  1. イベント処理、レスポンダ、レスポンダチェインの理解

  2. iOSのタッチイベントの流れ

  3. UIKit: UIControl



Read next

メジャーを表示する

画面上にビューを描画するには、ビューのサイズを知る必要があります。そのため、最初に計測を行い、次にルールに従ってビューのレイアウトを配置し、最後に描画を行います。そして、ここでの計測は描画プロセス全体の最初ですが、カスタムViewも知識ポイントを理解する必要があります。 ...

Mar 22, 2020 · 6 min read