From 2c7cab5ae511abe0fba79bf6adab9da5b3a0f2b4 Mon Sep 17 00:00:00 2001 From: hailey Date: Tue, 13 May 2025 13:01:47 -0700 Subject: [PATCH] fabric: update scroll forwarder for profile headers (#8366) * progress * remove old module * nil delegates * clean up problems * fix refreshing * bump --- .../expo-module.config.json | 6 - modules/expo-scroll-forwarder/index.ts | 1 - .../ios/ExpoScrollForwarder.podspec | 21 - .../ios/ExpoScrollForwarderModule.swift | 13 - .../ios/ExpoScrollForwarderView.swift | 213 --------- .../src/ExpoScrollForwarder.types.ts | 4 - .../src/ExpoScrollForwarderView.ios.tsx | 13 - .../src/ExpoScrollForwarderView.tsx | 7 - .../ScrollForwarder.podspec | 20 + .../ios/ScrollForwarderView.h | 14 + .../ios/ScrollForwarderView.mm | 411 ++++++++++++++++++ .../ios/ScrollForwarderViewManager.mm | 19 + .../package.json | 19 + .../src/ScrollForwarderView.ios.tsx | 12 + .../src/ScrollForwarderView.tsx | 5 + .../src/ScrollForwarderViewNativeComponent.ts | 16 + .../src/index.tsx | 2 + package.json | 3 +- src/screens/Profile/Sections/Feed.tsx | 8 +- src/view/com/posts/PostFeed.tsx | 31 +- src/view/screens/Profile.tsx | 22 +- yarn.lock | 4 + 22 files changed, 576 insertions(+), 288 deletions(-) delete mode 100644 modules/expo-scroll-forwarder/expo-module.config.json delete mode 100644 modules/expo-scroll-forwarder/index.ts delete mode 100644 modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec delete mode 100644 modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift delete mode 100644 modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift delete mode 100644 modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts delete mode 100644 modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx delete mode 100644 modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx create mode 100644 modules/react-native-scroll-forwarder/ScrollForwarder.podspec create mode 100644 modules/react-native-scroll-forwarder/ios/ScrollForwarderView.h create mode 100644 modules/react-native-scroll-forwarder/ios/ScrollForwarderView.mm create mode 100644 modules/react-native-scroll-forwarder/ios/ScrollForwarderViewManager.mm create mode 100644 modules/react-native-scroll-forwarder/package.json create mode 100644 modules/react-native-scroll-forwarder/src/ScrollForwarderView.ios.tsx create mode 100644 modules/react-native-scroll-forwarder/src/ScrollForwarderView.tsx create mode 100644 modules/react-native-scroll-forwarder/src/ScrollForwarderViewNativeComponent.ts create mode 100644 modules/react-native-scroll-forwarder/src/index.tsx diff --git a/modules/expo-scroll-forwarder/expo-module.config.json b/modules/expo-scroll-forwarder/expo-module.config.json deleted file mode 100644 index 1fd49f79b7..0000000000 --- a/modules/expo-scroll-forwarder/expo-module.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "platforms": ["ios"], - "ios": { - "modules": ["ExpoScrollForwarderModule"] - } -} diff --git a/modules/expo-scroll-forwarder/index.ts b/modules/expo-scroll-forwarder/index.ts deleted file mode 100644 index a4ad4b8506..0000000000 --- a/modules/expo-scroll-forwarder/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView' diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec deleted file mode 100644 index 78ca9812e4..0000000000 --- a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec +++ /dev/null @@ -1,21 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'ExpoScrollForwarder' - s.version = '1.0.0' - s.summary = 'Forward scroll gesture from UIView to UIScrollView' - s.description = 'Forward scroll gesture from UIView to UIScrollView' - s.author = 'bluesky-social' - s.homepage = 'https://github.com/bluesky-social/social-app' - s.platforms = { :ios => '13.4', :tvos => '13.4' } - s.source = { git: '' } - s.static_framework = true - - s.dependency 'ExpoModulesCore' - - # Swift/Objective-C compatibility - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - 'SWIFT_COMPILATION_MODE' => 'wholemodule' - } - - s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" -end diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift deleted file mode 100644 index 53e2588258..0000000000 --- a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift +++ /dev/null @@ -1,13 +0,0 @@ -import ExpoModulesCore - -public class ExpoScrollForwarderModule: Module { - public func definition() -> ModuleDefinition { - Name("ExpoScrollForwarder") - - View(ExpoScrollForwarderView.self) { - Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in - view.scrollViewTag = prop - } - } - } -} diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift deleted file mode 100644 index 15993ef29e..0000000000 --- a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift +++ /dev/null @@ -1,213 +0,0 @@ -import ExpoModulesCore - -// This view will be used as a native component. Make sure to inherit from `ExpoView` -// to apply the proper styling (e.g. border radius and shadows). -class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate { - var scrollViewTag: Int? { - didSet { - self.tryFindScrollView() - } - } - - private var rctScrollView: RCTScrollView? - private var rctRefreshCtrl: RCTRefreshControl? - private var cancelGestureRecognizers: [UIGestureRecognizer]? - private var animTimer: Timer? - private var initialOffset: CGFloat = 0.0 - private var didImpact: Bool = false - - required init(appContext: AppContext? = nil) { - super.init(appContext: appContext) - - let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:))) - pg.delegate = self - self.addGestureRecognizer(pg) - - let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:))) - tg.isEnabled = false - tg.delegate = self - - let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:))) - lpg.minimumPressDuration = 0.01 - lpg.isEnabled = false - lpg.delegate = self - - self.cancelGestureRecognizers = [lpg, tg] - } - - // We don't want to recognize the scroll pan gesture and the swipe back gesture together - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer { - return false - } - - return true - } - - // We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will - // interfere with the native swipe back gesture. - override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else { - return true - } - - let velocity = gestureRecognizer.velocity(in: self) - return abs(velocity.y) > abs(velocity.x) - } - - // This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another - // recognizer for this one. - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - self.stopTimer() - } - - // This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change - // the scroll view gesture's delegate, so we add an additional recognizer to detect this. - @IBAction func callOnPress(_ sender: UITapGestureRecognizer) { - self.stopTimer() - } - - @IBAction func callOnPan(_ sender: UIPanGestureRecognizer) { - guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else { - return - } - - let translation = sender.translation(in: self).y - - if sender.state == .began { - if sv.contentOffset.y < 0 { - sv.contentOffset.y = 0 - } - - self.initialOffset = sv.contentOffset.y - } - - if sender.state == .changed { - sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset) - - if sv.contentOffset.y <= -130, !didImpact { - let generator = UIImpactFeedbackGenerator(style: .light) - generator.impactOccurred() - - self.didImpact = true - } - } - - if sender.state == .ended { - let velocity = sender.velocity(in: self).y - self.didImpact = false - - if sv.contentOffset.y <= -130 { - self.rctRefreshCtrl?.forwarderBeginRefreshing() - return - } - - // A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal - // scroll view - if abs(velocity) < 250, sv.contentOffset.y >= 0 { - return - } - - self.startDecayAnimation(translation, velocity) - } - } - - func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) { - guard let sv = self.rctScrollView?.scrollView else { - return - } - - var velocity = velocity - - self.enableCancelGestureRecognizers() - - if velocity > 0 { - velocity = min(velocity, 5000) - } else { - velocity = max(velocity, -5000) - } - - var animTranslation = -translation - self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { _ in - velocity *= 0.9875 - animTranslation = (-velocity / 120) + animTranslation - - let nextOffset = self.dampenOffset(animTranslation + self.initialOffset) - - if nextOffset <= 0 { - if self.initialOffset <= 1 { - self.scrollToOffset(0) - } else { - sv.contentOffset.y = 0 - } - - self.stopTimer() - return - } else { - sv.contentOffset.y = nextOffset - } - - if abs(velocity) < 5 { - self.stopTimer() - } - } - } - - func dampenOffset(_ offset: CGFloat) -> CGFloat { - if offset < 0 { - return offset - (offset * 0.55) - } - - return offset - } - - func tryFindScrollView() { - guard let scrollViewTag = scrollViewTag else { - return - } - - // Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer. - // Otherwise we might end up with duplicates when we switch back to that scrollview. - self.removeCancelGestureRecognizers() - - self.rctScrollView = self.appContext? - .findView(withTag: scrollViewTag, ofType: RCTScrollView.self) - self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl - - self.addCancelGestureRecognizers() - } - - func addCancelGestureRecognizers() { - self.cancelGestureRecognizers?.forEach { r in - self.rctScrollView?.scrollView?.addGestureRecognizer(r) - } - } - - func removeCancelGestureRecognizers() { - self.cancelGestureRecognizers?.forEach { r in - self.rctScrollView?.scrollView?.removeGestureRecognizer(r) - } - } - - func enableCancelGestureRecognizers() { - self.cancelGestureRecognizers?.forEach { r in - r.isEnabled = true - } - } - - func disableCancelGestureRecognizers() { - self.cancelGestureRecognizers?.forEach { r in - r.isEnabled = false - } - } - - func scrollToOffset(_ offset: Int, animated: Bool = true) { - self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated) - } - - func stopTimer() { - self.disableCancelGestureRecognizers() - self.animTimer?.invalidate() - self.animTimer = nil - } -} diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts b/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts deleted file mode 100644 index 3f1e4a63da..0000000000 --- a/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ExpoScrollForwarderViewProps { - scrollViewTag: number | null - children: React.ReactNode -} diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx deleted file mode 100644 index 18bdb25c8a..0000000000 --- a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {requireNativeViewManager} from 'expo-modules-core' - -import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' - -const NativeView: React.ComponentType = - requireNativeViewManager('ExpoScrollForwarder') - -export function ExpoScrollForwarderView({ - children, - ...rest -}: ExpoScrollForwarderViewProps) { - return {children} -} diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx deleted file mode 100644 index d66eef7cbd..0000000000 --- a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' - -export function ExpoScrollForwarderView({ - children, -}: React.PropsWithChildren) { - return children -} 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..840cd04ff3 --- /dev/null +++ b/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.mm @@ -0,0 +1,411 @@ +#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 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..497112ad11 --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/ScrollForwarderView.ios.tsx @@ -0,0 +1,12 @@ +import { + default as NativeScrollForwarderView, + type 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..315605aae5 --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/ScrollForwarderView.tsx @@ -0,0 +1,5 @@ +import {type 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..9c6348de32 --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/ScrollForwarderViewNativeComponent.ts @@ -0,0 +1,16 @@ +import {type ViewProps} from 'react-native' +import { + type BubblingEventHandler, + type Int32, +} from 'react-native/Libraries/Types/CodegenTypes' +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent' + +type OnRefreshEvent = {} + +export interface NativeProps extends ViewProps { + scrollViewTag: Int32 | null + refreshing?: boolean + onRefresh?: BubblingEventHandler +} + +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 dd81bd7dd4..d2da67ed69 100644 --- a/package.json +++ b/package.json @@ -220,7 +220,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.160", diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx index e0c3e221f4..6aeecd5aae 100644 --- a/src/screens/Profile/Sections/Feed.tsx +++ b/src/screens/Profile/Sections/Feed.tsx @@ -9,7 +9,7 @@ import {isIOS, isNative} from '#/platform/detection' import {type FeedDescriptor} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' -import {PostFeed} from '#/view/com/posts/PostFeed' +import {PostFeed, type PostFeedRef} from '#/view/com/posts/PostFeed' import {EmptyState} from '#/view/com/util/EmptyState' import {type ListRef} from '#/view/com/util/List' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' @@ -24,6 +24,8 @@ interface FeedSectionProps { scrollElRef: ListRef ignoreFilterFor?: string setScrollViewTag: (tag: number | null) => void + postFeedRef?: React.RefObject + onRefreshEnd?: () => void } export const ProfileFeedSection = React.forwardRef< SectionRef, @@ -36,6 +38,8 @@ export const ProfileFeedSection = React.forwardRef< scrollElRef, ignoreFilterFor, setScrollViewTag, + postFeedRef, + onRefreshEnd, }, ref, ) { @@ -91,6 +95,8 @@ export const ProfileFeedSection = React.forwardRef< shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined } isVideoFeed={isVideoFeed} + ref={postFeedRef} + onRefreshEnd={onRefreshEnd} /> {(isScrolledDown || hasNew) && ( Promise +} + // DISABLED need to check if this is causing random feed refreshes -prf // const REFRESH_AFTER = STALE.HOURS.ONE const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY @@ -189,6 +201,7 @@ let PostFeed = ({ savedFeedConfig, initialNumToRender: initialNumToRenderOverride, isVideoFeed = false, + ref, }: { feed: FeedDescriptor feedParams?: FeedParams @@ -211,6 +224,7 @@ let PostFeed = ({ savedFeedConfig?: AppBskyActorDefs.SavedFeed initialNumToRender?: number isVideoFeed?: boolean + ref?: React.ForwardedRef }): React.ReactNode => { const {_} = useLingui() const queryClient = useQueryClient() @@ -629,22 +643,31 @@ let PostFeed = ({ // events // = + // - const onRefresh = useCallback(async () => { + const refreshFeed = async () => { logEvent('feed:refresh', { feedType: feedType, feedUrl: feed, reason: 'pull-to-refresh', }) - setIsPTRing(true) try { await refetch() onHasNew?.(false) } catch (err) { logger.error('Failed to refresh posts feed', {message: err}) } + } + + const onRefresh = async () => { + setIsPTRing(true) + await refreshFeed() setIsPTRing(false) - }, [refetch, setIsPTRing, onHasNew, feed, feedType]) + } + + useImperativeHandle(ref, () => ({ + refreshFeed, + })) const onEndReached = useCallback(async () => { if (isFetching || !hasNextPage || isError) return diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index cc339bb03d..59defce9ff 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo} from 'react' +import React, {useCallback, useMemo, useRef, useState} from 'react' import {StyleSheet} from 'react-native' import {SafeAreaView} from 'react-native-safe-area-context' import { @@ -11,6 +11,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' +import {ScrollForwarderView} from 'modules/react-native-scroll-forwarder/src' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useSetTitle} from '#/lib/hooks/useSetTitle' @@ -46,7 +47,7 @@ import * as Layout from '#/components/Layout' import {ScreenHider} from '#/components/moderation/ScreenHider' import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' import {navigate} from '#/Navigation' -import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' +import {type PostFeedRef} from '../com/posts/PostFeed' interface SectionRef { scrollToTop: () => void @@ -178,6 +179,7 @@ function ProfileScreenLoaded({ enabled: !!profile.associated?.labeler, }) const [currentPage, setCurrentPage] = React.useState(0) + const [isRefreshing, setIsRefreshing] = useState(false) const {_} = useLingui() const [scrollViewTag, setScrollViewTag] = React.useState(null) @@ -334,6 +336,14 @@ function ProfileScreenLoaded({ scrollSectionToTop(index) } + const postFeedRef = useRef() + + const onRefresh = async () => { + setIsRefreshing(true) + await postFeedRef.current?.refreshFeed() + setIsRefreshing(false) + } + // rendering // = @@ -343,7 +353,10 @@ function ProfileScreenLoaded({ setMinimumHeight: (height: number) => void }) => { return ( - + - + ) } @@ -408,6 +421,7 @@ function ProfileScreenLoaded({ scrollElRef={scrollElRef as ListRef} ignoreFilterFor={profile.did} setScrollViewTag={setScrollViewTag} + postFeedRef={postFeedRef} /> ) : null} diff --git a/yarn.lock b/yarn.lock index 9f684dab45..35f7c5c474 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17436,6 +17436,10 @@ react-native-screens@^4.11.1: react-native-is-edge-to-edge "^1.1.7" warn-once "^0.1.0" +"react-native-scroll-forwarder@link:./modules/react-native-scroll-forwarder": + version "0.0.0" + uid "" + react-native-svg@15.12.0: version "15.12.0" resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.12.0.tgz#0e2d476961e8b07f8c549fe4489c99b5130dc150"