From 44275e03fbacbc2e16efb9e1d8f563e003d92d51 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 26 Sep 2026 23:14:03 +0100 Subject: [PATCH] Fix iOS RefreshControl appearance after native navigation --- .../RCTPullToRefreshViewComponentView.mm | 45 ++++++- .../RCTPullToRefreshViewComponentViewTests.mm | 115 ++++++++++++++++++ .../RNTesterPods.xcodeproj/project.pbxproj | 4 + 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/React/Tests/Mounting/RCTPullToRefreshViewComponentViewTests.mm diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm index 60160efb163d..544e1900b573 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm @@ -21,6 +21,9 @@ using namespace facebook::react; @interface RCTPullToRefreshViewComponentView () +#if !TARGET_OS_TV +- (void)_updateAppearance; +#endif @end #if TARGET_OS_TV @@ -39,6 +42,24 @@ - (void)setRefreshing:(BOOL)refreshing #else +@interface RCTPullToRefreshControl : UIRefreshControl +@property (nonatomic, weak) RCTPullToRefreshViewComponentView *componentView; +@end + +@implementation RCTPullToRefreshControl + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if (self.window) { + // UIKit can apply new UIAppearance values when returning from a native screen, + // even though the React props have not changed. + [self.componentView _updateAppearance]; + } +} + +@end + @implementation RCTPullToRefreshViewComponentView { UIRefreshControl *_refreshControl; RCTScrollViewComponentView *__weak _scrollViewComponentView; @@ -66,7 +87,9 @@ - (instancetype)initWithFrame:(CGRect)frame - (void)_initializeUIRefreshControl { - _refreshControl = [UIRefreshControl new]; + RCTPullToRefreshControl *refreshControl = [RCTPullToRefreshControl new]; + refreshControl.componentView = self; + _refreshControl = refreshControl; [_refreshControl addTarget:self action:@selector(handleUIControlEventValueChanged) forControlEvents:UIControlEventValueChanged]; @@ -92,6 +115,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & const auto &oldConcreteProps = static_cast(*_props); const auto &newConcreteProps = static_cast(*props); + if (_recycled || newConcreteProps.backgroundColor != oldConcreteProps.backgroundColor) { + _refreshControl.backgroundColor = RCTUIColorFromSharedColor(newConcreteProps.backgroundColor); + } + if (_recycled || newConcreteProps.tintColor != oldConcreteProps.tintColor) { _refreshControl.tintColor = RCTUIColorFromSharedColor(newConcreteProps.tintColor); } @@ -166,6 +193,22 @@ - (void)_updateTitle [[NSAttributedString alloc] initWithString:RCTNSStringFromString(concreteProps.title) attributes:attributes]; } +- (void)_updateAppearance +{ + const auto &concreteProps = static_cast(*_props); + + // Preserve UIKit defaults for appearance properties that React has not supplied. + if (concreteProps.backgroundColor) { + _refreshControl.backgroundColor = RCTUIColorFromSharedColor(concreteProps.backgroundColor); + } + if (concreteProps.tintColor) { + _refreshControl.tintColor = RCTUIColorFromSharedColor(concreteProps.tintColor); + } + if (!concreteProps.title.empty()) { + [self _updateTitle]; + } +} + #pragma mark - Attaching & Detaching - (void)layoutSubviews diff --git a/packages/react-native/React/Tests/Mounting/RCTPullToRefreshViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTPullToRefreshViewComponentViewTests.mm new file mode 100644 index 000000000000..541110674404 --- /dev/null +++ b/packages/react-native/React/Tests/Mounting/RCTPullToRefreshViewComponentViewTests.mm @@ -0,0 +1,115 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import +#import +#import +#import + +using namespace facebook::react; + +#if TARGET_OS_IOS + +@interface RCTPullToRefreshViewComponentViewTests : XCTestCase +@end + +@implementation RCTPullToRefreshViewComponentViewTests + +- (void)testBackgroundColorIsForwardedUpdatedAndRemoved +{ + RCTScrollViewComponentView *scrollView = [RCTScrollViewComponentView new]; + RCTPullToRefreshViewComponentView *component = [RCTPullToRefreshViewComponentView new]; + [scrollView addSubview:component]; + + auto props = std::make_shared(); + props->backgroundColor = colorFromRGBA(255, 0, 0, 255); + [component updateProps:props oldProps:nullptr]; + XCTAssertEqualObjects(scrollView.scrollView.refreshControl.backgroundColor, UIColor.redColor); + + auto updatedProps = std::make_shared(*props); + updatedProps->backgroundColor = colorFromRGBA(0, 0, 255, 255); + [component updateProps:updatedProps oldProps:props]; + XCTAssertEqualObjects(scrollView.scrollView.refreshControl.backgroundColor, UIColor.blueColor); + + auto defaultProps = std::make_shared(); + [component updateProps:defaultProps oldProps:updatedProps]; + XCTAssertNil(scrollView.scrollView.refreshControl.backgroundColor); +} + +- (void)testExplicitAppearanceIsRestoredWhenReturningToWindow +{ + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + RCTScrollViewComponentView *scrollView = [RCTScrollViewComponentView new]; + RCTPullToRefreshViewComponentView *component = [RCTPullToRefreshViewComponentView new]; + auto props = std::make_shared(); + props->backgroundColor = colorFromRGBA(255, 0, 0, 255); + props->tintColor = colorFromRGBA(0, 0, 255, 255); + props->title = "React title"; + props->titleColor = colorFromRGBA(0, 255, 0, 255); + [component updateProps:props oldProps:nullptr]; + [scrollView addSubview:component]; + [window addSubview:scrollView]; + UIRefreshControl *control = scrollView.scrollView.refreshControl; + + [scrollView removeFromSuperview]; + // Simulate native appearance changes without leaking a global UIAppearance + // proxy into other tests. Reattaching must restore the unchanged React props. + control.backgroundColor = UIColor.blackColor; + control.tintColor = UIColor.blackColor; + control.attributedTitle = + [[NSAttributedString alloc] initWithString:@"Native title" + attributes:@{NSForegroundColorAttributeName : UIColor.blackColor}]; + [window addSubview:scrollView]; + + XCTAssertEqual(control, scrollView.scrollView.refreshControl); + XCTAssertEqualObjects(control.backgroundColor, UIColor.redColor); + XCTAssertEqualObjects(control.tintColor, UIColor.blueColor); + XCTAssertEqualObjects(control.attributedTitle.string, @"React title"); + XCTAssertEqualObjects( + [control.attributedTitle attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:nil], + UIColor.greenColor); +} + +- (void)testUnspecifiedAppearanceIsNotOverriddenWhenReturningToWindow +{ + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + RCTScrollViewComponentView *scrollView = [RCTScrollViewComponentView new]; + RCTPullToRefreshViewComponentView *component = [RCTPullToRefreshViewComponentView new]; + [scrollView addSubview:component]; + UIRefreshControl *control = scrollView.scrollView.refreshControl; + control.backgroundColor = UIColor.redColor; + control.tintColor = UIColor.blueColor; + control.attributedTitle = [[NSAttributedString alloc] initWithString:@"Native default"]; + [window addSubview:scrollView]; + + XCTAssertEqualObjects(control.backgroundColor, UIColor.redColor); + XCTAssertEqualObjects(control.tintColor, UIColor.blueColor); + XCTAssertEqualObjects(control.attributedTitle.string, @"Native default"); +} + +- (void)testBackgroundColorIsReappliedAfterRecycling +{ + RCTScrollViewComponentView *scrollView = [RCTScrollViewComponentView new]; + RCTPullToRefreshViewComponentView *component = [RCTPullToRefreshViewComponentView new]; + auto props = std::make_shared(); + props->backgroundColor = colorFromRGBA(255, 0, 0, 255); + [component updateProps:props oldProps:nullptr]; + [scrollView addSubview:component]; + UIRefreshControl *original = scrollView.scrollView.refreshControl; + [component removeFromSuperview]; + [component prepareForRecycle]; + [component updateProps:props oldProps:nullptr]; + [scrollView addSubview:component]; + + XCTAssertNotEqual(original, scrollView.scrollView.refreshControl); + XCTAssertEqualObjects(scrollView.scrollView.refreshControl.backgroundColor, UIColor.redColor); +} + +@end + +#endif diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 8248d57b2b2a..f1410a6e0446 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 07BF7A641354C4B39AC4871D /* RCTPullToRefreshViewComponentViewTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4690E2D21595C381816B8F2F /* RCTPullToRefreshViewComponentViewTests.mm */; }; 0EA618032BE537D3001875EF /* RNTesterBundle.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 0EA618022BE537D3001875EF /* RNTesterBundle.bundle */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; 2DDEF0101F84BF7B00DBDF73 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2DDEF00F1F84BF7B00DBDF73 /* Images.xcassets */; }; @@ -90,6 +91,7 @@ 383889D923A7398900D06C3E /* RCTConvert_UIColorTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTConvert_UIColorTests.m; sourceTree = ""; }; 3D2AFAF41D646CF80089D1A3 /* legacy_image@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "legacy_image@2x.png"; path = "RNTester/legacy_image@2x.png"; sourceTree = ""; }; 3FF60722627F93D8F62FA1E3 /* Pods-RNTesterUnitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTesterUnitTests.release.xcconfig"; path = "Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests.release.xcconfig"; sourceTree = ""; }; + 4690E2D21595C381816B8F2F /* RCTPullToRefreshViewComponentViewTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; name = RCTPullToRefreshViewComponentViewTests.mm; path = "../react-native/React/Tests/Mounting/RCTPullToRefreshViewComponentViewTests.mm"; sourceTree = ""; }; 4C706D402EE4AF9BE838CBA9 /* libPods-RNTester.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNTester.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 51BC9297B6C3163C14532020 /* Pods-RNTester.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTester.release.xcconfig"; path = "Target Support Files/Pods-RNTester/Pods-RNTester.release.xcconfig"; sourceTree = ""; }; 5C60EB1B226440DB0018C04F /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = RNTester/AppDelegate.mm; sourceTree = ""; }; @@ -284,6 +286,7 @@ 83CBBA001A601CBA00E9B192 /* Products */, 2DE7E7D81FB2A4F3009E225D /* Frameworks */, E23BD6487B06BD71F1A86914 /* Pods */, + 4690E2D21595C381816B8F2F /* RCTPullToRefreshViewComponentViewTests.mm */, ); indentWidth = 2; sourceTree = ""; @@ -774,6 +777,7 @@ E7DB20EB22B2BAA6005AC45F /* RCTConvert_YGValueTests.m in Sources */, E7DB20E922B2BAA6005AC45F /* RCTComponentPropsTests.m in Sources */, E7DB20D822B2BAA6005AC45F /* RCTJSONTests.m in Sources */, + 07BF7A641354C4B39AC4871D /* RCTPullToRefreshViewComponentViewTests.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };