/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ #import "RCTTouchHandler.h" #import #import "RCTAssert.h" #import "RCTBridge.h" #import "RCTEventDispatcher.h" #import "RCTLog.h" #import "RCTSurfaceView.h" #import "RCTTouchEvent.h" #import "RCTUIManager.h" #import "RCTUtils.h" #import "UIView+React.h" @interface RCTTouchHandler () @end // TODO: this class behaves a lot like a module, and could be implemented as a // module if we were to assume that modules and RootViews had a 1:1 relationship @implementation RCTTouchHandler { __weak RCTEventDispatcher *_eventDispatcher; /** * Arrays managed in parallel tracking native touch object along with the * native view that was touched, and the React touch data dictionary. * These must be kept track of because `UIKit` destroys the touch targets * if touches are canceled, and we have no other way to recover this info. */ NSMutableOrderedSet *_nativeTouches; NSMutableArray *_reactTouches; NSMutableArray *_touchViews; __weak UIView *_cachedRootView; uint16_t _coalescingKey; } - (instancetype)initWithBridge:(RCTBridge *)bridge { RCTAssertParam(bridge); if ((self = [super initWithTarget:nil action:NULL])) { _eventDispatcher = [bridge moduleForClass:[RCTEventDispatcher class]]; _nativeTouches = [NSMutableOrderedSet new]; _reactTouches = [NSMutableArray new]; _touchViews = [NSMutableArray new]; // `cancelsTouchesInView` and `delaysTouches*` are needed in order to be used as a top level // event delegated recognizer. Otherwise, lower-level components not built // using RCT, will fail to recognize gestures. self.cancelsTouchesInView = NO; self.delaysTouchesBegan = NO; // This is default value. self.delaysTouchesEnded = NO; self.delegate = self; } return self; } RCT_NOT_IMPLEMENTED(- (instancetype)initWithTarget:(id)target action:(SEL)action) - (void)attachToView:(UIView *)view { RCTAssert(self.view == nil, @"RCTTouchHandler already has attached view."); [view addGestureRecognizer:self]; } - (void)detachFromView:(UIView *)view { RCTAssertParam(view); RCTAssert(self.view == view, @"RCTTouchHandler attached to another view."); [view removeGestureRecognizer:self]; } #pragma mark - Bookkeeping for touch indices - (void)_recordNewTouches:(NSSet *)touches { for (UITouch *touch in touches) { RCTAssert(![_nativeTouches containsObject:touch], @"Touch is already recorded. This is a critical bug."); // Find closest React-managed touchable view UIView *targetView = touch.view; while (targetView) { if (targetView.reactTag && targetView.userInteractionEnabled) { break; } targetView = targetView.superview; } NSNumber *reactTag = [targetView reactTagAtPoint:[touch locationInView:targetView]]; if (!reactTag || !targetView.userInteractionEnabled) { continue; } // Get new, unique touch identifier for the react touch const NSUInteger RCTMaxTouches = 11; // This is the maximum supported by iDevices NSInteger touchID = ([_reactTouches.lastObject[@"identifier"] integerValue] + 1) % RCTMaxTouches; for (NSDictionary *reactTouch in _reactTouches) { NSInteger usedID = [reactTouch[@"identifier"] integerValue]; if (usedID == touchID) { // ID has already been used, try next value touchID ++; } else if (usedID > touchID) { // If usedID > touchID, touchID must be unique, so we can stop looking break; } } // Create touch NSMutableDictionary *reactTouch = [[NSMutableDictionary alloc] initWithCapacity:RCTMaxTouches]; reactTouch[@"target"] = reactTag; reactTouch[@"identifier"] = @(touchID); // Add to arrays [_touchViews addObject:targetView]; [_nativeTouches addObject:touch]; [_reactTouches addObject:reactTouch]; } } - (void)_recordRemovedTouches:(NSSet *)touches { for (UITouch *touch in touches) { NSUInteger index = [_nativeTouches indexOfObject:touch]; if (index == NSNotFound) { continue; } [_touchViews removeObjectAtIndex:index]; [_nativeTouches removeObjectAtIndex:index]; [_reactTouches removeObjectAtIndex:index]; } } - (void)_updateReactTouchAtIndex:(NSInteger)touchIndex { UITouch *nativeTouch = _nativeTouches[touchIndex]; CGPoint windowLocation = [nativeTouch locationInView:nativeTouch.window]; RCTAssert(_cachedRootView, @"We were unable to find a root view for the touch"); CGPoint rootViewLocation = [nativeTouch.window convertPoint:windowLocation toView:_cachedRootView]; UIView *touchView = _touchViews[touchIndex]; CGPoint touchViewLocation = [nativeTouch.window convertPoint:windowLocation toView:touchView]; NSMutableDictionary *reactTouch = _reactTouches[touchIndex]; reactTouch[@"pageX"] = @(RCTSanitizeNaNValue(rootViewLocation.x, @"touchEvent.pageX")); reactTouch[@"pageY"] = @(RCTSanitizeNaNValue(rootViewLocation.y, @"touchEvent.pageY")); reactTouch[@"locationX"] = @(RCTSanitizeNaNValue(touchViewLocation.x, @"touchEvent.locationX")); reactTouch[@"locationY"] = @(RCTSanitizeNaNValue(touchViewLocation.y, @"touchEvent.locationY")); reactTouch[@"timestamp"] = @(nativeTouch.timestamp * 1000); // in ms, for JS // TODO: force for a 'normal' touch is usually 1.0; // should we expose a `normalTouchForce` constant somewhere (which would // have a value of `1.0 / nativeTouch.maximumPossibleForce`)? if (RCTForceTouchAvailable()) { reactTouch[@"force"] = @(RCTZeroIfNaN(nativeTouch.force / nativeTouch.maximumPossibleForce)); } } /** * Constructs information about touch events to send across the serialized * boundary. This data should be compliant with W3C `Touch` objects. This data * alone isn't sufficient to construct W3C `Event` objects. To construct that, * there must be a simple receiver on the other side of the bridge that * organizes the touch objects into `Event`s. * * We send the data as an array of `Touch`es, the type of action * (start/end/move/cancel) and the indices that represent "changed" `Touch`es * from that array. */ - (void)_updateAndDispatchTouches:(NSSet *)touches eventName:(NSString *)eventName { // Update touches NSMutableArray *changedIndexes = [NSMutableArray new]; for (UITouch *touch in touches) { NSInteger index = [_nativeTouches indexOfObject:touch]; if (index == NSNotFound) { continue; } [self _updateReactTouchAtIndex:index]; [changedIndexes addObject:@(index)]; } if (changedIndexes.count == 0) { return; } // Deep copy the touches because they will be accessed from another thread // TODO: would it be safer to do this in the bridge or executor, rather than trusting caller? NSMutableArray *reactTouches = [[NSMutableArray alloc] initWithCapacity:_reactTouches.count]; for (NSDictionary *touch in _reactTouches) { [reactTouches addObject:[touch copy]]; } BOOL canBeCoalesced = [eventName isEqualToString:@"touchMove"]; // We increment `_coalescingKey` twice here just for sure that // this `_coalescingKey` will not be reused by another (preceding or following) event // (yes, even if coalescing only happens (and makes sense) on events of the same type). if (!canBeCoalesced) { _coalescingKey++; } RCTTouchEvent *event = [[RCTTouchEvent alloc] initWithEventName:eventName reactTag:self.view.reactTag reactTouches:reactTouches changedIndexes:changedIndexes coalescingKey:_coalescingKey]; if (!canBeCoalesced) { _coalescingKey++; } [_eventDispatcher sendEvent:event]; } /*** * To ensure compatibilty when using UIManager.measure and RCTTouchHandler, we have to adopt * UIManager.measure's behavior in finding a "root view". * Usually RCTTouchHandler is already attached to a root view but in some cases (e.g. Modal), * we are instead attached to some RCTView subtree. This is also the case when embedding some RN * views inside a seperate ViewController not controlled by RN. * This logic will either find the nearest rootView, or go all the way to the UIWindow. * While this is not optimal, it is exactly what UIManager.measure does, and what Touchable.js * relies on. * We cache it here so that we don't have to repeat it for every touch in the gesture. */ - (void)_cacheRootView { UIView *rootView = self.view; while (rootView.superview && ![rootView isReactRootView] && ![rootView isKindOfClass:[RCTSurfaceView class]]) { rootView = rootView.superview; } _cachedRootView = rootView; } #pragma mark - Gesture Recognizer Delegate Callbacks static BOOL RCTAllTouchesAreCancelledOrEnded(NSSet *touches) { for (UITouch *touch in touches) { if (touch.phase == UITouchPhaseBegan || touch.phase == UITouchPhaseMoved || touch.phase == UITouchPhaseStationary) { return NO; } } return YES; } static BOOL RCTAnyTouchesChanged(NSSet *touches) { for (UITouch *touch in touches) { if (touch.phase == UITouchPhaseBegan || touch.phase == UITouchPhaseMoved) { return YES; } } return NO; } #pragma mark - `UIResponder`-ish touch-delivery methods - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesBegan:touches withEvent:event]; [self _cacheRootView]; // "start" has to record new touches *before* extracting the event. // "end"/"cancel" needs to remove the touch *after* extracting the event. [self _recordNewTouches:touches]; [self _updateAndDispatchTouches:touches eventName:@"touchStart"]; if (self.state == UIGestureRecognizerStatePossible) { self.state = UIGestureRecognizerStateBegan; } else if (self.state == UIGestureRecognizerStateBegan) { self.state = UIGestureRecognizerStateChanged; } } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesMoved:touches withEvent:event]; [self _updateAndDispatchTouches:touches eventName:@"touchMove"]; self.state = UIGestureRecognizerStateChanged; } - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesEnded:touches withEvent:event]; [self _updateAndDispatchTouches:touches eventName:@"touchEnd"]; if (RCTAllTouchesAreCancelledOrEnded(event.allTouches)) { self.state = UIGestureRecognizerStateEnded; } else if (RCTAnyTouchesChanged(event.allTouches)) { self.state = UIGestureRecognizerStateChanged; } [self _recordRemovedTouches:touches]; } - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesCancelled:touches withEvent:event]; [self _updateAndDispatchTouches:touches eventName:@"touchCancel"]; if (RCTAllTouchesAreCancelledOrEnded(event.allTouches)) { self.state = UIGestureRecognizerStateCancelled; } else if (RCTAnyTouchesChanged(event.allTouches)) { self.state = UIGestureRecognizerStateChanged; } [self _recordRemovedTouches:touches]; } - (BOOL)canPreventGestureRecognizer:(__unused UIGestureRecognizer *)preventedGestureRecognizer { return NO; } - (BOOL)canBePreventedByGestureRecognizer:(UIGestureRecognizer *)preventingGestureRecognizer { // We fail in favour of other external gesture recognizers. // iOS will ask `delegate`'s opinion about this gesture recognizer little bit later. return ![preventingGestureRecognizer.view isDescendantOfView:self.view]; } - (void)reset { if (_nativeTouches.count != 0) { [self _updateAndDispatchTouches:_nativeTouches.set eventName:@"touchCancel"]; [_nativeTouches removeAllObjects]; [_reactTouches removeAllObjects]; [_touchViews removeAllObjects]; _cachedRootView = nil; } } #pragma mark - Other - (void)cancel { self.enabled = NO; self.enabled = YES; } #pragma mark - UIGestureRecognizerDelegate - (BOOL)gestureRecognizer:(__unused UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { // Same condition for `failure of` as for `be prevented by`. return [self canBePreventedByGestureRecognizer:otherGestureRecognizer]; } @end