diff --git a/modules/expo-scroll-forwarder/expo-module.config.json b/modules/expo-scroll-forwarder/expo-module.config.json new file mode 100644 index 0000000000..1fd49f79b7 --- /dev/null +++ b/modules/expo-scroll-forwarder/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios"], + "ios": { + "modules": ["ExpoScrollForwarderModule"] + } +} diff --git a/modules/expo-scroll-forwarder/index.ts b/modules/expo-scroll-forwarder/index.ts new file mode 100644 index 0000000000..a4ad4b8506 --- /dev/null +++ b/modules/expo-scroll-forwarder/index.ts @@ -0,0 +1 @@ +export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView' diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec new file mode 100644 index 0000000000..78ca9812e4 --- /dev/null +++ b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000000..53e2588258 --- /dev/null +++ b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000000..e6ebef1d33 --- /dev/null +++ b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift @@ -0,0 +1,230 @@ +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 scrollView: UIScrollView? + 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 sv = self.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.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 + } + + private func findScrollView(in view: UIView, foundCount: Int) -> UIScrollView? { + var foundCount = foundCount + if let sv = view as? UIScrollView { return sv } + for child in view.subviews { + if let found = findScrollView(in: child, foundCount: foundCount) { + if foundCount == 1 { + print("found sv: \(found)") +// return found + } else { + print("found sv: \(found)") + foundCount += 1 + } + } + } + return nil + } + + func tryFindScrollView() { + // 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() + + guard let sv = self.findScrollView(in: self.superview!.superview!.superview!, foundCount: 0) else { + print("⚠️ ExpoScrollForwarder: couldn’t find UIScrollView under tag \(tag)") + return + } + + self.scrollView = sv + self.rctRefreshCtrl = sv.refreshControl as? RCTRefreshControl + + self.addCancelGestureRecognizers() + } + + func addCancelGestureRecognizers() { + self.cancelGestureRecognizers?.forEach { r in + self.scrollView?.addGestureRecognizer(r) + } + } + + func removeCancelGestureRecognizers() { + self.cancelGestureRecognizers?.forEach { r in + self.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.scrollView?.scrollRectToVisible(CGRect(x: 0, y: offset, width: 0, height: 0), 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 new file mode 100644 index 0000000000..3f1e4a63da --- /dev/null +++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000000..18bdb25c8a --- /dev/null +++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000000..d66eef7cbd --- /dev/null +++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx @@ -0,0 +1,7 @@ +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 deleted file mode 100644 index 137aea92b8..0000000000 --- a/modules/react-native-scroll-forwarder/ScrollForwarder.podspec +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 3b3b4898ab..0000000000 --- a/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.h +++ /dev/null @@ -1,14 +0,0 @@ -#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 deleted file mode 100644 index 840cd04ff3..0000000000 --- a/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.mm +++ /dev/null @@ -1,411 +0,0 @@ -#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 deleted file mode 100644 index 5f3a464c48..0000000000 --- a/modules/react-native-scroll-forwarder/ios/ScrollForwarderViewManager.mm +++ /dev/null @@ -1,19 +0,0 @@ -#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 deleted file mode 100644 index 2f766a402e..0000000000 --- a/modules/react-native-scroll-forwarder/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 497112ad11..0000000000 --- a/modules/react-native-scroll-forwarder/src/ScrollForwarderView.ios.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 889537e349..0000000000 --- a/modules/react-native-scroll-forwarder/src/ScrollForwarderView.tsx +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 9c6348de32..0000000000 --- a/modules/react-native-scroll-forwarder/src/ScrollForwarderViewNativeComponent.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 2210e5d9f2..0000000000 --- a/modules/react-native-scroll-forwarder/src/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export {ScrollForwarderView} from './ScrollForwarderView' -export * from './ScrollForwarderViewNativeComponent' diff --git a/package.json b/package.json index 241249175f..c25886824a 100644 --- a/package.json +++ b/package.json @@ -215,8 +215,7 @@ "tlds": "^1.234.0", "tldts": "^6.1.46", "zeego": "^1.6.2", - "zod": "^3.20.2", - "react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder" + "zod": "^3.20.2" }, "devDependencies": { "@atproto/dev-env": "^0.3.133", @@ -230,7 +229,6 @@ "@react-native/babel-preset": "0.79.2", "@react-native/eslint-config": "^0.79.2", "@react-native/typescript-config": "^0.79.2", - "@react-native/babel-preset": "0.79.2", "@sentry/webpack-plugin": "^3.2.2", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "^13.2.0", diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx index 6e388ecc9d..e0c3e221f4 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, PostFeedRef} from '#/view/com/posts/PostFeed' +import {PostFeed} 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,8 +24,6 @@ interface FeedSectionProps { scrollElRef: ListRef ignoreFilterFor?: string setScrollViewTag: (tag: number | null) => void - postFeedRef?: React.RefObject - onRefreshEnd?: () => void } export const ProfileFeedSection = React.forwardRef< SectionRef, @@ -38,8 +36,6 @@ export const ProfileFeedSection = React.forwardRef< scrollElRef, ignoreFilterFor, setScrollViewTag, - postFeedRef, - onRefreshEnd, }, ref, ) { @@ -95,8 +91,6 @@ 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 @@ -193,7 +181,6 @@ let PostFeed = ({ savedFeedConfig, initialNumToRender: initialNumToRenderOverride, isVideoFeed = false, - ref, }: { feed: FeedDescriptor feedParams?: FeedParams @@ -216,7 +203,6 @@ let PostFeed = ({ savedFeedConfig?: AppBskyActorDefs.SavedFeed initialNumToRender?: number isVideoFeed?: boolean - ref?: React.ForwardedRef }): React.ReactNode => { const {_} = useLingui() const queryClient = useQueryClient() @@ -599,31 +585,22 @@ let PostFeed = ({ // events // = - // - const refreshFeed = async () => { + const onRefresh = React.useCallback(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) - } - - useImperativeHandle(ref, () => ({ - refreshFeed, - })) + }, [refetch, setIsPTRing, onHasNew, feed, feedType]) const onEndReached = useCallback(async () => { if (isFetching || !hasNextPage || isError) return diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index c76625a3b7..cc339bb03d 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useRef, useState} from 'react' +import React, {useCallback, useMemo} from 'react' import {StyleSheet} from 'react-native' import {SafeAreaView} from 'react-native-safe-area-context' import { @@ -46,8 +46,7 @@ import * as Layout from '#/components/Layout' import {ScreenHider} from '#/components/moderation/ScreenHider' import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' import {navigate} from '#/Navigation' -import {ScrollForwarderView} from 'modules/react-native-scroll-forwarder/src' -import {PostFeedRef} from '../com/posts/PostFeed' +import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' interface SectionRef { scrollToTop: () => void @@ -179,7 +178,6 @@ 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) @@ -336,14 +334,6 @@ function ProfileScreenLoaded({ scrollSectionToTop(index) } - const postFeedRef = useRef() - - const onRefresh = async () => { - setIsRefreshing(true) - await postFeedRef.current?.refreshFeed() - setIsRefreshing(false) - } - // rendering // = @@ -353,10 +343,7 @@ function ProfileScreenLoaded({ setMinimumHeight: (height: number) => void }) => { return ( - + - + ) } @@ -421,7 +408,6 @@ function ProfileScreenLoaded({ scrollElRef={scrollElRef as ListRef} ignoreFilterFor={profile.did} setScrollViewTag={setScrollViewTag} - postFeedRef={postFeedRef} /> ) : null} diff --git a/yarn.lock b/yarn.lock index d07d17e3ca..6f34eb67cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16975,10 +16975,6 @@ 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"