fabric: update scroll forwarder for profile headers (#8366)
* progress * remove old module * nil delegates * clean up problems * fix refreshing * bump
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["ExpoScrollForwarderModule"]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UITouch>, 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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface ExpoScrollForwarderViewProps {
|
||||
scrollViewTag: number | null
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
|
||||
requireNativeViewManager('ExpoScrollForwarder')
|
||||
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
...rest
|
||||
}: ExpoScrollForwarderViewProps) {
|
||||
return <NativeView {...rest}>{children}</NativeView>
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
|
||||
return children
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
#import <React/RCTViewComponentView.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
#ifndef ScrollForwarderViewNativeComponent_h
|
||||
#define ScrollForwarderViewNativeComponent_h
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ScrollForwarderView : RCTViewComponentView
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
#endif /* ScrollForwarderViewNativeComponent_h */
|
||||
@@ -0,0 +1,411 @@
|
||||
#import "ScrollForwarderView.h"
|
||||
|
||||
#import <React/RCTEnhancedScrollView.h>
|
||||
#import <React/RCTScrollViewComponentView.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/ComponentDescriptors.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/EventEmitters.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/Props.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/RCTComponentViewHelpers.h>
|
||||
|
||||
#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 () <RCTScrollForwarderViewViewProtocol, UIGestureRecognizerDelegate>
|
||||
|
||||
@end
|
||||
|
||||
@implementation ScrollForwarderView {
|
||||
NSArray<UIGestureRecognizer *> * _cancelGestureRecognizers;
|
||||
RCTScrollViewComponentView * _svcv;
|
||||
CGPoint _initialOffset;
|
||||
|
||||
CADisplayLink * _displayLink;
|
||||
CGFloat _currentVelocity;
|
||||
CGFloat _accumulatedTranslation;
|
||||
|
||||
bool _didImpact;
|
||||
}
|
||||
|
||||
+ (ComponentDescriptorProvider)componentDescriptorProvider
|
||||
{
|
||||
return concreteComponentDescriptorProvider<ScrollForwarderViewComponentDescriptor>();
|
||||
}
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame
|
||||
{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
static const auto defaultProps = std::make_shared<const ScrollForwarderViewProps>();
|
||||
_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<UIGestureRecognizer *> *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<ScrollForwarderViewProps const>(_props);
|
||||
const auto &newViewProps = *std::static_pointer_cast<ScrollForwarderViewProps const>(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<UITouch *> *)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<const facebook::react::ScrollForwarderViewEventEmitter>(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<RCTComponentViewProtocol> ScrollForwarderViewCls(void)
|
||||
{
|
||||
return ScrollForwarderView.class;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,19 @@
|
||||
#import <React/RCTViewManager.h>
|
||||
#import <React/RCTUIManager.h>
|
||||
#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
|
||||
@@ -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 <me@haileyok.com>",
|
||||
"license": "MIT",
|
||||
"homepage": "#readme",
|
||||
"create-react-native-library": {
|
||||
"languages": "kotlin-objc",
|
||||
"type": "fabric-view",
|
||||
"version": "0.50.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
default as NativeScrollForwarderView,
|
||||
type NativeProps,
|
||||
} from './ScrollForwarderViewNativeComponent'
|
||||
|
||||
export function ScrollForwarderView({children, ...rest}: NativeProps) {
|
||||
return (
|
||||
<NativeScrollForwarderView {...rest} style={{flex: 1}}>
|
||||
{children}
|
||||
</NativeScrollForwarderView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {type NativeProps} from './ScrollForwarderViewNativeComponent'
|
||||
|
||||
export function ScrollForwarderView({children}: NativeProps) {
|
||||
return children
|
||||
}
|
||||
@@ -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<OnRefreshEvent>
|
||||
}
|
||||
|
||||
export default codegenNativeComponent<NativeProps>('ScrollForwarderView')
|
||||
@@ -0,0 +1,2 @@
|
||||
export {ScrollForwarderView} from './ScrollForwarderView'
|
||||
export * from './ScrollForwarderViewNativeComponent'
|
||||
Reference in New Issue
Block a user