/**
 * Copyright (c) 2015-present, Facebook, Inc.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

#import "RCTMJScrollView.h"

#import <UIKit/UIKit.h>

#import <React/RCTConvert.h>
#import <React/RCTEventDispatcher.h>
#import <React/RCTLog.h>
#import <React/RCTUIManager.h>
#import <React/RCTUIManagerObserverCoordinator.h>
#import <React/RCTUIManagerUtils.h>
#import <React/RCTUtils.h>
#import "UIView+Private1.h"
#import <React/UIView+React.h>
#import "MJRefresh.h"

#if !TARGET_OS_TV
#import <React/RCTRefreshControl.h>
#endif

@interface RCTMJScrollEvent : NSObject <RCTEvent>

- (instancetype)initWithEventName:(NSString *)eventName
                         reactTag:(NSNumber *)reactTag
          scrollViewContentOffset:(CGPoint)scrollViewContentOffset
           scrollViewContentInset:(UIEdgeInsets)scrollViewContentInset
            scrollViewContentSize:(CGSize)scrollViewContentSize
                  scrollViewFrame:(CGRect)scrollViewFrame
              scrollViewZoomScale:(CGFloat)scrollViewZoomScale
                         userData:(NSDictionary *)userData
                    coalescingKey:(uint16_t)coalescingKey NS_DESIGNATED_INITIALIZER;

@end

@implementation RCTMJScrollEvent
{
    CGPoint _scrollViewContentOffset;
    UIEdgeInsets _scrollViewContentInset;
    CGSize _scrollViewContentSize;
    CGRect _scrollViewFrame;
    CGFloat _scrollViewZoomScale;
    NSDictionary *_userData;
    uint16_t _coalescingKey;
}

@synthesize viewTag = _viewTag;
@synthesize eventName = _eventName;

- (instancetype)initWithEventName:(NSString *)eventName
                         reactTag:(NSNumber *)reactTag
          scrollViewContentOffset:(CGPoint)scrollViewContentOffset
           scrollViewContentInset:(UIEdgeInsets)scrollViewContentInset
            scrollViewContentSize:(CGSize)scrollViewContentSize
                  scrollViewFrame:(CGRect)scrollViewFrame
              scrollViewZoomScale:(CGFloat)scrollViewZoomScale
                         userData:(NSDictionary *)userData
                    coalescingKey:(uint16_t)coalescingKey
{
    RCTAssertParam(reactTag);
    
    if ((self = [super init])) {
        _eventName = [eventName copy];
        _viewTag = reactTag;
        _scrollViewContentOffset = scrollViewContentOffset;
        _scrollViewContentInset = scrollViewContentInset;
        _scrollViewContentSize = scrollViewContentSize;
        _scrollViewFrame = scrollViewFrame;
        _scrollViewZoomScale = scrollViewZoomScale;
        _userData = userData;
        _coalescingKey = coalescingKey;
    }
    return self;
}

RCT_NOT_IMPLEMENTED(- (instancetype)init)

- (uint16_t)coalescingKey
{
    return _coalescingKey;
}

- (NSDictionary *)body
{
    NSDictionary *body = @{
                           @"contentOffset": @{
                                   @"x": @(_scrollViewContentOffset.x),
                                   @"y": @(_scrollViewContentOffset.y)
                                   },
                           @"contentInset": @{
                                   @"top": @(_scrollViewContentInset.top),
                                   @"left": @(_scrollViewContentInset.left),
                                   @"bottom": @(_scrollViewContentInset.bottom),
                                   @"right": @(_scrollViewContentInset.right)
                                   },
                           @"contentSize": @{
                                   @"width": @(_scrollViewContentSize.width),
                                   @"height": @(_scrollViewContentSize.height)
                                   },
                           @"layoutMeasurement": @{
                                   @"width": @(_scrollViewFrame.size.width),
                                   @"height": @(_scrollViewFrame.size.height)
                                   },
                           @"zoomScale": @(_scrollViewZoomScale ?: 1),
                           };
    
    if (_userData) {
        NSMutableDictionary *mutableBody = [body mutableCopy];
        [mutableBody addEntriesFromDictionary:_userData];
        body = mutableBody;
    }
    
    return body;
}

- (BOOL)canCoalesce
{
    return YES;
}

- (RCTMJScrollEvent *)coalesceWithEvent:(RCTMJScrollEvent *)newEvent
{
    NSArray<NSDictionary *> *updatedChildFrames = [_userData[@"updatedChildFrames"] arrayByAddingObjectsFromArray:newEvent->_userData[@"updatedChildFrames"]];
    if (updatedChildFrames) {
        NSMutableDictionary *userData = [newEvent->_userData mutableCopy];
        userData[@"updatedChildFrames"] = updatedChildFrames;
        newEvent->_userData = userData;
    }
    
    return newEvent;
}

+ (NSString *)moduleDotMethod
{
    return @"RCTEventEmitter.receiveEvent";
}

- (NSArray *)arguments
{
    return @[self.viewTag, RCTNormalizeInputEventName(self.eventName), [self body]];
}

@end

/**
 * Include a custom scroll view subclass because we want to limit certain
 * default UIKit behaviors such as textFields automatically scrolling
 * scroll views that contain them.
 */
