superviewの範囲外に配置されたviewにタッチイベントを送信する

かんばんわ。

表題の件について、色々調べていたので現時点の状況を備忘としてまとめます。
同じような問題で困ってる方が居れば是非参考にしてください。

次のようなプロジェクトを作りました。

  • UIViewに250x300の背景ビューをaddSubviewする
  • 背景ビューに50x50のパネルビューをaddSubviewする
  • パネルビューをドラッグするとパネルビューが動く

簡単に言うと、画面上のパネルをドラッグすると動きます、というだけのプロジェクトです。
こんなかんじ。


動きます。ぐいーん。

パネルビュークラスのtouchesMovedメソッドの実装コードは以下の通り。

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    CGRect rect = self.frame;
    UITouch* touch = [touches anyObject];
    
    CGPoint newPoint = [touch locationInView:self];
    CGPoint prePoint = [touch previousLocationInView:self];
    rect.origin.x += newPoint.x - prePoint.x;
    rect.origin.y += newPoint.y - prePoint.y;
    
    self.frame = rect;
}

しかしこのパネルビューは、背景ビューの範囲外にまでドラッグできてしまいます。
パネルビュー、親離れ。

そして範囲外で指を離してしまうと、もうパネルビューをタップしてもドラッグしてもタッチイベントを受け取ることができません。
そもそもsuperviewである背景ビューがタッチイベントを受け取らないので当たり前なのですが。
そこは iOS Developer Library の「Event Handling Guide for iOS」(iOSイベント処理ガイド)にしっかり書いています。

しかしどうしても範囲外に置き去りにされたパネルビューでイベントを受け取りたい。
この答えも「Event Handling Guide for iOS」にありました。さすがAppleさんやで…。

方法の一つに「UIWindowのサブクラスでsendEvent:メソッドをオーバーライドすればええで」と書いているので、今回はそれで実装してみました。

まずはUIWindowクラスを継承したCustomWindowクラスを作成します。
次にリファレンスのコードを参考にsendEvent:をオーバーライドしてみます。
こんな感じ。

- (void)sendEvent:(UIEvent *)event {
    NSSet* touches = [event allTouches];
    AppDelegate* appdelegate = [[UIApplication sharedApplication] delegate];
    NSArray* panels = [appdelegate.viewController getPanelViews];
    
    for (UIView* panel in panels) {
        NSMutableSet* began = nil;
        NSMutableSet* moved = nil;
        NSMutableSet* ended = nil;
        NSMutableSet* cancelled = nil;
        
        for (UITouch* touch in touches) {
            CGPoint touchPoint = [touch locationInView:panel];
            // タッチ座標がパネルビューの範囲内か確認 
            // 範囲外だったら無視する 
            if ((CGRectGetWidth(panel.frame)  >= touchPoint.x) && (touchPoint.x > 0) &&
                (CGRectGetHeight(panel.frame) >= touchPoint.y) && (touchPoint.y > 0) ) {
                switch ([touch phase]) {
                    case UITouchPhaseBegan:
                        if (!began) {
                            began = [NSMutableSet set];
                        }
                        [began addObject:touch];
                        break;
                        
                    case UITouchPhaseMoved:
                        if (!moved) {
                            moved = [NSMutableSet set];
                        }
                        [moved addObject:touch];
                        break;
                        
                    case UITouchPhaseEnded:
                        if (!ended) {
                            ended = [NSMutableSet set];
                        }
                        [ended addObject:touch];
                        break;
                        
                    case UITouchPhaseCancelled:
                        if (!cancelled) {
                            cancelled = [NSMutableSet set];
                        }
                        [cancelled addObject:touch];
                        break;
                        
                    default:
                        break;
                }
                // 各種タッチメソッドの呼び出し 
                if (began) [panel touchesBegan:began withEvent:event];
                if (moved) [panel touchesMoved:moved withEvent:event];
                if (ended) [panel touchesEnded:ended withEvent:event];
                if (cancelled) [panel touchesCancelled:cancelled withEvent:event];
            }
        }
    }
    
    [super sendEvent:event];
}

getPanelViewsメソッドは背景ビューにaddSubviewされているパネルビューを配列で取得してきます。
ちょっと不細工ですが、とにかくイベントを送信したいビューが取得できれば何でもいいです。
気をつけないといけないのが、必ずスーパークラスのsendEvent:メソッドを呼び出さないといけないこと。

あとはAppDelegateで使うUIWindowをCustomWindowに差し替えるだけ。

これで実行すると・・・。

おお、ちゃんと背景ビューの範囲外に居るパネルビューがドラッグで移動できる!!
いいね!

しかしここで更なる問題が。

背景ビューの範囲内に居るパネルビューをドラッグすると何故か位置がずれて移動してしまいます。
これは、CustomWindowのsendEvent:内でパネルビューに対してtouchesMoved:を呼び出した後に、[super sendEvent:event]からレスポンダチェーンをたどって再びパネルビューのtouchesMoved:が呼ばれているからです。
touchesMoved:じゃ分かりづらいので、試しにパネルビューのtocuhesBegan:にログを埋め込んでみると二回連続でタッチイベントが呼び出されていることが確認できます。
ちくしょう。

これを回避する方法として、sendEvent:内でtouchesMoved:等のタッチ系のメソッドを呼び出した場合は[super sendEvent:]を呼び出さないことが考えられるのですが、それはパネルビューだけではなくパネルビューのsuperviewに対してもイベントが送信されないことも意味します。(たぶん)
で、それはちょっと怖いので、パネルビューのuserInteractionEnabledをNOに設定しておきました。
これでパネルビューに対しては[super sendEvent:]からのタッチイベントはこないはず。

さて。
この状態で動かすと・・・よしよし、ちゃんと動くね。

ん・・・?

なんか素早くドラッグしたらパネルビューが付いて来れずに置いてけぼりになっちゃってる〜〜〜!!!

えー・・・なんでよ・・・。
この問題の解決方法が分からない。
ので、この方法はとりあえずここまで。

この問題の解決方法をご存知の方が居るなら教えてください(´・ω・`)


追記:
解決しました。
superviewの範囲外に配置されたviewにタッチイベントを送信する おまけ