diff --git a/modules/react-native-scroll-forwarder/ScrollForwarder.podspec b/modules/react-native-scroll-forwarder/ScrollForwarder.podspec new file mode 100644 index 0000000000..137aea92b8 --- /dev/null +++ b/modules/react-native-scroll-forwarder/ScrollForwarder.podspec @@ -0,0 +1,20 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "ScrollForwarder" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => ".git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm,cpp}" + s.private_header_files = "ios/**/*.h" + + install_modules_dependencies(s) +end diff --git a/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.h b/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.h new file mode 100644 index 0000000000..3b3b4898ab --- /dev/null +++ b/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.h @@ -0,0 +1,14 @@ +#import +#import + +#ifndef ScrollForwarderViewNativeComponent_h +#define ScrollForwarderViewNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN + +@interface ScrollForwarderView : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END + +#endif /* ScrollForwarderViewNativeComponent_h */ diff --git a/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.mm b/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.mm new file mode 100644 index 0000000000..79422a4e17 --- /dev/null +++ b/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.mm @@ -0,0 +1,401 @@ +#import "ScrollForwarderView.h" + +#import +#import +#import +#import +#import +#import +#import +#import + +#import "RCTFabricComponentsPlugins.h" + +using namespace facebook::react; + +static const CGFloat kPullThreshold = 130.0; +static const CGFloat kDampingFactor = 0.55; +static const CGFloat kMaxVelocity = 5000.0; +static const CGFloat kVelocityDecay = 0.9875; +static const CGFloat kMinimumVelocity = 5.0; + +@interface ScrollForwarderView () + +@end + +@implementation ScrollForwarderView { + UIView * _view; + + 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; + + _view = [[UIView alloc] init]; + + UIPanGestureRecognizer *pg = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)]; + pg.delegate = self; + [self addGestureRecognizer:pg]; + + UITapGestureRecognizer *tg = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)]; + [tg setEnabled:true]; + tg.delegate = self; + + UILongPressGestureRecognizer *lpg = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)]; + [lpg setMinimumPressDuration:0.01]; + [lpg setEnabled:true]; + lpg.delegate = self; + + NSArray *cancelGestureRecognizers = [NSArray arrayWithObjects:lpg, tg, nil]; + _cancelGestureRecognizers = cancelGestureRecognizers; + + self.contentView = _view; + } + + return self; +} + +// MARK: - Lifecycle + +- (void)dealloc +{ + [self stopAnimation]; + _svcv = nil; +} + +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + [self stopAnimation]; + _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]; + } + + [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]; +} + +// 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]; + if (_displayLink) { + [_displayLink invalidate]; + _displayLink = nil; + } +} + +- (void)handlePan:(UIPanGestureRecognizer *)gesture { + if (!_svcv) return; + + UIScrollView *sv = _svcv.scrollView; + + CGPoint translation = [gesture translationInView:self]; + + if (gesture.state == UIGestureRecognizerStateBegan) { + 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]; + _didImpact = false; + + if (sv.contentOffset.y <= -kPullThreshold) { + [self refresh]; +// if ([_svcv.scrollView.refreshControl isKindOfClass:[RCTRefreshControl class]]) { +// RCTRefreshControl *refreshCtrl = (RCTRefreshControl *) _svcv.scrollView.refreshControl; +// [refreshCtrl forwarderBeginRefreshing]; +// } + 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; +} + +- (void) refresh +{ +// if ([_svcv.scrollView.refreshControl isKindOfClass:[RCTPullToRefreshViewComponentView class]]) { +// RCTPullToRefreshViewComponentView *refreshView = (RCTPullToRefreshViewComponentView *) _svcv.scrollView.refreshControl; +// [refreshView beginRefreshingProgrammatically]; +// } +} + +Class ScrollForwarderViewCls(void) +{ + return ScrollForwarderView.class; +} + +@end + + +//- (void)forwarderBeginRefreshing +//{ +// _refreshingProgrammatically = NO; +// +// [self sizeToFit]; +// +// if (!self.scrollView) { +// return; +// } +// +// UIScrollView *scrollView = (UIScrollView *)self.scrollView; +// +// [UIView animateWithDuration:0.3 +// delay:0 +// options:UIViewAnimationOptionBeginFromCurrentState +// animations:^(void) { +// // Whenever we call this method, the scrollview will always be at a position of +// // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl +// [scrollView setContentOffset:CGPointMake(0, -65)]; +// } +// completion:^(__unused BOOL finished) { +// [super beginRefreshing]; +// [self setCurrentRefreshingState:super.refreshing]; +// +// if (self->_onRefresh) { +// self->_onRefresh(nil); +// } +// } +// ]; +//} diff --git a/modules/react-native-scroll-forwarder/ios/ScrollForwarderViewManager.mm b/modules/react-native-scroll-forwarder/ios/ScrollForwarderViewManager.mm new file mode 100644 index 0000000000..5f3a464c48 --- /dev/null +++ b/modules/react-native-scroll-forwarder/ios/ScrollForwarderViewManager.mm @@ -0,0 +1,19 @@ +#import +#import +#import "RCTBridge.h" + +@interface ScrollForwarderViewManager : RCTViewManager +@end + +@implementation ScrollForwarderViewManager + +RCT_EXPORT_MODULE(ScrollForwarderView) + +- (UIView *)view +{ + return [[UIView alloc] init]; +} + +RCT_EXPORT_VIEW_PROPERTY(scrollViewTag, NSNumber) + +@end diff --git a/modules/react-native-scroll-forwarder/package.json b/modules/react-native-scroll-forwarder/package.json new file mode 100644 index 0000000000..2f766a402e --- /dev/null +++ b/modules/react-native-scroll-forwarder/package.json @@ -0,0 +1,19 @@ +{ + "name": "react-native-scroll-forwarder", + "version": "0.0.0", + "description": "Scroll forwarder module for Bluesky profile headers", + "main": "src/index", + "codegenConfig": { + "name": "ScrollForwarderViewSpec", + "type": "all", + "jsSrcsDir": "src" + }, + "author": "Hailey ", + "license": "MIT", + "homepage": "#readme", + "create-react-native-library": { + "languages": "kotlin-objc", + "type": "fabric-view", + "version": "0.50.2" + } +} diff --git a/modules/react-native-scroll-forwarder/src/ScrollForwarderView.ios.tsx b/modules/react-native-scroll-forwarder/src/ScrollForwarderView.ios.tsx new file mode 100644 index 0000000000..65d7aba53e --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/ScrollForwarderView.ios.tsx @@ -0,0 +1,10 @@ +import { + default as NativeScrollForwarderView, + NativeProps, +} from './ScrollForwarderViewNativeComponent' + +export function ScrollForwarderView({children, ...rest}: NativeProps) { + return ( + {children} + ) +} diff --git a/modules/react-native-scroll-forwarder/src/ScrollForwarderView.tsx b/modules/react-native-scroll-forwarder/src/ScrollForwarderView.tsx new file mode 100644 index 0000000000..889537e349 --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/ScrollForwarderView.tsx @@ -0,0 +1,5 @@ +import {NativeProps} from './ScrollForwarderViewNativeComponent' + +export function ScrollForwarderView({children}: NativeProps) { + return children +} diff --git a/modules/react-native-scroll-forwarder/src/ScrollForwarderViewNativeComponent.ts b/modules/react-native-scroll-forwarder/src/ScrollForwarderViewNativeComponent.ts new file mode 100644 index 0000000000..8ffbd850db --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/ScrollForwarderViewNativeComponent.ts @@ -0,0 +1,9 @@ +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent' +import type {ViewProps} from 'react-native' +import {Int32} from 'react-native/Libraries/Types/CodegenTypes' + +export interface NativeProps extends ViewProps { + scrollViewTag?: Int32 +} + +export default codegenNativeComponent('ScrollForwarderView') diff --git a/modules/react-native-scroll-forwarder/src/index.tsx b/modules/react-native-scroll-forwarder/src/index.tsx new file mode 100644 index 0000000000..2210e5d9f2 --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/index.tsx @@ -0,0 +1,2 @@ +export {ScrollForwarderView} from './ScrollForwarderView' +export * from './ScrollForwarderViewNativeComponent' diff --git a/package.json b/package.json index bfdee87b9e..899e8a2e68 100644 --- a/package.json +++ b/package.json @@ -215,7 +215,8 @@ "tlds": "^1.234.0", "tldts": "^6.1.46", "zeego": "^1.6.2", - "zod": "^3.20.2" + "zod": "^3.20.2", + "react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder" }, "devDependencies": { "@atproto/dev-env": "^0.3.129", diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index cc339bb03d..da27fe47fc 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -47,6 +47,7 @@ import {ScreenHider} from '#/components/moderation/ScreenHider' import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' import {navigate} from '#/Navigation' import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' +import {ScrollForwarderView} from 'modules/react-native-scroll-forwarder/src' interface SectionRef { scrollToTop: () => void @@ -343,7 +344,7 @@ function ProfileScreenLoaded({ setMinimumHeight: (height: number) => void }) => { return ( - + - + ) } diff --git a/yarn.lock b/yarn.lock index d8c1c8e997..ffac0471ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16900,6 +16900,10 @@ react-native-screens@~4.10.0: react-freeze "^1.0.0" warn-once "^0.1.0" +"react-native-scroll-forwarder@link:./modules/react-native-scroll-forwarder": + version "0.0.0" + uid "" + react-native-svg@15.11.2: version "15.11.2" resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.11.2.tgz#7540e8e1eabc4dcd3b1e35ada5a1d9f1b96d37c4"