@interface RCTMJCustomScrollView : UIScrollView<UIGestureRecognizerDelegate>

@property (nonatomic, assign) BOOL centerContent;
#if !TARGET_OS_TV
@property (nonatomic, strong) RCTRefreshControl *rctRefreshControl;
@property (nonatomic, assign) BOOL pinchGestureEnabled;
#endif

@end


@implementation RCTMJCustomScrollView

- (instancetype)initWithFrame:(CGRect)frame
{
    if ((self = [super initWithFrame:frame])) {
        [self.panGestureRecognizer addTarget:self action:@selector(handleCustomPan:)];
        
        if ([self respondsToSelector:@selector(setSemanticContentAttribute:)]) {
            // We intentionaly force `UIScrollView`s `semanticContentAttribute` to `LTR` here
            // because this attribute affects a position of vertical scrollbar; we don't want this
            // scrollbar flip because we also flip it with whole `UIScrollView` flip.
            self.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
        }
        
#if !TARGET_OS_TV
        _pinchGestureEnabled = YES;
#endif
    }
    return self;
}

- (UIView *)contentView
{
    return ((RCTMJScrollView *)self.superview).contentView;
}

/**
 * @return Whether or not the scroll view interaction should be blocked because
 * JS was found to be the responder.
 */
- (BOOL)_shouldDisableScrollInteraction
{
    // Since this may be called on every pan, we need to make sure to only climb
    // the hierarchy on rare occasions.
    UIView *JSResponder = [RCTUIManager JSResponder];
    if (JSResponder && JSResponder != self.superview) {
        BOOL superviewHasResponder = [self isDescendantOfView:JSResponder];
        return superviewHasResponder;
    }
    return NO;
}

- (void)handleCustomPan:(__unused UIPanGestureRecognizer *)sender
{
    if ([self _shouldDisableScrollInteraction] && ![[RCTUIManager JSResponder] isKindOfClass:[RCTMJScrollView class]]) {
        self.panGestureRecognizer.enabled = NO;
        self.panGestureRecognizer.enabled = YES;
        // TODO: If mid bounce, animate the scroll view to a non-bounced position
        // while disabling (but only if `stopScrollInteractionIfJSHasResponder` was
        // called *during* a `pan`). Currently, it will just snap into place which
        // is not so bad either.
        // Another approach:
        // self.scrollEnabled = NO;
        // self.scrollEnabled = YES;
    }
}

- (void)scrollRectToVisible:(CGRect)rect animated:(BOOL)animated
{
    // Limiting scroll area to an area where we actually have content.
    CGSize contentSize = self.contentSize;
    UIEdgeInsets contentInset = self.contentInset;
    CGSize fullSize = CGSizeMake(
                                 contentSize.width + contentInset.left + contentInset.right,
                                 contentSize.height + contentInset.top + contentInset.bottom);
    
    rect = CGRectIntersection((CGRect){CGPointZero, fullSize}, rect);
    if (CGRectIsNull(rect)) {
        return;
    }
    
    [super scrollRectToVisible:rect animated:animated];
}

/**
 * Returning `YES` cancels touches for the "inner" `view` and causes a scroll.
 * Returning `NO` causes touches to be directed to that inner view and prevents
 * the scroll view from scrolling.
 *
 * `YES` -> Allows scrolling.
 * `NO` -> Doesn't allow scrolling.
 *
 * By default this returns NO for all views that are UIControls and YES for
 * everything else. What that does is allows scroll views to scroll even when a
 * touch started inside of a `UIControl` (`UIButton` etc). For React scroll
 * views, we want the default to be the same behavior as `UIControl`s so we
 * return `YES` by default. But there's one case where we want to block the
 * scrolling no matter what: When JS believes it has its own responder lock on
 * a view that is *above* the scroll view in the hierarchy. So we abuse this
 * `touchesShouldCancelInContentView` API in order to stop the scroll view from
 * scrolling in this case.
 *
 * We are not aware of *any* other solution to the problem because alternative
 * approaches require that we disable the scrollview *before* touches begin or
 * move. This approach (`touchesShouldCancelInContentView`) works even if the
 * JS responder is set after touches start/move because
 * `touchesShouldCancelInContentView` is called as soon as the scroll view has
 * been touched and dragged *just* far enough to decide to begin the "drag"
 * movement of the scroll interaction. Returning `NO`, will cause the drag
 * operation to fail.
 *
 * `touchesShouldCancelInContentView` will stop the *initialization* of a
 * scroll pan gesture and most of the time this is sufficient. On rare
 * occasion, the scroll gesture would have already initialized right before JS
 * notifies native of the JS responder being set. In order to recover from that
 * timing issue we have a fallback that kills any ongoing pan gesture that
 * occurs when native is notified of a JS responder.
 *
 * Note: Explicitly returning `YES`, instead of relying on the default fixes
 * (at least) one bug where if you have a UIControl inside a UIScrollView and
 * tap on the UIControl and then start dragging (to scroll), it won't scroll.
 * Chat with @andras for more details.
 *
 * In order to have this called, you must have delaysContentTouches set to NO
 * (which is the not the `UIKit` default).
 */
