Skip to content

Commit fe7e97a

Browse files
gaearonfacebook-github-bot
authored andcommitted
Fix ScrollView centerContent losing taps and causing jitter on iOS (#47591)
Summary: The React Native `<ScrollView>` has a peculiar `centerContent` prop. It solves a common need — keeping the image "centered" while it's not fully zoomed in (but then allowing full panning after it's sufficiently zoomed in). This prop sort of works but it has a few wonky behaviors: - If you start tapping immediately after pinch (and don't stop), the taps will not be recognized until a second after you stop tapping. I suspect this is because the existing `centerContent` implementation hijacks the `contentOffset` setter, but the calling UIKit code _does not know_ it's being hijacked, and so the calling UIKit code _thinks_ it needs to do a momentum animation. This (invisible) momentum animation causes the scroll view to keep eating the tap touches. - While you're zooming in, once you cross the threshold where `contentOffset` hijacking stops adjusting values, there will be a sudden visual jump during the pinch. This is because the "real" `contentOffset` tracks the accumulated translation from the pinch gesture, and once it gets taken into account with no "correction", the new offset snaps into place. - While not sufficiently pinched in, the vertical axis is completely rigid. It does not have the natural rubber banding. The solution to all of these issues is described [here](https://petersteinberger.com/blog/2013/how-to-center-uiscrollview/). Instead of hijacking `contentOffset`, it is more reliable to track zooming, child view, and frame changes, and adjust `contentInsets` instead. This solves all three issues: - UIKit isn't confused by the content offset changing from under it so it doesn't mistrigger a faux momentum animation. - There is no sudden jump because it's the insets that are being adjusted. - Rubber banding just works. ## Changelog: <!-- Help reviewers and the release process by writing your own changelog entry. Pick one each for the category and type tags: [ANDROID|GENERAL|IOS|INTERNAL] [BREAKING|ADDED|CHANGED|DEPRECATED|REMOVED|FIXED|SECURITY] - Message For more details, see: https://reactnative.dev/contributing/changelogs-in-pull-requests --> [IOS] [FIXED] - Fixed centerContent losing taps and causing jitter Pull Request resolved: #47591 Test Plan: I'm extracting this from [a patch we're applying to Bluesky](https://github.com/bluesky-social/social-app/blob/2ef697fe3d7dec198544ed6834553f33b95790b3/patches/react-native%2B0.74.1.patch). I'll be honest — I have not tested this in isolation, and it likely requires some testing to get merged in. I do not, unfortuntately, have the capacity to do it myself so this is more of a "throw over the wall" kind of patch. Maybe it will be helpful to somebody else. I've tested these in our real open source app (bluesky-social/social-app#6298). You can reproduce it in any of the lightboxes in the feed or the profile. ### Before the fix Observe the failing tap gestures, sudden jump while pinching, lack of rubber banding. https://github.com/user-attachments/assets/c9883201-c9f0-4782-9b80-8e0a9f77c47c ### After the fix Observe the natural iOS behavior. https://github.com/user-attachments/assets/c025e1df-6963-40ba-9e28-d48bfa5e631d Unfortunately I do not have the capacity to verify this fix in other scenarios outside of our app. Reviewed By: sammy-SC Differential Revision: D66093472 Pulled By: javache fbshipit-source-id: 064f0415b8093ff55cb51bdebab2a46ee97f8fa9
1 parent 2a2f58a commit fe7e97a

File tree

2 files changed

+85
-29
lines changed

2 files changed

+85
-29
lines changed

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm

+39-10
Original file line numberDiff line numberDiff line change
@@ -67,28 +67,52 @@ - (void)preserveContentOffsetWithBlock:(void (^)())block
6767
* ScrollView, we force it to be centered, but when you zoom or the content otherwise
6868
* becomes larger than the ScrollView, there is no padding around the content but it
6969
* can still fill the whole view.
70+
* This implementation is based on https://petersteinberger.com/blog/2013/how-to-center-uiscrollview/.
7071
*/
71-
- (void)setContentOffset:(CGPoint)contentOffset
72+
- (void)centerContentIfNeeded
7273
{
73-
if (_isSetContentOffsetDisabled) {
74+
if (!_centerContent) {
7475
return;
7576
}
7677

77-
if (_centerContent && !CGSizeEqualToSize(self.contentSize, CGSizeZero)) {
78-
CGSize scrollViewSize = self.bounds.size;
79-
if (self.contentSize.width <= scrollViewSize.width) {
80-
contentOffset.x = -(scrollViewSize.width - self.contentSize.width) / 2.0;
81-
}
82-
if (self.contentSize.height <= scrollViewSize.height) {
83-
contentOffset.y = -(scrollViewSize.height - self.contentSize.height) / 2.0;
84-
}
78+
CGSize contentSize = self.contentSize;
79+
CGSize boundsSize = self.bounds.size;
80+
if (CGSizeEqualToSize(contentSize, CGSizeZero) || CGSizeEqualToSize(boundsSize, CGSizeZero)) {
81+
return;
82+
}
83+
84+
CGFloat top = 0, left = 0;
85+
if (contentSize.width < boundsSize.width) {
86+
left = (boundsSize.width - contentSize.width) * 0.5f;
87+
}
88+
if (contentSize.height < boundsSize.height) {
89+
top = (boundsSize.height - contentSize.height) * 0.5f;
8590
}
91+
self.contentInset = UIEdgeInsetsMake(top, left, top, left);
92+
}
8693

94+
- (void)setContentOffset:(CGPoint)contentOffset
95+
{
96+
if (_isSetContentOffsetDisabled) {
97+
return;
98+
}
8799
super.contentOffset = CGPointMake(
88100
RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
89101
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
90102
}
91103

104+
- (void)setFrame:(CGRect)frame
105+
{
106+
[super setFrame:frame];
107+
[self centerContentIfNeeded];
108+
}
109+
110+
- (void)didAddSubview:(UIView *)subview
111+
{
112+
[super didAddSubview:subview];
113+
[self centerContentIfNeeded];
114+
}
115+
92116
- (BOOL)touchesShouldCancelInContentView:(UIView *)view
93117
{
94118
if ([_overridingDelegate respondsToSelector:@selector(touchesShouldCancelInContentView:)]) {
@@ -258,6 +282,11 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
258282
}
259283
}
260284

285+
- (void)scrollViewDidZoom:(__unused UIScrollView *)scrollView
286+
{
287+
[self centerContentIfNeeded];
288+
}
289+
261290
#pragma mark -
262291

263292
- (BOOL)isHorizontal:(UIScrollView *)scrollView

packages/react-native/React/Views/ScrollView/RCTScrollView.m

+46-19
Original file line numberDiff line numberDiff line change
@@ -159,26 +159,8 @@ - (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view
159159
return !shouldDisableScrollInteraction;
160160
}
161161

162-
/*
163-
* Automatically centers the content such that if the content is smaller than the
164-
* ScrollView, we force it to be centered, but when you zoom or the content otherwise
165-
* becomes larger than the ScrollView, there is no padding around the content but it
166-
* can still fill the whole view.
167-
*/
168162
- (void)setContentOffset:(CGPoint)contentOffset
169163
{
170-
UIView *contentView = [self contentView];
171-
if (contentView && _centerContent && !CGSizeEqualToSize(contentView.frame.size, CGSizeZero)) {
172-
CGSize subviewSize = contentView.frame.size;
173-
CGSize scrollViewSize = self.bounds.size;
174-
if (subviewSize.width <= scrollViewSize.width) {
175-
contentOffset.x = -(scrollViewSize.width - subviewSize.width) / 2.0;
176-
}
177-
if (subviewSize.height <= scrollViewSize.height) {
178-
contentOffset.y = -(scrollViewSize.height - subviewSize.height) / 2.0;
179-
}
180-
}
181-
182164
super.contentOffset = CGPointMake(
183165
RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
184166
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
@@ -433,6 +415,12 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
433415
// Does nothing
434416
}
435417

418+
- (void)setFrame:(CGRect)frame
419+
{
420+
[super setFrame:frame];
421+
[self centerContentIfNeeded];
422+
}
423+
436424
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
437425
{
438426
[super insertReactSubview:view atIndex:atIndex];
@@ -450,6 +438,8 @@ - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
450438
RCTApplyTransformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
451439
[_scrollView addSubview:view];
452440
}
441+
442+
[self centerContentIfNeeded];
453443
}
454444

455445
- (void)removeReactSubview:(UIView *)subview
@@ -658,9 +648,45 @@ -(void)delegateMethod : (UIScrollView *)scrollView \
658648
}
659649

660650
RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, onMomentumScrollBegin)
661-
RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll)
662651
RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
663652

653+
- (void)scrollViewDidZoom:(UIScrollView *)scrollView
654+
{
655+
[self centerContentIfNeeded];
656+
657+
RCT_SEND_SCROLL_EVENT(onScroll, nil);
658+
RCT_FORWARD_SCROLL_EVENT(scrollViewDidZoom : scrollView);
659+
}
660+
661+
/*
662+
* Automatically centers the content such that if the content is smaller than the
663+
* ScrollView, we force it to be centered, but when you zoom or the content otherwise
664+
* becomes larger than the ScrollView, there is no padding around the content but it
665+
* can still fill the whole view.
666+
* This implementation is based on https://petersteinberger.com/blog/2013/how-to-center-uiscrollview/.
667+
*/
668+
- (void)centerContentIfNeeded
669+
{
670+
if (!_scrollView.centerContent) {
671+
return;
672+
}
673+
674+
CGSize contentSize = self.contentSize;
675+
CGSize boundsSize = self.bounds.size;
676+
if (CGSizeEqualToSize(contentSize, CGSizeZero) || CGSizeEqualToSize(boundsSize, CGSizeZero)) {
677+
return;
678+
}
679+
680+
CGFloat top = 0, left = 0;
681+
if (contentSize.width < boundsSize.width) {
682+
left = (boundsSize.width - contentSize.width) * 0.5f;
683+
}
684+
if (contentSize.height < boundsSize.height) {
685+
top = (boundsSize.height - contentSize.height) * 0.5f;
686+
}
687+
_scrollView.contentInset = UIEdgeInsetsMake(top, left, top, left);
688+
}
689+
664690
- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
665691
{
666692
[_scrollListeners addObject:scrollListener];
@@ -939,6 +965,7 @@ - (void)updateContentSizeIfNeeded
939965
CGSize contentSize = self.contentSize;
940966
if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
941967
_scrollView.contentSize = contentSize;
968+
[self centerContentIfNeeded];
942969
}
943970
}
944971

0 commit comments

Comments
 (0)