From a516782c08cf87420f8847db14c598798e5b5aed Mon Sep 17 00:00:00 2001 From: Calvin Liu Date: Fri, 25 Sep 2026 11:06:04 -0700 Subject: [PATCH] Hide misplaced iOS 26 scroll edge effects on inverted lists (#58684) Summary: Pull Request resolved: https://github.com/react/react-native/pull/58684 iOS 26 adds scroll edge effects, which UIKit positions in a scroll view's own coordinate space. Inverted virtualized lists are implemented with a `scaleY(-1)` transform on the scroll view, and UIKit does not account for it, so neither effect lands where it belongs: - Against a navigation bar, `topEdgeEffect` darkens the lower half of the screen instead of the content passing under the bar. - Against a bottom bar, `bottomEdgeEffect` dims the entire screen. With both bars present the two stack, leaving content close to unreadable. This reproduces with a plain `UIScrollView` carrying the same transform, with no React Native involved, so the underlying defect is in UIKit rather than here. What can be done from this side is stop the misplaced effects from rendering, so `RCTEnhancedScrollView` now hides both while the scroll view is inverted. Nothing visible is lost by doing so. An inverted list never gets a usable edge effect at any scroll position; the only thing the effects produce while mirrored is the misplaced darkening. Giving the bars correct effects is not possible from here, because UIKit has no notion of the transform. Reported as react-native#54181, migrated from software-mansion/react-native-screens#3293. Changelog: [iOS][Fixed] - Hide the misplaced iOS 26 scroll edge effects on inverted ScrollViews Differential Revision: D120641422 --- .../ScrollView/RCTEnhancedScrollView.h | 7 +++++++ .../ScrollView/RCTEnhancedScrollView.mm | 20 +++++++++++++++++++ .../ScrollView/RCTScrollViewComponentView.mm | 3 +++ .../api-snapshots/ReactAppleDebugCxx.api | 1 + .../api-snapshots/ReactAppleNewarchCxx.api | 1 + .../api-snapshots/ReactAppleReleaseCxx.api | 1 + 6 files changed, 33 insertions(+) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h index 6402bc6c86c1..66e2163f37f4 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h @@ -52,6 +52,13 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) BOOL snapToEnd; @property (nonatomic, copy) NSArray *snapToOffsets; +/* + * Whether this scroll view is mirrored by a `scaleY(-1)` transform, as inverted virtualized lists + * are. UIKit does not account for that transform when it positions iOS 26 scroll edge effects, so + * they have to be compensated for here. + */ +@property (nonatomic, assign) BOOL inverted; + /* * Makes `setContentOffset:` method no-op when given `block` is executed. * The block is being executed synchronously. diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm index b3481c1b98b4..ef212e09cb63 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm @@ -51,6 +51,26 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } +- (void)setInverted:(BOOL)inverted +{ + if (_inverted == inverted) { + return; + } + _inverted = inverted; + + if (@available(iOS 26.0, *)) { + // UIKit positions the scroll edge effects in this scroll view's own coordinate space, and the + // `scaleY(-1)` mirror turns that space upside down, so neither effect lands where it belongs. + // Against a navigation bar, `topEdgeEffect` darkens the lower half of the screen rather than + // the content passing under the bar; against a bottom bar, `bottomEdgeEffect` dims the whole + // screen. Neither produces anything useful at any scroll position while mirrored, so both are + // hidden. The bars cannot be given correct effects from here, because UIKit has no notion of + // the transform. + self.topEdgeEffect.hidden = inverted; + self.bottomEdgeEffect.hidden = inverted; + } +} + - (void)preserveContentOffsetWithBlock:(void (^)())block { if (!block) { diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 14a08b49cb7c..ffd10bc6f236 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -455,6 +455,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } [super updateProps:props oldProps:oldProps]; + + // Must run after `super updateProps:` so it is based on the latest calculated view. + ((RCTEnhancedScrollView *)_scrollView).inverted = [self isInverted]; } - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index a8f040724fa6..4d9e635ea724 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -1035,6 +1035,7 @@ interface RCTDivisionAnimatedNode : public RCTValueAnimatedNode { interface RCTEnhancedScrollView : public UIScrollView { public @property (assign) BOOL centerContent; public @property (assign) BOOL disableIntervalMomentum; + public @property (assign) BOOL inverted; public @property (assign) BOOL pinchGestureEnabled; public @property (assign) BOOL snapToEnd; public @property (assign) BOOL snapToStart; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 3c0a535fb10e..ab2605831282 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -1035,6 +1035,7 @@ interface RCTDivisionAnimatedNode : public RCTValueAnimatedNode { interface RCTEnhancedScrollView : public UIScrollView { public @property (assign) BOOL centerContent; public @property (assign) BOOL disableIntervalMomentum; + public @property (assign) BOOL inverted; public @property (assign) BOOL pinchGestureEnabled; public @property (assign) BOOL snapToEnd; public @property (assign) BOOL snapToStart; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 958586fc805e..7ce061e80013 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -1035,6 +1035,7 @@ interface RCTDivisionAnimatedNode : public RCTValueAnimatedNode { interface RCTEnhancedScrollView : public UIScrollView { public @property (assign) BOOL centerContent; public @property (assign) BOOL disableIntervalMomentum; + public @property (assign) BOOL inverted; public @property (assign) BOOL pinchGestureEnabled; public @property (assign) BOOL snapToEnd; public @property (assign) BOOL snapToStart;