- (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view
{
    //TODO: shouldn't this call super if _shouldDisableScrollInteraction returns NO?
    return ![self _shouldDisableScrollInteraction];
}

/*
 * Automatically centers the content such that if the content is smaller than the
 * ScrollView, we force it to be centered, but when you zoom or the content otherwise
 * becomes larger than the ScrollView, there is no padding around the content but it
 * can still fill the whole view.
 */
- (void)setContentOffset:(CGPoint)contentOffset
{
    UIView *contentView = [self contentView];
    if (contentView && _centerContent) {
        CGSize subviewSize = contentView.frame.size;
        CGSize scrollViewSize = self.bounds.size;
        if (subviewSize.width <= scrollViewSize.width) {
            contentOffset.x = -(scrollViewSize.width - subviewSize.width) / 2.0;
        }
        if (subviewSize.height <= scrollViewSize.height) {
            contentOffset.y = -(scrollViewSize.height - subviewSize.height) / 2.0;
        }
    }
    super.contentOffset = contentOffset;
}

- (void)setFrame:(CGRect)frame
{
    // Preserving and revalidating `contentOffset`.
    CGPoint originalOffset = self.contentOffset;
    
    [super setFrame:frame];
    
    UIEdgeInsets contentInset = self.contentInset;
    CGSize contentSize = self.contentSize;
    
    // If contentSize has not been measured yet we can't check bounds.
    if (CGSizeEqualToSize(contentSize, CGSizeZero)) {
        self.contentOffset = originalOffset;
    } else {
        // Make sure offset don't exceed bounds. This could happen on screen rotation.
        CGSize boundsSize = self.bounds.size;
        self.contentOffset = CGPointMake(
                                         MAX(-contentInset.left, MIN(contentSize.width - boundsSize.width + contentInset.right, originalOffset.x)),
                                         MAX(-contentInset.top, MIN(contentSize.height - boundsSize.height + contentInset.bottom, originalOffset.y)));
    }
}

#if !TARGET_OS_TV
- (void)setRctRefreshControl:(RCTRefreshControl *)refreshControl
{
    if (_rctRefreshControl) {
        [_rctRefreshControl removeFromSuperview];
    }
    _rctRefreshControl = refreshControl;
    [self addSubview:_rctRefreshControl];
}

- (void)setPinchGestureEnabled:(BOOL)pinchGestureEnabled
{
    self.pinchGestureRecognizer.enabled = pinchGestureEnabled;
    _pinchGestureEnabled = pinchGestureEnabled;
}

- (void)didMoveToWindow
{
    [super didMoveToWindow];
    // ScrollView enables pinch gesture late in its lifecycle. So simply setting it
    // in the setter gets overriden when the view loads.
    self.pinchGestureRecognizer.enabled = _pinchGestureEnabled;
}
#endif //TARGET_OS_TV

@end

@interface RCTMJScrollView () <RCTUIManagerObserver>

@end

@implementation RCTMJScrollView
{
    RCTEventDispatcher *_eventDispatcher;
    CGRect _prevFirstVisibleFrame;
    __weak UIView *_firstVisibleView;
    RCTMJCustomScrollView *_scrollView;
    UIView *_contentView;
    NSTimeInterval _lastScrollDispatchTime;
    NSMutableArray<NSValue *> *_cachedChildFrames;
    BOOL _allowNextScrollNoMatterWhat;
    CGRect _lastClippedToRect;
    uint16_t _coalescingKey;
    NSString *_lastEmittedEventName;
    NSHashTable *_scrollListeners;
}

- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
{
    RCTAssertParam(eventDispatcher);
    
    if ((self = [super initWithFrame:CGRectZero])) {
        _eventDispatcher = eventDispatcher;
        
        _scrollView = [[RCTMJCustomScrollView alloc] initWithFrame:CGRectZero];
        _scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
        _scrollView.delegate = self;
        _scrollView.delaysContentTouches = NO;
        
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 /* __IPHONE_11_0 */
        // `contentInsetAdjustmentBehavior` is only available since iOS 11.
        // We set the default behavior to "never" so that iOS
        // doesn't do weird things to UIScrollView insets automatically
        // and keeps it as an opt-in behavior.
        if ([_scrollView respondsToSelector:@selector(setContentInsetAdjustmentBehavior:)]) {
            _scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
        }
#endif
        
        _automaticallyAdjustContentInsets = YES;
        _DEPRECATED_sendUpdatedChildFrames = NO;
        _contentInset = UIEdgeInsetsZero;
        _contentSize = CGSizeZero;
        _lastClippedToRect = CGRectNull;
        
        _scrollEventThrottle = 0.0;
        _lastScrollDispatchTime = 0;
        _cachedChildFrames = [NSMutableArray new];
        
        _scrollListeners = [NSHashTable weakObjectsHashTable];
        
        [self addSubview:_scrollView];
    }
    return self;
}

RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)

static inline void RCTApplyTransformationAccordingLayoutDirection(UIView *view, UIUserInterfaceLayoutDirection layoutDirection) {
    view.transform =
    layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight ?
    CGAffineTransformIdentity :
    CGAffineTransformMakeScale(-1, 1);
}

