UNPKG

42.1 kBPlain TextView Raw
1/*
2 * Copyright (c) Facebook, Inc. and its affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8#import "RCTScrollView.h"
9
10#import <UIKit/UIKit.h>
11
12#import "RCTConvert.h"
13#import "RCTLog.h"
14#import "RCTUIManager.h"
15#import "RCTUIManagerObserverCoordinator.h"
16#import "RCTUIManagerUtils.h"
17#import "RCTUtils.h"
18#import "UIView+Private.h"
19#import "UIView+React.h"
20#import "RCTScrollEvent.h"
21
22#if !TARGET_OS_TV
23#import "RCTRefreshControl.h"
24#endif
25
26/**
27 * Include a custom scroll view subclass because we want to limit certain
28 * default UIKit behaviors such as textFields automatically scrolling
29 * scroll views that contain them.
30 */
31@interface RCTCustomScrollView : UIScrollView<UIGestureRecognizerDelegate>
32
33@property (nonatomic, assign) BOOL centerContent;
34#if !TARGET_OS_TV
35@property (nonatomic, strong) UIView<RCTCustomRefreshContolProtocol> *customRefreshControl;
36@property (nonatomic, assign) BOOL pinchGestureEnabled;
37#endif
38
39@end
40
41@implementation RCTCustomScrollView
42
43- (instancetype)initWithFrame:(CGRect)frame
44{
45 if ((self = [super initWithFrame:frame])) {
46 [self.panGestureRecognizer addTarget:self action:@selector(handleCustomPan:)];
47
48 if ([self respondsToSelector:@selector(setSemanticContentAttribute:)]) {
49 // We intentionally force `UIScrollView`s `semanticContentAttribute` to `LTR` here
50 // because this attribute affects a position of vertical scrollbar; we don't want this
51 // scrollbar flip because we also flip it with whole `UIScrollView` flip.
52 self.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
53 }
54
55 #if !TARGET_OS_TV
56 _pinchGestureEnabled = YES;
57 #endif
58 }
59 return self;
60}
61
62- (UIView *)contentView
63{
64 return ((RCTScrollView *)self.superview).contentView;
65}
66
67/**
68 * @return Whether or not the scroll view interaction should be blocked because
69 * JS was found to be the responder.
70 */
71- (BOOL)_shouldDisableScrollInteraction
72{
73 // Since this may be called on every pan, we need to make sure to only climb
74 // the hierarchy on rare occasions.
75 UIView *JSResponder = [RCTUIManager JSResponder];
76 if (JSResponder && JSResponder != self.superview) {
77 BOOL superviewHasResponder = [self isDescendantOfView:JSResponder];
78 return superviewHasResponder;
79 }
80 return NO;
81}
82
83- (void)handleCustomPan:(__unused UIPanGestureRecognizer *)sender
84{
85 if ([self _shouldDisableScrollInteraction] && ![[RCTUIManager JSResponder] isKindOfClass:[RCTScrollView class]]) {
86 self.panGestureRecognizer.enabled = NO;
87 self.panGestureRecognizer.enabled = YES;
88 // TODO: If mid bounce, animate the scroll view to a non-bounced position
89 // while disabling (but only if `stopScrollInteractionIfJSHasResponder` was
90 // called *during* a `pan`). Currently, it will just snap into place which
91 // is not so bad either.
92 // Another approach:
93 // self.scrollEnabled = NO;
94 // self.scrollEnabled = YES;
95 }
96}
97
98- (void)scrollRectToVisible:(CGRect)rect animated:(BOOL)animated
99{
100 // Limiting scroll area to an area where we actually have content.
101 CGSize contentSize = self.contentSize;
102 UIEdgeInsets contentInset = self.contentInset;
103 CGSize fullSize = CGSizeMake(
104 contentSize.width + contentInset.left + contentInset.right,
105 contentSize.height + contentInset.top + contentInset.bottom);
106
107 rect = CGRectIntersection((CGRect){CGPointZero, fullSize}, rect);
108 if (CGRectIsNull(rect)) {
109 return;
110 }
111
112 [super scrollRectToVisible:rect animated:animated];
113}
114
115/**
116 * Returning `YES` cancels touches for the "inner" `view` and causes a scroll.
117 * Returning `NO` causes touches to be directed to that inner view and prevents
118 * the scroll view from scrolling.
119 *
120 * `YES` -> Allows scrolling.
121 * `NO` -> Doesn't allow scrolling.
122 *
123 * By default this returns NO for all views that are UIControls and YES for
124 * everything else. What that does is allows scroll views to scroll even when a
125 * touch started inside of a `UIControl` (`UIButton` etc). For React scroll
126 * views, we want the default to be the same behavior as `UIControl`s so we
127 * return `YES` by default. But there's one case where we want to block the
128 * scrolling no matter what: When JS believes it has its own responder lock on
129 * a view that is *above* the scroll view in the hierarchy. So we abuse this
130 * `touchesShouldCancelInContentView` API in order to stop the scroll view from
131 * scrolling in this case.
132 *
133 * We are not aware of *any* other solution to the problem because alternative
134 * approaches require that we disable the scrollview *before* touches begin or
135 * move. This approach (`touchesShouldCancelInContentView`) works even if the
136 * JS responder is set after touches start/move because
137 * `touchesShouldCancelInContentView` is called as soon as the scroll view has
138 * been touched and dragged *just* far enough to decide to begin the "drag"
139 * movement of the scroll interaction. Returning `NO`, will cause the drag
140 * operation to fail.
141 *
142 * `touchesShouldCancelInContentView` will stop the *initialization* of a
143 * scroll pan gesture and most of the time this is sufficient. On rare
144 * occasion, the scroll gesture would have already initialized right before JS
145 * notifies native of the JS responder being set. In order to recover from that
146 * timing issue we have a fallback that kills any ongoing pan gesture that
147 * occurs when native is notified of a JS responder.
148 *
149 * Note: Explicitly returning `YES`, instead of relying on the default fixes
150 * (at least) one bug where if you have a UIControl inside a UIScrollView and
151 * tap on the UIControl and then start dragging (to scroll), it won't scroll.
152 * Chat with @andras for more details.
153 *
154 * In order to have this called, you must have delaysContentTouches set to NO
155 * (which is the not the `UIKit` default).
156 */
157- (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view
158{
159 BOOL shouldDisableScrollInteraction = [self _shouldDisableScrollInteraction];
160
161 if (shouldDisableScrollInteraction == NO) {
162 [super touchesShouldCancelInContentView:view];
163 }
164
165 return !shouldDisableScrollInteraction;
166}
167
168/*
169 * Automatically centers the content such that if the content is smaller than the
170 * ScrollView, we force it to be centered, but when you zoom or the content otherwise
171 * becomes larger than the ScrollView, there is no padding around the content but it
172 * can still fill the whole view.
173 */
174- (void)setContentOffset:(CGPoint)contentOffset
175{
176 UIView *contentView = [self contentView];
177 if (contentView && _centerContent && !CGSizeEqualToSize(contentView.frame.size, CGSizeZero)) {
178 CGSize subviewSize = contentView.frame.size;
179 CGSize scrollViewSize = self.bounds.size;
180 if (subviewSize.width <= scrollViewSize.width) {
181 contentOffset.x = -(scrollViewSize.width - subviewSize.width) / 2.0;
182 }
183 if (subviewSize.height <= scrollViewSize.height) {
184 contentOffset.y = -(scrollViewSize.height - subviewSize.height) / 2.0;
185 }
186 }
187
188 super.contentOffset = CGPointMake(
189 RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
190 RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
191}
192
193- (void)setFrame:(CGRect)frame
194{
195 // Preserving and revalidating `contentOffset`.
196 CGPoint originalOffset = self.contentOffset;
197
198 [super setFrame:frame];
199
200 UIEdgeInsets contentInset = self.contentInset;
201 CGSize contentSize = self.contentSize;
202
203 // If contentSize has not been measured yet we can't check bounds.
204 if (CGSizeEqualToSize(contentSize, CGSizeZero)) {
205 self.contentOffset = originalOffset;
206 } else {
207 if (@available(iOS 11.0, *)) {
208 if (!UIEdgeInsetsEqualToEdgeInsets(UIEdgeInsetsZero, self.adjustedContentInset)) {
209 contentInset = self.adjustedContentInset;
210 }
211 }
212 CGSize boundsSize = self.bounds.size;
213 CGFloat xMaxOffset = contentSize.width - boundsSize.width + contentInset.right;
214 CGFloat yMaxOffset = contentSize.height - boundsSize.height + contentInset.bottom;
215 // Make sure offset doesn't exceed bounds. This can happen on screen rotation.
216 if ((originalOffset.x >= -contentInset.left) && (originalOffset.x <= xMaxOffset) &&
217 (originalOffset.y >= -contentInset.top) && (originalOffset.y <= yMaxOffset)) {
218 return;
219 }
220 self.contentOffset = CGPointMake(
221 MAX(-contentInset.left, MIN(xMaxOffset, originalOffset.x)),
222 MAX(-contentInset.top, MIN(yMaxOffset, originalOffset.y)));
223 }
224}
225
226#if !TARGET_OS_TV
227- (void)setCustomRefreshControl:(UIView<RCTCustomRefreshContolProtocol> *)refreshControl
228{
229 if (_customRefreshControl) {
230 [_customRefreshControl removeFromSuperview];
231 }
232 _customRefreshControl = refreshControl;
233 [self addSubview:_customRefreshControl];
234}
235
236- (void)setPinchGestureEnabled:(BOOL)pinchGestureEnabled
237{
238 self.pinchGestureRecognizer.enabled = pinchGestureEnabled;
239 _pinchGestureEnabled = pinchGestureEnabled;
240}
241
242- (void)didMoveToWindow
243{
244 [super didMoveToWindow];
245 // ScrollView enables pinch gesture late in its lifecycle. So simply setting it
246 // in the setter gets overridden when the view loads.
247 self.pinchGestureRecognizer.enabled = _pinchGestureEnabled;
248}
249#endif //TARGET_OS_TV
250
251- (BOOL)shouldGroupAccessibilityChildren
252{
253 return YES;
254}
255
256@end
257
258@interface RCTScrollView () <RCTUIManagerObserver>
259
260@end
261
262@implementation RCTScrollView
263{
264 RCTEventDispatcher *_eventDispatcher;
265 CGRect _prevFirstVisibleFrame;
266 __weak UIView *_firstVisibleView;
267 RCTCustomScrollView *_scrollView;
268 UIView *_contentView;
269 NSTimeInterval _lastScrollDispatchTime;
270 NSMutableArray<NSValue *> *_cachedChildFrames;
271 BOOL _allowNextScrollNoMatterWhat;
272 CGRect _lastClippedToRect;
273 uint16_t _coalescingKey;
274 NSString *_lastEmittedEventName;
275 NSHashTable *_scrollListeners;
276}
277
278- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
279{
280 RCTAssertParam(eventDispatcher);
281
282 if ((self = [super initWithFrame:CGRectZero])) {
283 _eventDispatcher = eventDispatcher;
284
285 _scrollView = [[RCTCustomScrollView alloc] initWithFrame:CGRectZero];
286 _scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
287 _scrollView.delegate = self;
288 _scrollView.delaysContentTouches = NO;
289
290#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 /* __IPHONE_11_0 */
291 // `contentInsetAdjustmentBehavior` is only available since iOS 11.
292 // We set the default behavior to "never" so that iOS
293 // doesn't do weird things to UIScrollView insets automatically
294 // and keeps it as an opt-in behavior.
295 if ([_scrollView respondsToSelector:@selector(setContentInsetAdjustmentBehavior:)]) {
296 if (@available(iOS 11.0, *)) {
297 _scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
298 }
299 }
300#endif
301
302 _automaticallyAdjustContentInsets = YES;
303 _DEPRECATED_sendUpdatedChildFrames = NO;
304 _contentInset = UIEdgeInsetsZero;
305 _contentSize = CGSizeZero;
306 _lastClippedToRect = CGRectNull;
307
308 _scrollEventThrottle = 0.0;
309 _lastScrollDispatchTime = 0;
310 _cachedChildFrames = [NSMutableArray new];
311
312 _scrollListeners = [NSHashTable weakObjectsHashTable];
313
314 [self addSubview:_scrollView];
315 }
316 return self;
317}
318
319RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
320RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
321
322static inline void RCTApplyTransformationAccordingLayoutDirection(UIView *view, UIUserInterfaceLayoutDirection layoutDirection) {
323 view.transform =
324 layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight ?
325 CGAffineTransformIdentity :
326 CGAffineTransformMakeScale(-1, 1);
327}
328
329- (void)setReactLayoutDirection:(UIUserInterfaceLayoutDirection)layoutDirection
330{
331 [super setReactLayoutDirection:layoutDirection];
332
333 RCTApplyTransformationAccordingLayoutDirection(_scrollView, layoutDirection);
334 RCTApplyTransformationAccordingLayoutDirection(_contentView, layoutDirection);
335}
336
337- (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
338{
339 // Does nothing
340}
341
342- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
343{
344 [super insertReactSubview:view atIndex:atIndex];
345#if !TARGET_OS_TV
346 if ([view conformsToProtocol:@protocol(RCTCustomRefreshContolProtocol)]) {
347 [_scrollView setCustomRefreshControl:(UIView<RCTCustomRefreshContolProtocol> *)view];
348 if (![view isKindOfClass:[UIRefreshControl class]]
349 && [view conformsToProtocol:@protocol(UIScrollViewDelegate)]) {
350 [self addScrollListener:(UIView<UIScrollViewDelegate> *)view];
351 }
352 } else
353#endif
354 {
355 RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview, the already set subview looks like: %@", [_contentView react_recursiveDescription]);
356 _contentView = view;
357 RCTApplyTransformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
358 [_scrollView addSubview:view];
359 }
360}
361
362- (void)removeReactSubview:(UIView *)subview
363{
364 [super removeReactSubview:subview];
365#if !TARGET_OS_TV
366 if ([subview conformsToProtocol:@protocol(RCTCustomRefreshContolProtocol)]) {
367 [_scrollView setCustomRefreshControl:nil];
368 if (![subview isKindOfClass:[UIRefreshControl class]]
369 && [subview conformsToProtocol:@protocol(UIScrollViewDelegate)]) {
370 [self removeScrollListener:(UIView<UIScrollViewDelegate> *)subview];
371 }
372 } else
373#endif
374 {
375 RCTAssert(_contentView == subview, @"Attempted to remove non-existent subview");
376 _contentView = nil;
377 }
378}
379
380- (void)didUpdateReactSubviews
381{
382 // Do nothing, as subviews are managed by `insertReactSubview:atIndex:`
383}
384
385- (void)didSetProps:(NSArray<NSString *> *)changedProps
386{
387 if ([changedProps containsObject:@"contentSize"]) {
388 [self updateContentOffsetIfNeeded];
389 }
390}
391
392- (BOOL)centerContent
393{
394 return _scrollView.centerContent;
395}
396
397- (void)setCenterContent:(BOOL)centerContent
398{
399 _scrollView.centerContent = centerContent;
400}
401
402- (void)setClipsToBounds:(BOOL)clipsToBounds
403{
404 super.clipsToBounds = clipsToBounds;
405 _scrollView.clipsToBounds = clipsToBounds;
406}
407
408- (void)dealloc
409{
410 _scrollView.delegate = nil;
411 [_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self];
412}
413
414- (void)layoutSubviews
415{
416 [super layoutSubviews];
417 RCTAssert(self.subviews.count == 1, @"we should only have exactly one subview");
418 RCTAssert([self.subviews lastObject] == _scrollView, @"our only subview should be a scrollview");
419
420#if !TARGET_OS_TV
421 // Adjust the refresh control frame if the scrollview layout changes.
422 UIView<RCTCustomRefreshContolProtocol> *refreshControl = _scrollView.customRefreshControl;
423 if (refreshControl && refreshControl.isRefreshing) {
424 refreshControl.frame = (CGRect){_scrollView.contentOffset, {_scrollView.frame.size.width, refreshControl.frame.size.height}};
425 }
426#endif
427
428 [self updateClippedSubviews];
429}
430
431- (void)updateClippedSubviews
432{
433 // Find a suitable view to use for clipping
434 UIView *clipView = [self react_findClipView];
435 if (!clipView) {
436 return;
437 }
438
439 static const CGFloat leeway = 1.0;
440
441 const CGSize contentSize = _scrollView.contentSize;
442 const CGRect bounds = _scrollView.bounds;
443 const BOOL scrollsHorizontally = contentSize.width > bounds.size.width;
444 const BOOL scrollsVertically = contentSize.height > bounds.size.height;
445
446 const BOOL shouldClipAgain =
447 CGRectIsNull(_lastClippedToRect) ||
448 !CGRectEqualToRect(_lastClippedToRect, bounds) ||
449 (scrollsHorizontally && (bounds.size.width < leeway || fabs(_lastClippedToRect.origin.x - bounds.origin.x) >= leeway)) ||
450 (scrollsVertically && (bounds.size.height < leeway || fabs(_lastClippedToRect.origin.y - bounds.origin.y) >= leeway));
451
452 if (shouldClipAgain) {
453 const CGRect clipRect = CGRectInset(clipView.bounds, -leeway, -leeway);
454 [self react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
455 _lastClippedToRect = bounds;
456 }
457}
458
459- (void)setContentInset:(UIEdgeInsets)contentInset
460{
461 if (UIEdgeInsetsEqualToEdgeInsets(contentInset, _contentInset)) {
462 return;
463 }
464
465 CGPoint contentOffset = _scrollView.contentOffset;
466
467 _contentInset = contentInset;
468 [RCTView autoAdjustInsetsForView:self
469 withScrollView:_scrollView
470 updateOffset:NO];
471
472 _scrollView.contentOffset = contentOffset;
473}
474
475- (BOOL)isHorizontal:(UIScrollView *)scrollView
476{
477 return scrollView.contentSize.width > self.frame.size.width;
478}
479
480- (void)scrollToOffset:(CGPoint)offset
481{
482 [self scrollToOffset:offset animated:YES];
483}
484
485- (void)scrollToOffset:(CGPoint)offset animated:(BOOL)animated
486{
487 if (!CGPointEqualToPoint(_scrollView.contentOffset, offset)) {
488 CGRect maxRect = CGRectMake(fmin(-_scrollView.contentInset.left, 0),
489 fmin(-_scrollView.contentInset.top, 0),
490 fmax(_scrollView.contentSize.width - _scrollView.bounds.size.width + _scrollView.contentInset.right + fmax(_scrollView.contentInset.left, 0), 0.01),
491 fmax(_scrollView.contentSize.height - _scrollView.bounds.size.height + _scrollView.contentInset.bottom + fmax(_scrollView.contentInset.top, 0), 0.01)); // Make width and height greater than 0
492 // Ensure at least one scroll event will fire
493 _allowNextScrollNoMatterWhat = YES;
494 if (!CGRectContainsPoint(maxRect, offset) && !self.scrollToOverflowEnabled) {
495 CGFloat x = fmax(offset.x, CGRectGetMinX(maxRect));
496 x = fmin(x, CGRectGetMaxX(maxRect));
497 CGFloat y = fmax(offset.y, CGRectGetMinY(maxRect));
498 y = fmin(y, CGRectGetMaxY(maxRect));
499 offset = CGPointMake(x, y);
500 }
501 [_scrollView setContentOffset:offset animated:animated];
502 }
503}
504
505/**
506 * If this is a vertical scroll view, scrolls to the bottom.
507 * If this is a horizontal scroll view, scrolls to the right.
508 */
509- (void)scrollToEnd:(BOOL)animated
510{
511 BOOL isHorizontal = [self isHorizontal:_scrollView];
512 CGPoint offset;
513 if (isHorizontal) {
514 CGFloat offsetX = _scrollView.contentSize.width - _scrollView.bounds.size.width + _scrollView.contentInset.right;
515 offset = CGPointMake(fmax(offsetX, 0), 0);
516 } else {
517 CGFloat offsetY = _scrollView.contentSize.height - _scrollView.bounds.size.height + _scrollView.contentInset.bottom;
518 offset = CGPointMake(0, fmax(offsetY, 0));
519 }
520 if (!CGPointEqualToPoint(_scrollView.contentOffset, offset)) {
521 // Ensure at least one scroll event will fire
522 _allowNextScrollNoMatterWhat = YES;
523 [_scrollView setContentOffset:offset animated:animated];
524 }
525}
526
527- (void)zoomToRect:(CGRect)rect animated:(BOOL)animated
528{
529 [_scrollView zoomToRect:rect animated:animated];
530}
531
532- (void)refreshContentInset
533{
534 [RCTView autoAdjustInsetsForView:self
535 withScrollView:_scrollView
536 updateOffset:YES];
537}
538
539#pragma mark - ScrollView delegate
540
541#define RCT_SEND_SCROLL_EVENT(_eventName, _userData) { \
542 NSString *eventName = NSStringFromSelector(@selector(_eventName)); \
543 [self sendScrollEventWithName:eventName scrollView:_scrollView userData:_userData]; \
544}
545
546#define RCT_FORWARD_SCROLL_EVENT(call) \
547for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) { \
548 if ([scrollViewListener respondsToSelector:_cmd]) { \
549 [scrollViewListener call]; \
550 } \
551}
552
553#define RCT_SCROLL_EVENT_HANDLER(delegateMethod, eventName) \
554- (void)delegateMethod:(UIScrollView *)scrollView \
555{ \
556 RCT_SEND_SCROLL_EVENT(eventName, nil); \
557 RCT_FORWARD_SCROLL_EVENT(delegateMethod:scrollView); \
558}
559
560RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, onMomentumScrollBegin)
561RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll)
562RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
563
564- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
565{
566 [_scrollListeners addObject:scrollListener];
567}
568
569- (void)removeScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
570{
571 [_scrollListeners removeObject:scrollListener];
572}
573
574- (void)scrollViewDidScroll:(UIScrollView *)scrollView
575{
576 NSTimeInterval now = CACurrentMediaTime();
577 [self updateClippedSubviews];
578 /**
579 * TODO: this logic looks wrong, and it may be because it is. Currently, if _scrollEventThrottle
580 * is set to zero (the default), the "didScroll" event is only sent once per scroll, instead of repeatedly
581 * while scrolling as expected. However, if you "fix" that bug, ScrollView will generate repeated
582 * warnings, and behave strangely (ListView works fine however), so don't fix it unless you fix that too!
583 *
584 * We limit the delta to 17ms so that small throttles intended to enable 60fps updates will not
585 * inadvertently filter out any scroll events.
586 */
587 if (_allowNextScrollNoMatterWhat ||
588 (_scrollEventThrottle > 0 && _scrollEventThrottle < MAX(0.017, now - _lastScrollDispatchTime))) {
589
590 if (_DEPRECATED_sendUpdatedChildFrames) {
591 // Calculate changed frames
592 RCT_SEND_SCROLL_EVENT(onScroll, (@{@"updatedChildFrames": [self calculateChildFramesData]}));
593 } else {
594 RCT_SEND_SCROLL_EVENT(onScroll, nil);
595 }
596
597 // Update dispatch time
598 _lastScrollDispatchTime = now;
599 _allowNextScrollNoMatterWhat = NO;
600 }
601 RCT_FORWARD_SCROLL_EVENT(scrollViewDidScroll:scrollView);
602}
603
604- (NSArray<NSDictionary *> *)calculateChildFramesData
605{
606 NSMutableArray<NSDictionary *> *updatedChildFrames = [NSMutableArray new];
607 [[_contentView reactSubviews] enumerateObjectsUsingBlock:
608 ^(UIView *subview, NSUInteger idx, __unused BOOL *stop) {
609
610 // Check if new or changed
611 CGRect newFrame = subview.frame;
612 BOOL frameChanged = NO;
613 if (self->_cachedChildFrames.count <= idx) {
614 frameChanged = YES;
615 [self->_cachedChildFrames addObject:[NSValue valueWithCGRect:newFrame]];
616 } else if (!CGRectEqualToRect(newFrame, [self->_cachedChildFrames[idx] CGRectValue])) {
617 frameChanged = YES;
618 self->_cachedChildFrames[idx] = [NSValue valueWithCGRect:newFrame];
619 }
620
621 // Create JS frame object
622 if (frameChanged) {
623 [updatedChildFrames addObject: @{
624 @"index": @(idx),
625 @"x": @(newFrame.origin.x),
626 @"y": @(newFrame.origin.y),
627 @"width": @(newFrame.size.width),
628 @"height": @(newFrame.size.height),
629 }];
630 }
631 }];
632
633 return updatedChildFrames;
634}
635
636- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
637{
638 _allowNextScrollNoMatterWhat = YES; // Ensure next scroll event is recorded, regardless of throttle
639 RCT_SEND_SCROLL_EVENT(onScrollBeginDrag, nil);
640 RCT_FORWARD_SCROLL_EVENT(scrollViewWillBeginDragging:scrollView);
641}
642
643- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
644{
645 if (self.snapToOffsets) {
646 // An alternative to enablePaging and snapToInterval which allows setting custom
647 // stopping points that don't have to be the same distance apart. Often seen in
648 // apps which feature horizonally scrolling items. snapToInterval does not enforce
649 // scrolling one interval at a time but guarantees that the scroll will stop at
650 // a snap offset point.
651
652 // Find which axis to snap
653 BOOL isHorizontal = [self isHorizontal:scrollView];
654 CGFloat velocityAlongAxis = isHorizontal ? velocity.x : velocity.y;
655 CGFloat offsetAlongAxis = isHorizontal ? _scrollView.contentOffset.x : _scrollView.contentOffset.y;
656
657 // Calculate maximum content offset
658 CGSize viewportSize = [self _calculateViewportSize];
659 CGFloat maximumOffset = isHorizontal
660 ? MAX(0, _scrollView.contentSize.width - viewportSize.width)
661 : MAX(0, _scrollView.contentSize.height - viewportSize.height);
662
663 // Calculate the snap offsets adjacent to the initial offset target
664 CGFloat targetOffset = isHorizontal ? targetContentOffset->x : targetContentOffset->y;
665 CGFloat smallerOffset = 0.0;
666 CGFloat largerOffset = maximumOffset;
667
668 for (unsigned long i = 0; i < self.snapToOffsets.count; i++) {
669 CGFloat offset = [[self.snapToOffsets objectAtIndex:i] floatValue];
670
671 if (offset <= targetOffset) {
672 if (targetOffset - offset < targetOffset - smallerOffset) {
673 smallerOffset = offset;
674 }
675 }
676
677 if (offset >= targetOffset) {
678 if (offset - targetOffset < largerOffset - targetOffset) {
679 largerOffset = offset;
680 }
681 }
682 }
683
684 // Calculate the nearest offset
685 CGFloat nearestOffset = targetOffset - smallerOffset < largerOffset - targetOffset
686 ? smallerOffset
687 : largerOffset;
688
689 CGFloat firstOffset = [[self.snapToOffsets firstObject] floatValue];
690 CGFloat lastOffset = [[self.snapToOffsets lastObject] floatValue];
691
692 // if scrolling after the last snap offset and snapping to the
693 // end of the list is disabled, then we allow free scrolling
694 if (!self.snapToEnd && targetOffset >= lastOffset) {
695 if (offsetAlongAxis >= lastOffset) {
696 // free scrolling
697 } else {
698 // snap to end
699 targetOffset = lastOffset;
700 }
701 } else if (!self.snapToStart && targetOffset <= firstOffset) {
702 if (offsetAlongAxis <= firstOffset) {
703 // free scrolling
704 } else {
705 // snap to beginning
706 targetOffset = firstOffset;
707 }
708 } else if (velocityAlongAxis > 0.0) {
709 targetOffset = largerOffset;
710 } else if (velocityAlongAxis < 0.0) {
711 targetOffset = smallerOffset;
712 } else {
713 targetOffset = nearestOffset;
714 }
715
716 // Make sure the new offset isn't out of bounds
717 targetOffset = MIN(MAX(0, targetOffset), maximumOffset);
718
719 // Set new targetContentOffset
720 if (isHorizontal) {
721 targetContentOffset->x = targetOffset;
722 } else {
723 targetContentOffset->y = targetOffset;
724 }
725 } else if (self.snapToInterval) {
726 // An alternative to enablePaging which allows setting custom stopping intervals,
727 // smaller than a full page size. Often seen in apps which feature horizonally
728 // scrolling items. snapToInterval does not enforce scrolling one interval at a time
729 // but guarantees that the scroll will stop at an interval point.
730 CGFloat snapToIntervalF = (CGFloat)self.snapToInterval;
731
732 // Find which axis to snap
733 BOOL isHorizontal = [self isHorizontal:scrollView];
734
735 // What is the current offset?
736 CGFloat velocityAlongAxis = isHorizontal ? velocity.x : velocity.y;
737 CGFloat targetContentOffsetAlongAxis = targetContentOffset->y;
738 if (isHorizontal) {
739 // Use current scroll offset to determine the next index to snap to when momentum disabled
740 targetContentOffsetAlongAxis = self.disableIntervalMomentum ? scrollView.contentOffset.x : targetContentOffset->x;
741 }
742
743 // Offset based on desired alignment
744 CGFloat frameLength = isHorizontal ? self.frame.size.width : self.frame.size.height;
745 CGFloat alignmentOffset = 0.0f;
746 if ([self.snapToAlignment isEqualToString: @"center"]) {
747 alignmentOffset = (frameLength * 0.5f) + (snapToIntervalF * 0.5f);
748 } else if ([self.snapToAlignment isEqualToString: @"end"]) {
749 alignmentOffset = frameLength;
750 }
751
752 // Pick snap point based on direction and proximity
753 CGFloat fractionalIndex = (targetContentOffsetAlongAxis + alignmentOffset) / snapToIntervalF;
754
755 NSInteger snapIndex =
756 velocityAlongAxis > 0.0 ?
757 ceil(fractionalIndex) :
758 velocityAlongAxis < 0.0 ?
759 floor(fractionalIndex) :
760 round(fractionalIndex);
761 CGFloat newTargetContentOffset = (snapIndex * snapToIntervalF) - alignmentOffset;
762
763 // Set new targetContentOffset
764 if (isHorizontal) {
765 targetContentOffset->x = newTargetContentOffset;
766 } else {
767 targetContentOffset->y = newTargetContentOffset;
768 }
769 }
770
771 NSDictionary *userData = @{
772 @"velocity": @{
773 @"x": @(velocity.x),
774 @"y": @(velocity.y)
775 },
776 @"targetContentOffset": @{
777 @"x": @(targetContentOffset->x),
778 @"y": @(targetContentOffset->y)
779 }
780 };
781 RCT_SEND_SCROLL_EVENT(onScrollEndDrag, userData);
782 RCT_FORWARD_SCROLL_EVENT(scrollViewWillEndDragging:scrollView withVelocity:velocity targetContentOffset:targetContentOffset);
783}
784
785- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
786{
787 RCT_FORWARD_SCROLL_EVENT(scrollViewDidEndDragging:scrollView willDecelerate:decelerate);
788}
789
790- (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(UIView *)view
791{
792 RCT_SEND_SCROLL_EVENT(onScrollBeginDrag, nil);
793 RCT_FORWARD_SCROLL_EVENT(scrollViewWillBeginZooming:scrollView withView:view);
794}
795
796- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale
797{
798 RCT_SEND_SCROLL_EVENT(onScrollEndDrag, nil);
799 RCT_FORWARD_SCROLL_EVENT(scrollViewDidEndZooming:scrollView withView:view atScale:scale);
800}
801
802- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
803{
804 // Fire a final scroll event
805 _allowNextScrollNoMatterWhat = YES;
806 [self scrollViewDidScroll:scrollView];
807
808 // Fire the end deceleration event
809 RCT_SEND_SCROLL_EVENT(onMomentumScrollEnd, nil);
810 RCT_FORWARD_SCROLL_EVENT(scrollViewDidEndDecelerating:scrollView);
811}
812
813- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView
814{
815 // Fire a final scroll event
816 _allowNextScrollNoMatterWhat = YES;
817 [self scrollViewDidScroll:scrollView];
818
819 // Fire the end deceleration event
820 RCT_SEND_SCROLL_EVENT(onMomentumScrollEnd, nil);
821 RCT_FORWARD_SCROLL_EVENT(scrollViewDidEndScrollingAnimation:scrollView);
822}
823
824- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView
825{
826 for (NSObject<UIScrollViewDelegate> *scrollListener in _scrollListeners) {
827 if ([scrollListener respondsToSelector:_cmd] &&
828 ![scrollListener scrollViewShouldScrollToTop:scrollView]) {
829 return NO;
830 }
831 }
832
833 if (self.inverted) {
834 [self scrollToEnd:YES];
835 return NO;
836 }
837
838 return YES;
839}
840
841- (UIView *)viewForZoomingInScrollView:(__unused UIScrollView *)scrollView
842{
843 return _contentView;
844}
845
846#pragma mark - Setters
847
848- (CGSize)_calculateViewportSize
849{
850 CGSize viewportSize = self.bounds.size;
851 if (_automaticallyAdjustContentInsets) {
852 UIEdgeInsets contentInsets = [RCTView contentInsetsForView:self];
853 viewportSize = CGSizeMake(self.bounds.size.width - contentInsets.left - contentInsets.right,
854 self.bounds.size.height - contentInsets.top - contentInsets.bottom);
855 }
856 return viewportSize;
857}
858
859- (CGPoint)calculateOffsetForContentSize:(CGSize)newContentSize
860{
861 CGPoint oldOffset = _scrollView.contentOffset;
862 CGPoint newOffset = oldOffset;
863
864 CGSize oldContentSize = _scrollView.contentSize;
865 CGSize viewportSize = [self _calculateViewportSize];
866
867 BOOL fitsinViewportY = oldContentSize.height <= viewportSize.height && newContentSize.height <= viewportSize.height;
868 if (newContentSize.height < oldContentSize.height && !fitsinViewportY) {
869 CGFloat offsetHeight = oldOffset.y + viewportSize.height;
870 if (oldOffset.y < 0) {
871 // overscrolled on top, leave offset alone
872 } else if (offsetHeight > oldContentSize.height) {
873 // overscrolled on the bottom, preserve overscroll amount
874 newOffset.y = MAX(0, oldOffset.y - (oldContentSize.height - newContentSize.height));
875 } else if (offsetHeight > newContentSize.height) {
876 // offset falls outside of bounds, scroll back to end of list
877 newOffset.y = MAX(0, newContentSize.height - viewportSize.height);
878 }
879 }
880
881 BOOL fitsinViewportX = oldContentSize.width <= viewportSize.width && newContentSize.width <= viewportSize.width;
882 if (newContentSize.width < oldContentSize.width && !fitsinViewportX) {
883 CGFloat offsetHeight = oldOffset.x + viewportSize.width;
884 if (oldOffset.x < 0) {
885 // overscrolled at the beginning, leave offset alone
886 } else if (offsetHeight > oldContentSize.width && newContentSize.width > viewportSize.width) {
887 // overscrolled at the end, preserve overscroll amount as much as possible
888 newOffset.x = MAX(0, oldOffset.x - (oldContentSize.width - newContentSize.width));
889 } else if (offsetHeight > newContentSize.width) {
890 // offset falls outside of bounds, scroll back to end
891 newOffset.x = MAX(0, newContentSize.width - viewportSize.width);
892 }
893 }
894
895 // all other cases, offset doesn't change
896 return newOffset;
897}
898
899/**
900 * Once you set the `contentSize`, to a nonzero value, it is assumed to be
901 * managed by you, and we'll never automatically compute the size for you,
902 * unless you manually reset it back to {0, 0}
903 */
904- (CGSize)contentSize
905{
906 if (!CGSizeEqualToSize(_contentSize, CGSizeZero)) {
907 return _contentSize;
908 }
909
910 return _contentView.frame.size;
911}
912
913- (void)updateContentOffsetIfNeeded
914{
915 CGSize contentSize = self.contentSize;
916 if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
917 // When contentSize is set manually, ScrollView internals will reset
918 // contentOffset to {0, 0}. Since we potentially set contentSize whenever
919 // anything in the ScrollView updates, we workaround this issue by manually
920 // adjusting contentOffset whenever this happens
921 CGPoint newOffset = [self calculateOffsetForContentSize:contentSize];
922 _scrollView.contentSize = contentSize;
923 _scrollView.contentOffset = newOffset;
924 }
925}
926
927// maintainVisibleContentPosition is used to allow seamless loading of content from both ends of
928// the scrollview without the visible content jumping in position.
929- (void)setMaintainVisibleContentPosition:(NSDictionary *)maintainVisibleContentPosition
930{
931 if (maintainVisibleContentPosition != nil && _maintainVisibleContentPosition == nil) {
932 [_eventDispatcher.bridge.uiManager.observerCoordinator addObserver:self];
933 } else if (maintainVisibleContentPosition == nil && _maintainVisibleContentPosition != nil) {
934 [_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self];
935 }
936 _maintainVisibleContentPosition = maintainVisibleContentPosition;
937}
938
939#pragma mark - RCTUIManagerObserver
940
941- (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
942{
943 RCTAssertUIManagerQueue();
944 [manager prependUIBlock:^(__unused RCTUIManager *uiManager, __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
945 BOOL horz = [self isHorizontal:self->_scrollView];
946 NSUInteger minIdx = [self->_maintainVisibleContentPosition[@"minIndexForVisible"] integerValue];
947 for (NSUInteger ii = minIdx; ii < self->_contentView.subviews.count; ++ii) {
948 // Find the first entirely visible view. This must be done after we update the content offset
949 // or it will tend to grab rows that were made visible by the shift in position
950 UIView *subview = self->_contentView.subviews[ii];
951 if ((horz
952 ? subview.frame.origin.x >= self->_scrollView.contentOffset.x
953 : subview.frame.origin.y >= self->_scrollView.contentOffset.y) ||
954 ii == self->_contentView.subviews.count - 1) {
955 self->_prevFirstVisibleFrame = subview.frame;
956 self->_firstVisibleView = subview;
957 break;
958 }
959 }
960 }];
961 [manager addUIBlock:^(__unused RCTUIManager *uiManager, __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
962 if (self->_maintainVisibleContentPosition == nil) {
963 return; // The prop might have changed in the previous UIBlocks, so need to abort here.
964 }
965 NSNumber *autoscrollThreshold = self->_maintainVisibleContentPosition[@"autoscrollToTopThreshold"];
966 // TODO: detect and handle/ignore re-ordering
967 if ([self isHorizontal:self->_scrollView]) {
968 CGFloat deltaX = self->_firstVisibleView.frame.origin.x - self->_prevFirstVisibleFrame.origin.x;
969 if (ABS(deltaX) > 0.1) {
970 self->_scrollView.contentOffset = CGPointMake(
971 self->_scrollView.contentOffset.x + deltaX,
972 self->_scrollView.contentOffset.y
973 );
974 if (autoscrollThreshold != nil) {
975 // If the offset WAS within the threshold of the start, animate to the start.
976 if (self->_scrollView.contentOffset.x - deltaX <= [autoscrollThreshold integerValue]) {
977 [self scrollToOffset:CGPointMake(0, self->_scrollView.contentOffset.y) animated:YES];
978 }
979 }
980 }
981 } else {
982 CGRect newFrame = self->_firstVisibleView.frame;
983 CGFloat deltaY = newFrame.origin.y - self->_prevFirstVisibleFrame.origin.y;
984 if (ABS(deltaY) > 0.1) {
985 self->_scrollView.contentOffset = CGPointMake(
986 self->_scrollView.contentOffset.x,
987 self->_scrollView.contentOffset.y + deltaY
988 );
989 if (autoscrollThreshold != nil) {
990 // If the offset WAS within the threshold of the start, animate to the start.
991 if (self->_scrollView.contentOffset.y - deltaY <= [autoscrollThreshold integerValue]) {
992 [self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, 0) animated:YES];
993 }
994 }
995 }
996 }
997 }];
998}
999
1000// Note: setting several properties of UIScrollView has the effect of
1001// resetting its contentOffset to {0, 0}. To prevent this, we generate
1002// setters here that will record the contentOffset beforehand, and
1003// restore it after the property has been set.
1004
1005#define RCT_SET_AND_PRESERVE_OFFSET(setter, getter, type) \
1006- (void)setter:(type)value \
1007{ \
1008 CGPoint contentOffset = _scrollView.contentOffset; \
1009 [_scrollView setter:value]; \
1010 _scrollView.contentOffset = contentOffset; \
1011} \
1012- (type)getter \
1013{ \
1014 return [_scrollView getter]; \
1015}
1016
1017RCT_SET_AND_PRESERVE_OFFSET(setAlwaysBounceHorizontal, alwaysBounceHorizontal, BOOL)
1018RCT_SET_AND_PRESERVE_OFFSET(setAlwaysBounceVertical, alwaysBounceVertical, BOOL)
1019RCT_SET_AND_PRESERVE_OFFSET(setBounces, bounces, BOOL)
1020RCT_SET_AND_PRESERVE_OFFSET(setBouncesZoom, bouncesZoom, BOOL)
1021RCT_SET_AND_PRESERVE_OFFSET(setCanCancelContentTouches, canCancelContentTouches, BOOL)
1022RCT_SET_AND_PRESERVE_OFFSET(setDecelerationRate, decelerationRate, CGFloat)
1023RCT_SET_AND_PRESERVE_OFFSET(setDirectionalLockEnabled, isDirectionalLockEnabled, BOOL)
1024RCT_SET_AND_PRESERVE_OFFSET(setIndicatorStyle, indicatorStyle, UIScrollViewIndicatorStyle)
1025RCT_SET_AND_PRESERVE_OFFSET(setKeyboardDismissMode, keyboardDismissMode, UIScrollViewKeyboardDismissMode)
1026RCT_SET_AND_PRESERVE_OFFSET(setMaximumZoomScale, maximumZoomScale, CGFloat)
1027RCT_SET_AND_PRESERVE_OFFSET(setMinimumZoomScale, minimumZoomScale, CGFloat)
1028RCT_SET_AND_PRESERVE_OFFSET(setScrollEnabled, isScrollEnabled, BOOL)
1029#if !TARGET_OS_TV
1030RCT_SET_AND_PRESERVE_OFFSET(setPagingEnabled, isPagingEnabled, BOOL)
1031RCT_SET_AND_PRESERVE_OFFSET(setScrollsToTop, scrollsToTop, BOOL)
1032#endif
1033RCT_SET_AND_PRESERVE_OFFSET(setShowsHorizontalScrollIndicator, showsHorizontalScrollIndicator, BOOL)
1034RCT_SET_AND_PRESERVE_OFFSET(setShowsVerticalScrollIndicator, showsVerticalScrollIndicator, BOOL)
1035RCT_SET_AND_PRESERVE_OFFSET(setZoomScale, zoomScale, CGFloat);
1036RCT_SET_AND_PRESERVE_OFFSET(setScrollIndicatorInsets, scrollIndicatorInsets, UIEdgeInsets);
1037
1038#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 /* __IPHONE_11_0 */
1039- (void)setContentInsetAdjustmentBehavior:(UIScrollViewContentInsetAdjustmentBehavior)behavior
1040API_AVAILABLE(ios(11.0)){
1041 // `contentInsetAdjustmentBehavior` is available since iOS 11.
1042 if ([_scrollView respondsToSelector:@selector(setContentInsetAdjustmentBehavior:)]) {
1043 CGPoint contentOffset = _scrollView.contentOffset;
1044 if (@available(iOS 11.0, *)) {
1045 _scrollView.contentInsetAdjustmentBehavior = behavior;
1046 }
1047 _scrollView.contentOffset = contentOffset;
1048 }
1049}
1050#endif
1051
1052- (void)sendScrollEventWithName:(NSString *)eventName
1053 scrollView:(UIScrollView *)scrollView
1054 userData:(NSDictionary *)userData
1055{
1056 if (![_lastEmittedEventName isEqualToString:eventName]) {
1057 _coalescingKey++;
1058 _lastEmittedEventName = [eventName copy];
1059 }
1060 RCTScrollEvent *scrollEvent = [[RCTScrollEvent alloc] initWithEventName:eventName
1061 reactTag:self.reactTag
1062 scrollViewContentOffset:scrollView.contentOffset
1063 scrollViewContentInset:scrollView.contentInset
1064 scrollViewContentSize:scrollView.contentSize
1065 scrollViewFrame:scrollView.frame
1066 scrollViewZoomScale:scrollView.zoomScale
1067 userData:userData
1068 coalescingKey:_coalescingKey];
1069 [_eventDispatcher sendEvent:scrollEvent];
1070}
1071
1072@end
1073
1074@implementation RCTEventDispatcher (RCTScrollView)
1075
1076- (void)sendFakeScrollEvent:(NSNumber *)reactTag
1077{
1078 // Use the selector here in case the onScroll block property is ever renamed
1079 NSString *eventName = NSStringFromSelector(@selector(onScroll));
1080 RCTScrollEvent *fakeScrollEvent = [[RCTScrollEvent alloc] initWithEventName:eventName
1081 reactTag:reactTag
1082 scrollViewContentOffset:CGPointZero
1083 scrollViewContentInset:UIEdgeInsetsZero
1084 scrollViewContentSize:CGSizeZero
1085 scrollViewFrame:CGRectZero
1086 scrollViewZoomScale:0
1087 userData:nil
1088 coalescingKey:0];
1089 [self sendEvent:fakeScrollEvent];
1090}
1091
1092@end