#import "ScrollForwarderView.h" #import #import #import #import #import #import #import "RCTFabricComponentsPlugins.h" using namespace facebook::react; // How far down a pull needs to be to trigger a refresh static const CGFloat kPullThreshold = 130.0; static const CGFloat kDampingFactor = 0.55; // The top speed that free scrolling can have static const CGFloat kMaxVelocity = 5000.0; // Free scrolling decay. This seems to be close to the default iOS value static const CGFloat kVelocityDecay = 0.9875; // What scroll release velocity will actually trigger free scrolling static const CGFloat kMinimumVelocity = 5.0; @interface ScrollForwarderView () @end @implementation ScrollForwarderView { NSArray * _cancelGestureRecognizers; RCTScrollViewComponentView * _svcv; CGPoint _initialOffset; CADisplayLink * _displayLink; CGFloat _currentVelocity; CGFloat _accumulatedTranslation; bool _didImpact; } + (ComponentDescriptorProvider)componentDescriptorProvider { return concreteComponentDescriptorProvider(); } - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { static const auto defaultProps = std::make_shared(); _props = defaultProps; UIPanGestureRecognizer *pg = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)]; pg.delegate = self; pg.cancelsTouchesInView = false; [self addGestureRecognizer:pg]; UITapGestureRecognizer *tg = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)]; [tg setEnabled:false]; tg.delegate = self; UILongPressGestureRecognizer *lpg = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)]; [lpg setMinimumPressDuration:0.01]; [lpg setEnabled:false]; lpg.delegate = self; NSArray *cancelGestureRecognizers = [NSArray arrayWithObjects:lpg, tg, nil]; _cancelGestureRecognizers = cancelGestureRecognizers; } return self; } // MARK: - Lifecycle - (void)dealloc { [self stopAnimation]; [self removeCancelGestureRecognizers]; _svcv = nil; for (UIGestureRecognizer *gr in _cancelGestureRecognizers) { gr.delegate = nil; } } - (void)prepareForRecycle { [super prepareForRecycle]; [self stopAnimation]; [self removeCancelGestureRecognizers]; _svcv = nil; } // MARK: - Props - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps { const auto &oldViewProps = *std::static_pointer_cast(_props); const auto &newViewProps = *std::static_pointer_cast(props); if (oldViewProps.scrollViewTag != newViewProps.scrollViewTag) { [self tryFindScrollView]; } if (oldViewProps.refreshing != newViewProps.refreshing) { if (!newViewProps.refreshing) { [self endRefreshing]; } } [super updateProps:props oldProps:oldProps]; } // MARK: - UIGestureRecognizerDelegate - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { if ([gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]] && [otherGestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) { return NO; } return YES; } - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { if (![gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) { return YES; } UIPanGestureRecognizer *pg = (UIPanGestureRecognizer *)gestureRecognizer; CGPoint velocity = [pg velocityInView:self]; return fabs(velocity.y) > fabs(velocity.x); } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { [self stopAnimation]; [super touchesBegan:touches withEvent:event]; } // MARK: - Scroll Forwarding - (void)removeCancelGestureRecognizers { if (!_svcv) return; for (UIGestureRecognizer *gr in _cancelGestureRecognizers) { [_svcv.scrollView removeGestureRecognizer:gr]; } } - (void)addCancelGestureRecognizers { if (!_svcv) return; for (UIGestureRecognizer *gr in _cancelGestureRecognizers) { [_svcv.scrollView addGestureRecognizer:gr]; } } - (void)enableCancelGestureRecognizers { for (UIGestureRecognizer *gr in _cancelGestureRecognizers) { [gr setEnabled:true]; } } - (void)disableCancelGestureRecognizers { for (UIGestureRecognizer *gr in _cancelGestureRecognizers) { [gr setEnabled:false]; } } - (void)scrollToOffset:(CGPoint)offset animated:(bool)animated { if (!_svcv) return; [_svcv scrollToOffset:offset animated:animated]; } - (void)stopAnimation { [self disableCancelGestureRecognizers]; [_displayLink invalidate]; _displayLink = nil; } - (void)handlePan:(UIPanGestureRecognizer *)gesture { if (!_svcv) return; UIScrollView *sv = _svcv.scrollView; CGPoint translation = [gesture translationInView:self]; if (gesture.state == UIGestureRecognizerStateBegan) { _didImpact = false; if (sv.contentOffset.y < 0) { CGPoint newOffset = CGPointMake(sv.contentOffset.x, 0); sv.contentOffset = newOffset; } _initialOffset = sv.contentOffset; } if (gesture.state == UIGestureRecognizerStateChanged) { CGPoint newOffset = CGPointMake(sv.contentOffset.x, [self dampenOffset:(-translation.y + _initialOffset.y)]); sv.contentOffset = newOffset; if (sv.contentOffset.y <= -kPullThreshold && !_didImpact) { UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; [generator impactOccurred]; _didImpact = true; } } if (gesture.state == UIGestureRecognizerStateEnded) { CGPoint velocity = [gesture velocityInView:self]; if (sv.contentOffset.y <= -kPullThreshold) { [self refresh]; return; } if (sv.contentOffset.y < 0) { CGPoint newOffset = CGPointMake(sv.contentOffset.x, 0); [self scrollToOffset:newOffset animated:true]; return; } if (abs(velocity.y) < 250 && sv.contentOffset.y >= 0) { return; } [self startDecayWithInitialTranslation:translation.y velocity:velocity.y]; } } - (CGFloat)dampenOffset:(CGFloat)offset { if (offset < 0) { return offset - (offset * kDampingFactor); } return offset; } - (void)handleTap:(UITapGestureRecognizer *)gesture { [self stopAnimation]; } - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture { [self stopAnimation]; } - (void)startDecayWithInitialTranslation:(CGFloat)translation velocity:(CGFloat)startVelocity { if (!_svcv) return; startVelocity = MAX(-kMaxVelocity, MIN(kMaxVelocity, startVelocity)); _currentVelocity = startVelocity; _accumulatedTranslation = -translation; [self enableCancelGestureRecognizers]; [_displayLink invalidate]; CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDecayStep:)]; link.preferredFramesPerSecond = 60; [link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; _displayLink = link; } - (void)handleDecayStep:(CADisplayLink *)link { _currentVelocity *= kVelocityDecay; CGFloat delta = -_currentVelocity / link.preferredFramesPerSecond; _accumulatedTranslation += delta; CGFloat rawY = _accumulatedTranslation + _initialOffset.y; CGFloat nextY = rawY > 0 ? rawY : 0; CGPoint newOffset = CGPointMake( _svcv.scrollView.contentOffset.x, nextY ); _svcv.scrollView.contentOffset = newOffset; if (fabs(_currentVelocity) < kMinimumVelocity || nextY <= 0) { [link invalidate]; _displayLink = nil; [self disableCancelGestureRecognizers]; } } /* * We use this component on profile pages. The screne consists of a header component, a scrollview with buttons to * switch between profile tabs, and a pager view (RNCPagerViewComponentView). Both the header and the tab bar are * inside the same RCTViewComponentView. The view heirarchy looks something like this: * - RCTViewComponentView * -- RNCPagerViewComponentView * ----- (Many views deep) RCTScrollViewComponentView * ------ RCTEnhancedScrollView * -- RCTViewComponentView * --- RCTViewComponentView * ---- ScrollForwarderView * --- RCTScrollViewComponentView * ---- RCTEnhancedScrollView * * We want to find that RCTScrollViewComponentView inside of the RNCPagerViewComponentView. To achieve this, we can * use self.superview.superview.superview to get to the root RCTViewComponentView, find the RNCPagerViewComponentView, * then iterate through that view's subviews until we find a RCTScrollViewComponentView. * * This isn't great, because if we reorder the React components, we'll need to update this logic. There's probably * an easier way to achieve this, similar to how we used to do it in Paper (ie, get the scroll view's tag and find that), * but this also comes with some benefits, eg being able to reduce a lot of the logic in the JS code and just find the * scrollview when subviews change. */ - (void)tryFindScrollView { [self removeCancelGestureRecognizers]; // The root RCTViewComponentView UIView *rootView = self.superview.superview.superview; UIView *pagerView; NSString *targetClsName = @"RNCPagerViewComponentView"; Class targetCls = NSClassFromString(targetClsName); for (UIView *subview in rootView.subviews) { if ([subview isKindOfClass:targetCls]) { pagerView = subview; break; } } if (!pagerView) return; RCTScrollViewComponentView *svcv = [self findRTCScrollViewComponentViewInView:pagerView]; if (!svcv) return; _svcv = svcv; [self addCancelGestureRecognizers]; } - (RCTScrollViewComponentView *)findRTCScrollViewComponentViewInView:(UIView *)view { for (UIView *subview in view.subviews) { if ([subview isKindOfClass:[RCTScrollViewComponentView class]]) { RCTScrollViewComponentView *svcv = (RCTScrollViewComponentView *) subview; return svcv; } RCTScrollViewComponentView *svcv = [self findRTCScrollViewComponentViewInView:subview]; if (svcv) return svcv; } return nil; } - (UIRefreshControl *)refreshContorl { if (!_svcv) return nil; return _svcv.scrollView.refreshControl; } - (void)refresh { __weak ScrollForwarderView *weakSelf = self; [_svcv.scrollView.refreshControl beginRefreshing]; [UIView animateWithDuration:0.3 delay:0 options:UIViewAnimationOptionBeginFromCurrentState animations:^(void) { if (!weakSelf) return; __strong ScrollForwarderView *self = weakSelf; // Whenever we call this method, the scrollview will always be at a position of // -130 or less. Scrolling back to -80 simulates the default behavior of RCTRefreshControl [self->_svcv.scrollView setContentOffset:CGPointMake(0, -65)]; } completion:^(__unused BOOL finished) { __strong ScrollForwarderView *self = weakSelf; if (self->_eventEmitter != nullptr) { std::dynamic_pointer_cast(self->_eventEmitter) ->onRefresh(facebook::react::ScrollForwarderViewEventEmitter::OnRefresh{}); } } ]; } - (void)endRefreshing { UIRefreshControl *rc = [self refreshContorl]; CGPoint newOffset = CGPointMake(_svcv.scrollView.contentOffset.x, 0.0); [self scrollToOffset:newOffset animated:true]; [rc endRefreshing]; } Class ScrollForwarderViewCls(void) { return ScrollForwarderView.class; } @end