- (void)setReactLayoutDirection:(UIUserInterfaceLayoutDirection)layoutDirection
{
    [super setReactLayoutDirection:layoutDirection];
    
    RCTApplyTransformationAccordingLayoutDirection(_scrollView, layoutDirection);
    RCTApplyTransformationAccordingLayoutDirection(_contentView, layoutDirection);
}

- (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
{
    // Does nothing
}

- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
{
    [super insertReactSubview:view atIndex:atIndex];
    
     #if !TARGET_OS_TV
     if ([view isKindOfClass:[RCTRefreshControl class]]) {
     [_scrollView setRctRefreshControl:(RCTRefreshControl *)view];
     } else if ([view isKindOfClass:[MJRefreshHeader class]]){
         _scrollView.mj_header = (MJRefreshHeader *)view;
     } else
     #endif
     {
     RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview");
     _contentView = view;
     RCTApplyTransformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
     [(RCTMJCustomScrollView *)_scrollView addSubview:view];
     }
}

- (void)removeReactSubview:(UIView *)subview
{
    [super removeReactSubview:subview];
  
     #if !TARGET_OS_TV
     if ([subview isKindOfClass:[RCTRefreshControl class]]) {
     [_scrollView setRctRefreshControl:nil];
     } else if ([subview isKindOfClass:[MJRefreshHeader class]]){
         _scrollView.mj_header = nil;
     } else
     #endif
     {
     RCTAssert(_contentView == subview, @"Attempted to remove non-existent subview");
     _contentView = nil;
     }
}

- (void)didUpdateReactSubviews
{
    // Do nothing, as subviews are managed by `insertReactSubview:atIndex:`
}

- (void)didSetProps:(NSArray<NSString *> *)changedProps
{
    if ([changedProps containsObject:@"contentSize"]) {
        [self updateContentOffsetIfNeeded];
    }
}

- (BOOL)centerContent
{
    return _scrollView.centerContent;
}

- (void)setCenterContent:(BOOL)centerContent
{
    _scrollView.centerContent = centerContent;
}

- (void)setClipsToBounds:(BOOL)clipsToBounds
{
    super.clipsToBounds = clipsToBounds;
    _scrollView.clipsToBounds = clipsToBounds;
}

- (void)dealloc
{
    _scrollView.delegate = nil;
    if (_maintainVisibleContentPosition != nil) {
        [_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self];
    }
}

- (void)layoutSubviews
{
    [super layoutSubviews];
    RCTAssert(self.subviews.count == 1, @"we should only have exactly one subview");
    RCTAssert([self.subviews lastObject] == _scrollView, @"our only subview should be a scrollview");
    
#if !TARGET_OS_TV
    // Adjust the refresh control frame if the scrollview layout changes.
    RCTRefreshControl *refreshControl = _scrollView.rctRefreshControl;
    if (refreshControl && refreshControl.refreshing) {
        refreshControl.frame = (CGRect){_scrollView.contentOffset, {_scrollView.frame.size.width, refreshControl.frame.size.height}};
    }
#endif
    
    [self updateClippedSubviews];
}

- (void)updateClippedSubviews
{
    // Find a suitable view to use for clipping
    UIView *clipView = [self react_findClipView];
    if (!clipView) {
        return;
    }
    
    static const CGFloat leeway = 1.0;
    
    const CGSize contentSize = _scrollView.contentSize;
    const CGRect bounds = _scrollView.bounds;
    const BOOL scrollsHorizontally = contentSize.width > bounds.size.width;
    const BOOL scrollsVertically = contentSize.height > bounds.size.height;
    
    const BOOL shouldClipAgain =
    CGRectIsNull(_lastClippedToRect) ||
    !CGRectEqualToRect(_lastClippedToRect, bounds) ||
    (scrollsHorizontally && (bounds.size.width < leeway || fabs(_lastClippedToRect.origin.x - bounds.origin.x) >= leeway)) ||
    (scrollsVertically && (bounds.size.height < leeway || fabs(_lastClippedToRect.origin.y - bounds.origin.y) >= leeway));
    
    if (shouldClipAgain) {
        const CGRect clipRect = CGRectInset(clipView.bounds, -leeway, -leeway);
        [self react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
        _lastClippedToRect = bounds;
    }
}

- (void)setContentInset:(UIEdgeInsets)contentInset
{
    if (UIEdgeInsetsEqualToEdgeInsets(contentInset, _contentInset)) {
        return;
    }
    
    CGPoint contentOffset = _scrollView.contentOffset;
    
    _contentInset = contentInset;
    [RCTView autoAdjustInsetsForView:self
                      withScrollView:_scrollView
                        updateOffset:NO];
    
    _scrollView.contentOffset = contentOffset;
}

- (BOOL)isHorizontal:(UIScrollView *)scrollView
{
    return scrollView.contentSize.width > self.frame.size.width;
}

- (void)scrollToOffset:(CGPoint)offset
{
    [self scrollToOffset:offset animated:YES];
}

- (void)scrollToOffset:(CGPoint)offset animated:(BOOL)animated
{
    if (!CGPointEqualToPoint(_scrollView.contentOffset, offset)) {
        // Ensure at least one scroll event will fire
        _allowNextScrollNoMatterWhat = YES;
        [_scrollView setContentOffset:offset animated:animated];
    }
}

/**
 * If this is a vertical scroll view, scrolls to the bottom.
 * If this is a horizontal scroll view, scrolls to the right.
 */
- (void)scrollToEnd:(BOOL)animated
{
    BOOL isHorizontal = [self isHorizontal:_scrollView];
    CGPoint offset;
    if (isHorizontal) {
        CGFloat offsetX = _scrollView.contentSize.width - _scrollView.bounds.size.width + _scrollView.contentInset.right;
        offset = CGPointMake(fmax(offsetX, 0), 0);
    } else {
        CGFloat offsetY = _scrollView.contentSize.height - _scrollView.bounds.size.height + _scrollView.contentInset.bottom;
        offset = CGPointMake(0, fmax(offsetY, 0));
    }
    if (!CGPointEqualToPoint(_scrollView.contentOffset, offset)) {
        // Ensure at least one scroll event will fire
        _allowNextScrollNoMatterWhat = YES;
        [_scrollView setContentOffset:offset animated:animated];
    }
}

- (void)zoomToRect:(CGRect)rect animated:(BOOL)animated
{
    [_scrollView zoomToRect:rect animated:animated];
}

- (void)refreshContentInset
{
    [RCTView autoAdjustInsetsForView:self
                      withScrollView:_scrollView
                        updateOffset:YES];
}

#pragma mark - ScrollView delegate

#define RCT_SEND_SCROLL_EVENT(_eventName, _userData) { \
NSString *eventName = NSStringFromSelector(@selector(_eventName)); \
[self sendScrollEventWithName:eventName scrollView:_scrollView userData:_userData]; \
}

#define RCT_FORWARD_SCROLL_EVENT(call) \
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) { \
if ([scrollViewListener respondsToSelector:_cmd]) { \
[scrollViewListener call]; \
} \
}

