From 51dae25ee1a119a73fde9c149c22268bf8c65359 Mon Sep 17 00:00:00 2001 From: Matthew Horan Date: Sun, 4 Jan 2026 16:17:54 -0500 Subject: [PATCH] Restore content offset after background snapshot on iOS In iOS 26, backgrounding an app triggers an app snapshot [1], which will be taken in all supported device orientations. When in landscape mode, the OS will make a layout pass in portrait followed by another in landscape (and vice versa), taking a snapshot of each. If scrolled to the bottom of a ScrollView in landscape mode, the content offset will be incorrect on resume, as UIScrollView updates the offset during the portrait layout pass to prevent overscroll. When the next layout pass is made, the offset is not reset to the original value. Store the content offset on update when foregrounded. When backgrounded, override the content offset with the stored content offset, clamped to the content size. When the second snapshot is taken (in the original orientation), the original content offset is restored. [1] https://developer.apple.com/documentation/uikit/preparing-your-ui-to-run-in-the-background#Prepare-your-UI-for-the-app-snapshot --- .../ScrollView/RCTEnhancedScrollView.mm | 23 +++++++++++++++++++ .../ScrollView/RCTScrollViewComponentView.mm | 7 ++++++ 2 files changed, 30 insertions(+) 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 1b02e8b2d39672..253b4c47642904 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm @@ -15,6 +15,7 @@ @interface RCTEnhancedScrollView () @implementation RCTEnhancedScrollView { __weak id _publicDelegate; BOOL _isSetContentOffsetDisabled; + CGPoint _foregroundContentOffset; } + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key @@ -97,6 +98,28 @@ - (void)setContentOffset:(CGPoint)contentOffset if (_isSetContentOffsetDisabled) { return; } + +#if TARGET_OS_IOS + UIWindowScene *scene = self.window.windowScene; + if (scene && scene.activationState == UISceneActivationStateBackground) { + if (!CGPointEqualToPoint(_foregroundContentOffset, CGPointZero)) { + BOOL isHorizontal = [self isHorizontal:self]; + CGSize viewportSize = self.bounds.size; + if (isHorizontal) { + contentOffset.x = + fmin(_foregroundContentOffset.x, + fmax(0, self.contentSize.width - viewportSize.width)); + } else { + contentOffset.y = + fmin(_foregroundContentOffset.y, + fmax(0, self.contentSize.height - viewportSize.height)); + } + } + } else { + _foregroundContentOffset = contentOffset; + } +#endif + super.contentOffset = CGPointMake( RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"), RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y")); 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 15e75f45632a60..cad5a55f669a1b 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -650,6 +650,13 @@ - (void)_updateStateWithContentOffset _avoidAdjustmentForMaintainVisibleContentPosition = enableImmediateUpdateModeForContentOffsetChanges; +#if TARGET_OS_IOS + UIWindowScene *scene = self.window.windowScene; + if (scene && scene.activationState == UISceneActivationStateBackground) { + return; + } +#endif + auto contentOffset = RCTPointFromCGPoint(_scrollView.contentOffset); BOOL isAccessibilityAPIUsed = _isAccessibilityAPIUsed; _state->updateState(