#define RCT_SCROLL_EVENT_HANDLER(delegateMethod, eventName) \
- (void)delegateMethod:(UIScrollView *)scrollView           \
{                                                           \
RCT_SEND_SCROLL_EVENT(eventName, nil);                    \
RCT_FORWARD_SCROLL_EVENT(delegateMethod:scrollView);      \
}

RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, onMomentumScrollBegin)
RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll)

- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
{
    [_scrollListeners addObject:scrollListener];
}

- (void)removeScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
{
    [_scrollListeners removeObject:scrollListener];
}

- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
    [self updateClippedSubviews];
    NSTimeInterval now = CACurrentMediaTime();
    /**
     * TODO: this logic looks wrong, and it may be because it is. Currently, if _scrollEventThrottle
     * is set to zero (the default), the "didScroll" event is only sent once per scroll, instead of repeatedly
     * while scrolling as expected. However, if you "fix" that bug, ScrollView will generate repeated
     * warnings, and behave strangely (ListView works fine however), so don't fix it unless you fix that too!
     */
    if (_allowNextScrollNoMatterWhat ||
        (_scrollEventThrottle > 0 && _scrollEventThrottle < (now - _lastScrollDispatchTime))) {
        
        if (_DEPRECATED_sendUpdatedChildFrames) {
            // Calculate changed frames
            RCT_SEND_SCROLL_EVENT(onScroll, (@{@"updatedChildFrames": [self calculateChildFramesData]}));
        } else {
            RCT_SEND_SCROLL_EVENT(onScroll, nil);
        }
        
        // Update dispatch time
        _lastScrollDispatchTime = now;
        _allowNextScrollNoMatterWhat = NO;
    }
    RCT_FORWARD_SCROLL_EVENT(scrollViewDidScroll:scrollView);
}

- (NSArray<NSDictionary *> *)calculateChildFramesData
{
    NSMutableArray<NSDictionary *> *updatedChildFrames = [NSMutableArray new];
    [[_contentView reactSubviews] enumerateObjectsUsingBlock:
     ^(UIView *subview, NSUInteger idx, __unused BOOL *stop) {
         
         // Check if new or changed
         CGRect newFrame = subview.frame;
         BOOL frameChanged = NO;
         if (self->_cachedChildFrames.count <= idx) {
             frameChanged = YES;
             [self->_cachedChildFrames addObject:[NSValue valueWithCGRect:newFrame]];
         } else if (!CGRectEqualToRect(newFrame, [self->_cachedChildFrames[idx] CGRectValue])) {
             frameChanged = YES;
             self->_cachedChildFrames[idx] = [NSValue valueWithCGRect:newFrame];
         }
         
         // Create JS frame object
         if (frameChanged) {
             [updatedChildFrames addObject: @{
                                              @"index": @(idx),
                                              @"x": @(newFrame.origin.x),
                                              @"y": @(newFrame.origin.y),
                                              @"width": @(newFrame.size.width),
                                              @"height": @(newFrame.size.height),
                                              }];
         }
     }];
    
    return updatedChildFrames;
}

- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
{
    _allowNextScrollNoMatterWhat = YES; // Ensure next scroll event is recorded, regardless of throttle
    RCT_SEND_SCROLL_EVENT(onScrollBeginDrag, nil);
    RCT_FORWARD_SCROLL_EVENT(scrollViewWillBeginDragging:scrollView);
}

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
{
    // snapToInterval
    // An alternative to enablePaging which allows setting custom stopping intervals,
    // smaller than a full page size. Often seen in apps which feature horizonally
    // scrolling items. snapToInterval does not enforce scrolling one interval at a time
    // but guarantees that the scroll will stop at an interval point.
    if (self.snapToInterval) {
        CGFloat snapToIntervalF = (CGFloat)self.snapToInterval;
        
        // Find which axis to snap
        BOOL isHorizontal = [self isHorizontal:scrollView];
        
        // What is the current offset?
        CGFloat velocityAlongAxis = isHorizontal ? velocity.x : velocity.y;
        CGFloat targetContentOffsetAlongAxis = isHorizontal ? targetContentOffset->x : targetContentOffset->y;
        
        // Offset based on desired alignment
        CGFloat frameLength = isHorizontal ? self.frame.size.width : self.frame.size.height;
        CGFloat alignmentOffset = 0.0f;
        if ([self.snapToAlignment isEqualToString: @"center"]) {
            alignmentOffset = (frameLength * 0.5f) + (snapToIntervalF * 0.5f);
        } else if ([self.snapToAlignment isEqualToString: @"end"]) {
            alignmentOffset = frameLength;
        }
        
        // Pick snap point based on direction and proximity
        CGFloat fractionalIndex = (targetContentOffsetAlongAxis + alignmentOffset) / snapToIntervalF;
        NSInteger snapIndex =
        velocityAlongAxis > 0.0 ?
        ceil(fractionalIndex) :
        velocityAlongAxis < 0.0 ?
        floor(fractionalIndex) :
        round(fractionalIndex);
        CGFloat newTargetContentOffset = (snapIndex * snapToIntervalF) - alignmentOffset;
        
        // Set new targetContentOffset
        if (isHorizontal) {
            targetContentOffset->x = newTargetContentOffset;
        } else {
            targetContentOffset->y = newTargetContentOffset;
        }
    }
    
    NSDictionary *userData = @{
                               @"velocity": @{
                                       @"x": @(velocity.x),
                                       @"y": @(velocity.y)
                                       },
                               @"targetContentOffset": @{
                                       @"x": @(targetContentOffset->x),
                                       @"y": @(targetContentOffset->y)
                                       }
                               };
    RCT_SEND_SCROLL_EVENT(onScrollEndDrag, userData);
    RCT_FORWARD_SCROLL_EVENT(scrollViewWillEndDragging:scrollView withVelocity:velocity targetContentOffset:targetContentOffset);
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
    RCT_FORWARD_SCROLL_EVENT(scrollViewDidEndDragging:scrollView willDecelerate:decelerate);
}

- (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(UIView *)view
{
    RCT_SEND_SCROLL_EVENT(onScrollBeginDrag, nil);
    RCT_FORWARD_SCROLL_EVENT(scrollViewWillBeginZooming:scrollView withView:view);
}

- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale
{
    RCT_SEND_SCROLL_EVENT(onScrollEndDrag, nil);
    RCT_FORWARD_SCROLL_EVENT(scrollViewDidEndZooming:scrollView withView:view atScale:scale);
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
    // Fire a final scroll event
    _allowNextScrollNoMatterWhat = YES;
    [self scrollViewDidScroll:scrollView];
    
    // Fire the end deceleration event
    RCT_SEND_SCROLL_EVENT(onMomentumScrollEnd, nil);
    RCT_FORWARD_SCROLL_EVENT(scrollViewDidEndDecelerating:scrollView);
}

- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView
{
    // Fire a final scroll event
    _allowNextScrollNoMatterWhat = YES;
    [self scrollViewDidScroll:scrollView];
    
    // Fire the end deceleration event
    RCT_SEND_SCROLL_EVENT(onMomentumScrollEnd, nil);
    RCT_FORWARD_SCROLL_EVENT(scrollViewDidEndScrollingAnimation:scrollView);
}

- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView
{
    for (NSObject<UIScrollViewDelegate> *scrollListener in _scrollListeners) {
        if ([scrollListener respondsToSelector:_cmd] &&
            ![scrollListener scrollViewShouldScrollToTop:scrollView]) {
            return NO;
        }
    }
    return YES;
}

- (UIView *)viewForZoomingInScrollView:(__unused UIScrollView *)scrollView
{
    return _contentView;
}

#pragma mark - Setters

- (CGSize)_calculateViewportSize
{
    CGSize viewportSize = self.bounds.size;
    if (_automaticallyAdjustContentInsets) {
        UIEdgeInsets contentInsets = [RCTView contentInsetsForView:self];
        viewportSize = CGSizeMake(self.bounds.size.width - contentInsets.left - contentInsets.right,
                                  self.bounds.size.height - contentInsets.top - contentInsets.bottom);
    }
    return viewportSize;
}

- (CGPoint)calculateOffsetForContentSize:(CGSize)newContentSize
{
    CGPoint oldOffset = _scrollView.contentOffset;
    CGPoint newOffset = oldOffset;
    
    CGSize oldContentSize = _scrollView.contentSize;
    CGSize viewportSize = [self _calculateViewportSize];
    
    BOOL fitsinViewportY = oldContentSize.height <= viewportSize.height && newContentSize.height <= viewportSize.height;
    if (newContentSize.height < oldContentSize.height && !fitsinViewportY) {
        CGFloat offsetHeight = oldOffset.y + viewportSize.height;
        if (oldOffset.y < 0) {
            // overscrolled on top, leave offset alone
        } else if (offsetHeight > oldContentSize.height) {
            // overscrolled on the bottom, preserve overscroll amount
            newOffset.y = MAX(0, oldOffset.y - (oldContentSize.height - newContentSize.height));
        } else if (offsetHeight > newContentSize.height) {
            // offset falls outside of bounds, scroll back to end of list
            newOffset.y = MAX(0, newContentSize.height - viewportSize.height);
        }
    }
    
    BOOL fitsinViewportX = oldContentSize.width <= viewportSize.width && newContentSize.width <= viewportSize.width;
    if (newContentSize.width < oldContentSize.width && !fitsinViewportX) {
        CGFloat offsetHeight = oldOffset.x + viewportSize.width;
        if (oldOffset.x < 0) {
            // overscrolled at the beginning, leave offset alone
        } else if (offsetHeight > oldContentSize.width && newContentSize.width > viewportSize.width) {
            // overscrolled at the end, preserve overscroll amount as much as possible
            newOffset.x = MAX(0, oldOffset.x - (oldContentSize.width - newContentSize.width));
        } else if (offsetHeight > newContentSize.width) {
            // offset falls outside of bounds, scroll back to end
            newOffset.x = MAX(0, newContentSize.width - viewportSize.width);
        }
    }
    
    // all other cases, offset doesn't change
    return newOffset;
}

/**
 * Once you set the `contentSize`, to a nonzero value, it is assumed to be
 * managed by you, and we'll never automatically compute the size for you,
 * unless you manually reset it back to {0, 0}
 */
- (CGSize)contentSize
{
    if (!CGSizeEqualToSize(_contentSize, CGSizeZero)) {
        return _contentSize;
    }
    
    return _contentView.frame.size;
}

- (void)updateContentOffsetIfNeeded
{
    CGSize contentSize = self.contentSize;
    if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
        // When contentSize is set manually, ScrollView internals will reset
        // contentOffset to  {0, 0}. Since we potentially set contentSize whenever
        // anything in the ScrollView updates, we workaround this issue by manually
        // adjusting contentOffset whenever this happens
        CGPoint newOffset = [self calculateOffsetForContentSize:contentSize];
        _scrollView.contentSize = contentSize;
        _scrollView.contentOffset = newOffset;
    }
}

// maintainVisibleContentPosition is used to allow seamless loading of content from both ends of
// the scrollview without the visible content jumping in position.
- (void)setMaintainVisibleContentPosition:(NSDictionary *)maintainVisibleContentPosition
{
    if (maintainVisibleContentPosition != nil && _maintainVisibleContentPosition == nil) {
        [_eventDispatcher.bridge.uiManager.observerCoordinator addObserver:self];
    } else if (maintainVisibleContentPosition == nil && _maintainVisibleContentPosition != nil) {
        [_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self];
    }
    _maintainVisibleContentPosition = maintainVisibleContentPosition;
}

#pragma mark - RCTUIManagerObserver

- (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
{
    RCTAssertUIManagerQueue();
    [manager prependUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
        BOOL horz = [self isHorizontal:self->_scrollView];
        NSUInteger minIdx = [self->_maintainVisibleContentPosition[@"minIndexForVisible"] integerValue];
        for (NSUInteger ii = minIdx; ii < self->_contentView.subviews.count; ++ii) {
            // Find the first entirely visible view. This must be done after we update the content offset
            // or it will tend to grab rows that were made visible by the shift in position
            UIView *subview = self->_contentView.subviews[ii];
            if ((horz
                 ? subview.frame.origin.x >= self->_scrollView.contentOffset.x
                 : subview.frame.origin.y >= self->_scrollView.contentOffset.y) ||
                ii == self->_contentView.subviews.count - 1) {
                self->_prevFirstVisibleFrame = subview.frame;
                self->_firstVisibleView = subview;
                break;
            }
        }
    }];
    [manager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
        if (self->_maintainVisibleContentPosition == nil) {
            return; // The prop might have changed in the previous UIBlocks, so need to abort here.
        }
        NSNumber *autoscrollThreshold = self->_maintainVisibleContentPosition[@"autoscrollToTopThreshold"];
        // TODO: detect and handle/ignore re-ordering
        if ([self isHorizontal:self->_scrollView]) {
            CGFloat deltaX = self->_firstVisibleView.frame.origin.x - self->_prevFirstVisibleFrame.origin.x;
            if (ABS(deltaX) > 0.1) {
                self->_scrollView.contentOffset = CGPointMake(
                                                              self->_scrollView.contentOffset.x + deltaX,
                                                              self->_scrollView.contentOffset.y
                                                              );
                if (autoscrollThreshold != nil) {
                    // If the offset WAS within the threshold of the start, animate to the start.
                    if (self->_scrollView.contentOffset.x - deltaX <= [autoscrollThreshold integerValue]) {
                        [self scrollToOffset:CGPointMake(0, self->_scrollView.contentOffset.y) animated:YES];
                    }
                }
            }
        } else {
            CGRect newFrame = self->_firstVisibleView.frame;
            CGFloat deltaY = newFrame.origin.y - self->_prevFirstVisibleFrame.origin.y;
            if (ABS(deltaY) > 0.1) {
                self->_scrollView.contentOffset = CGPointMake(
                                                              self->_scrollView.contentOffset.x,
                                                              self->_scrollView.contentOffset.y + deltaY
                                                              );
                if (autoscrollThreshold != nil) {
                    // If the offset WAS within the threshold of the start, animate to the start.
                    if (self->_scrollView.contentOffset.y - deltaY <= [autoscrollThreshold integerValue]) {
                        [self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, 0) animated:YES];
                    }
                }
            }
        }
    }];
}

// Note: setting several properties of UIScrollView has the effect of
// resetting its contentOffset to {0, 0}. To prevent this, we generate
// setters here that will record the contentOffset beforehand, and
// restore it after the property has been set.

#define RCT_SET_AND_PRESERVE_OFFSET(setter, getter, type) \
- (void)setter:(type)value                                \
{                                                         \
CGPoint contentOffset = _scrollView.contentOffset;      \
[_scrollView setter:value];                             \
_scrollView.contentOffset = contentOffset;              \
}                                                         \
- (type)getter                                            \
{                                                         \
return [_scrollView getter];                            \
}

RCT_SET_AND_PRESERVE_OFFSET(setAlwaysBounceHorizontal, alwaysBounceHorizontal, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setAlwaysBounceVertical, alwaysBounceVertical, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setBounces, bounces, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setBouncesZoom, bouncesZoom, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setCanCancelContentTouches, canCancelContentTouches, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setDecelerationRate, decelerationRate, CGFloat)
RCT_SET_AND_PRESERVE_OFFSET(setDirectionalLockEnabled, isDirectionalLockEnabled, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setIndicatorStyle, indicatorStyle, UIScrollViewIndicatorStyle)
RCT_SET_AND_PRESERVE_OFFSET(setKeyboardDismissMode, keyboardDismissMode, UIScrollViewKeyboardDismissMode)
RCT_SET_AND_PRESERVE_OFFSET(setMaximumZoomScale, maximumZoomScale, CGFloat)
RCT_SET_AND_PRESERVE_OFFSET(setMinimumZoomScale, minimumZoomScale, CGFloat)
RCT_SET_AND_PRESERVE_OFFSET(setScrollEnabled, isScrollEnabled, BOOL)
#if !TARGET_OS_TV
RCT_SET_AND_PRESERVE_OFFSET(setPagingEnabled, isPagingEnabled, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setScrollsToTop, scrollsToTop, BOOL)
#endif
RCT_SET_AND_PRESERVE_OFFSET(setShowsHorizontalScrollIndicator, showsHorizontalScrollIndicator, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setShowsVerticalScrollIndicator, showsVerticalScrollIndicator, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setZoomScale, zoomScale, CGFloat);
RCT_SET_AND_PRESERVE_OFFSET(setScrollIndicatorInsets, scrollIndicatorInsets, UIEdgeInsets);

#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 /* __IPHONE_11_0 */
- (void)setContentInsetAdjustmentBehavior:(UIScrollViewContentInsetAdjustmentBehavior)behavior
{
    // `contentInsetAdjustmentBehavior` is available since iOS 11.
    if ([_scrollView respondsToSelector:@selector(setContentInsetAdjustmentBehavior:)]) {
        CGPoint contentOffset = _scrollView.contentOffset;
        _scrollView.contentInsetAdjustmentBehavior = behavior;
        _scrollView.contentOffset = contentOffset;
    }
}
#endif

- (void)sendScrollEventWithName:(NSString *)eventName
                     scrollView:(UIScrollView *)scrollView
                       userData:(NSDictionary *)userData
{
    if (![_lastEmittedEventName isEqualToString:eventName]) {
        _coalescingKey++;
        _lastEmittedEventName = [eventName copy];
    }
    RCTMJScrollEvent *scrollEvent = [[RCTMJScrollEvent alloc] initWithEventName:eventName
                                                                   reactTag:self.reactTag
                                                    scrollViewContentOffset:scrollView.contentOffset
                                                     scrollViewContentInset:scrollView.contentInset
                                                      scrollViewContentSize:scrollView.contentSize
                                                            scrollViewFrame:scrollView.frame
                                                        scrollViewZoomScale:scrollView.zoomScale
                                                                   userData:userData
                                                              coalescingKey:_coalescingKey];
    [_eventDispatcher sendEvent:scrollEvent];
}

@end

@implementation RCTEventDispatcher (RCTMJScrollView)

- (void)sendFakeScrollEvent:(NSNumber *)reactTag
{
    // Use the selector here in case the onScroll block property is ever renamed
    NSString *eventName = NSStringFromSelector(@selector(onScroll));
    RCTMJScrollEvent *fakeScrollEvent = [[RCTMJScrollEvent alloc] initWithEventName:eventName
                                                                       reactTag:reactTag
                                                        scrollViewContentOffset:CGPointZero
                                                         scrollViewContentInset:UIEdgeInsetsZero
                                                          scrollViewContentSize:CGSizeZero
                                                                scrollViewFrame:CGRectZero
                                                            scrollViewZoomScale:0
                                                                       userData:nil
                                                                  coalescingKey:0];
    [self sendEvent:fakeScrollEvent];
}

@end

