Repository navigation
RefreshControl Issues - tintColor prop not respected & gets stuck on navigation #53987
Description
Activity
- addedType: New ArchitectureIssues and PRs related to new architecture (Fabric/Turbo Modules)Issues and PRs related to new architecture (Fabric/Turbo Modules)
on Sep 30, 2025 I have been hitting the same RefreshControl tintColor issue after upgrading from RN 0.79.5 to 0.81.4. The RefreshControl always adopts the appropriate color for the device's color scheme and ignores any tintColor I set. I've also managed to recreate it in a minimal vanilla project and am happy to share any additional details if that would help.
Reacted by Daniel Morales González, Okano Tatsuya and FantinSame issue
Reacted by Daniel Morales González, Helder Avila and FantinSame here
Reacted by Daniel Morales González, Helder Avila and FantinOkay so a little update here. I dug into the future 0.82.rc4 react native package and it does seem to resolve the tint color problem.
It does not seem to fix the "frozen" animation when going back and forth on tabs.
I spent some time trying to fix that and ended up on this (expand the details):
Details
Fully fixed file that you could could copy and use in place of the existing for now:
File Path:
/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm/* * 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 "RCTPullToRefreshViewComponentView.h" #import <react/renderer/components/FBReactNativeSpec/ComponentDescriptors.h> #import <react/renderer/components/FBReactNativeSpec/EventEmitters.h> #import <react/renderer/components/FBReactNativeSpec/Props.h> #import <react/renderer/components/FBReactNativeSpec/RCTComponentViewHelpers.h> #import <React/RCTConversions.h> #import <React/RCTRefreshableProtocol.h> #import <React/RCTScrollViewComponentView.h> #import "RCTFabricComponentsPlugins.h" using namespace facebook::react; @interface RCTPullToRefreshViewComponentView () <RCTPullToRefreshViewViewProtocol, RCTRefreshableProtocol> @end @implementation RCTPullToRefreshViewComponentView { UIRefreshControl *_refreshControl; RCTScrollViewComponentView *__weak _scrollViewComponentView; BOOL _recycled; BOOL _shouldBeRefreshing; } - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { self.hidden = YES; _props = PullToRefreshViewShadowNode::defaultSharedProps(); _recycled = NO; _shouldBeRefreshing = NO; [self _initializeUIRefreshControl]; } return self; } - (void)_initializeUIRefreshControl { _refreshControl = [UIRefreshControl new]; [_refreshControl addTarget:self action:@selector(handleUIControlEventValueChanged) forControlEvents:UIControlEventValueChanged]; } #pragma mark - RCTComponentViewProtocol + (ComponentDescriptorProvider)componentDescriptorProvider { return concreteComponentDescriptorProvider<PullToRefreshViewComponentDescriptor>(); } - (void)prepareForRecycle { [super prepareForRecycle]; _scrollViewComponentView = nil; [self _initializeUIRefreshControl]; _recycled = YES; } - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps { const auto &oldConcreteProps = static_cast<const PullToRefreshViewProps &>(*_props); const auto &newConcreteProps = static_cast<const PullToRefreshViewProps &>(*props); _shouldBeRefreshing = newConcreteProps.refreshing; if (_recycled || newConcreteProps.tintColor != oldConcreteProps.tintColor) { _refreshControl.tintColor = RCTUIColorFromSharedColor(newConcreteProps.tintColor); } if (_recycled || newConcreteProps.progressViewOffset != oldConcreteProps.progressViewOffset) { [self _updateProgressViewOffset:newConcreteProps.progressViewOffset]; } BOOL needsUpdateTitle = NO; if (_recycled || newConcreteProps.title != oldConcreteProps.title) { needsUpdateTitle = YES; } if (_recycled || newConcreteProps.titleColor != oldConcreteProps.titleColor) { needsUpdateTitle = YES; } [super updateProps:props oldProps:oldProps]; if (_recycled || needsUpdateTitle) { [self _updateTitle]; } if (_recycled || newConcreteProps.refreshing != oldConcreteProps.refreshing) { if (newConcreteProps.refreshing) { [self beginRefreshingProgrammatically]; } else { [_refreshControl endRefreshing]; } } if (_recycled || newConcreteProps.zIndex != oldConcreteProps.zIndex) { _refreshControl.layer.zPosition = newConcreteProps.zIndex.value_or(0); } _recycled = NO; } #pragma mark - Events - (void)handleUIControlEventValueChanged { static_cast<const PullToRefreshViewEventEmitter &>(*_eventEmitter).onRefresh({}); } - (void)_updateProgressViewOffset:(Float)progressViewOffset { _refreshControl.bounds = CGRectMake( _refreshControl.bounds.origin.x, -progressViewOffset, _refreshControl.bounds.size.width, _refreshControl.bounds.size.height); } - (void)_updateTitle { const auto &concreteProps = static_cast<const PullToRefreshViewProps &>(*_props); if (concreteProps.title.empty()) { _refreshControl.attributedTitle = nil; return; } NSMutableDictionary *attributes = [NSMutableDictionary dictionary]; if (concreteProps.titleColor) { attributes[NSForegroundColorAttributeName] = RCTUIColorFromSharedColor(concreteProps.titleColor); } _refreshControl.attributedTitle = [[NSAttributedString alloc] initWithString:RCTNSStringFromString(concreteProps.title) attributes:attributes]; } #pragma mark - Attaching & Detaching - (void)layoutSubviews { [super layoutSubviews]; if (self.window && _shouldBeRefreshing) { // Be cautious here: only recreate if not already refreshing if (!(_scrollViewComponentView.scrollView.refreshControl == _refreshControl && _refreshControl.isRefreshing)) { [self _recreateAndBeginRefreshing]; } } } - (void)didMoveToWindow { [super didMoveToWindow]; if (self.window && _shouldBeRefreshing) { // Be aggressive here: always recreate on window re-attach [self _recreateAndBeginRefreshing]; } } - (void)didMoveToSuperview { [super didMoveToSuperview]; if (self.superview) { [self _attach]; } else { [self _detach]; } } - (void)_attach { if (_scrollViewComponentView) { [self _detach]; } _scrollViewComponentView = [RCTScrollViewComponentView findScrollViewComponentViewForView:self]; if (!_scrollViewComponentView) { return; } if (@available(macCatalyst 13.1, *)) { _scrollViewComponentView.scrollView.refreshControl = _refreshControl; [self setNeedsLayout]; } } - (void)_detach { if (!_scrollViewComponentView) { return; } [_refreshControl endRefreshing]; if (@available(macCatalyst 13.1, *)) { _scrollViewComponentView.scrollView.refreshControl = nil; } _scrollViewComponentView = nil; } #pragma mark - Refresh control helpers - (void)beginRefreshingProgrammatically { _shouldBeRefreshing = YES; [self _ensureSpinnerIsAnimating]; } - (void)_ensureSpinnerIsAnimating { if (!_scrollViewComponentView) { return; } UIScrollView *scrollView = _scrollViewComponentView.scrollView; if (!scrollView) { return; } if (_shouldBeRefreshing) { if (scrollView.refreshControl == _refreshControl && _refreshControl.isRefreshing) { return; // Already animating fine } [self _recreateAndBeginRefreshing]; } } - (void)_recreateAndBeginRefreshing { if (!_scrollViewComponentView) { return; } UIScrollView *scrollView = _scrollViewComponentView.scrollView; if (!scrollView) { return; } [self _initializeUIRefreshControl]; // Apply all properties BEFORE assigning to scrollView.refreshControl const auto &concreteProps = static_cast<const PullToRefreshViewProps &>(*_props); _refreshControl.tintColor = RCTUIColorFromSharedColor(concreteProps.tintColor); _refreshControl.layer.zPosition = concreteProps.zIndex.value_or(0); [self _updateTitle]; [self _updateProgressViewOffset:concreteProps.progressViewOffset]; // Now assign to scrollView scrollView.refreshControl = _refreshControl; // Only adjust offset if not already pulled enough CGFloat currentOffsetY = scrollView.contentOffset.y; CGFloat refreshHeight = _refreshControl.frame.size.height; if (currentOffsetY > -refreshHeight) { CGPoint offset = {scrollView.contentOffset.x, currentOffsetY - refreshHeight}; [scrollView setContentOffset:offset animated:NO]; } [_refreshControl beginRefreshing]; } #pragma mark - Native commands - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { RCTPullToRefreshViewHandleCommand(self, commandName, args); } - (void)setNativeRefreshing:(BOOL)refreshing { _shouldBeRefreshing = refreshing; if (refreshing) { [self beginRefreshingProgrammatically]; } else { [_refreshControl endRefreshing]; } } #pragma mark - RCTRefreshableProtocol - (void)setRefreshing:(BOOL)refreshing { [self setNativeRefreshing:refreshing]; } #pragma mark - - (NSString *)componentViewName_DO_NOT_USE_THIS_IS_BROKEN { return @"RefreshControl"; } @end Class<RCTComponentViewProtocol> RCTPullToRefreshViewCls(void) { return RCTPullToRefreshViewComponentView.class; }
Here is the combined diff for
react-native 0.81.4:diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm index 0d231bc..53e8285 100644 --- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm +++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm @@ -24,23 +24,21 @@ using namespace facebook::react; @end @implementation RCTPullToRefreshViewComponentView { - BOOL _isBeforeInitialLayout; UIRefreshControl *_refreshControl; RCTScrollViewComponentView *__weak _scrollViewComponentView; + BOOL _recycled; + BOOL _shouldBeRefreshing; } - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { - // This view is not designed to be visible, it only serves UIViewController-like purpose managing - // attaching and detaching of a pull-to-refresh view to a scroll view. - // The pull-to-refresh view is not a subview of this view. self.hidden = YES; - - _isBeforeInitialLayout = YES; + _props = PullToRefreshViewShadowNode::defaultSharedProps(); + _recycled = NO; + _shouldBeRefreshing = NO; [self _initializeUIRefreshControl]; } - return self; } @@ -63,57 +61,55 @@ using namespace facebook::react; { [super prepareForRecycle]; _scrollViewComponentView = nil; - _props = nil; - _isBeforeInitialLayout = YES; [self _initializeUIRefreshControl]; + _recycled = YES; } - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps { - // Prop updates are ignored by _refreshControl until after the initial layout, so just store them in _props until then - if (_isBeforeInitialLayout) { - _props = std::static_pointer_cast<const PullToRefreshViewProps>(props); - return; - } - const auto &oldConcreteProps = static_cast<const PullToRefreshViewProps &>(*_props); const auto &newConcreteProps = static_cast<const PullToRefreshViewProps &>(*props); - if (newConcreteProps.tintColor != oldConcreteProps.tintColor) { + _shouldBeRefreshing = newConcreteProps.refreshing; + + if (_recycled || newConcreteProps.tintColor != oldConcreteProps.tintColor) { _refreshControl.tintColor = RCTUIColorFromSharedColor(newConcreteProps.tintColor); } - if (newConcreteProps.progressViewOffset != oldConcreteProps.progressViewOffset) { + if (_recycled || newConcreteProps.progressViewOffset != oldConcreteProps.progressViewOffset) { [self _updateProgressViewOffset:newConcreteProps.progressViewOffset]; } BOOL needsUpdateTitle = NO; - - if (newConcreteProps.title != oldConcreteProps.title) { + if (_recycled || newConcreteProps.title != oldConcreteProps.title) { needsUpdateTitle = YES; } - - if (newConcreteProps.titleColor != oldConcreteProps.titleColor) { + if (_recycled || newConcreteProps.titleColor != oldConcreteProps.titleColor) { needsUpdateTitle = YES; } [super updateProps:props oldProps:oldProps]; - if (needsUpdateTitle) { + if (_recycled || needsUpdateTitle) { [self _updateTitle]; } - // All prop updates must happen above the call to begin refreshing, or else _refreshControl will ignore the updates - if (newConcreteProps.refreshing != oldConcreteProps.refreshing) { + if (_recycled || newConcreteProps.refreshing != oldConcreteProps.refreshing) { if (newConcreteProps.refreshing) { [self beginRefreshingProgrammatically]; } else { [_refreshControl endRefreshing]; } } + + if (_recycled || newConcreteProps.zIndex != oldConcreteProps.zIndex) { + _refreshControl.layer.zPosition = newConcreteProps.zIndex.value_or(0); + } + + _recycled = NO; } -#pragma mark - +#pragma mark - Events - (void)handleUIControlEventValueChanged { @@ -132,7 +128,6 @@ using namespace facebook::react; - (void)_updateTitle { const auto &concreteProps = static_cast<const PullToRefreshViewProps &>(*_props); - if (concreteProps.title.empty()) { _refreshControl.attributedTitle = nil; return; @@ -144,7 +139,8 @@ using namespace facebook::react; } _refreshControl.attributedTitle = - [[NSAttributedString alloc] initWithString:RCTNSStringFromString(concreteProps.title) attributes:attributes]; + [[NSAttributedString alloc] initWithString:RCTNSStringFromString(concreteProps.title) + attributes:attributes]; } #pragma mark - Attaching & Detaching @@ -153,12 +149,22 @@ using namespace facebook::react; { [super layoutSubviews]; - // Attempts to begin refreshing before the initial layout are ignored by _refreshControl. So if the control is - // refreshing when mounted, we need to call beginRefreshing in layoutSubviews or it won't work. - if (_isBeforeInitialLayout) { - _isBeforeInitialLayout = NO; + if (self.window && _shouldBeRefreshing) { + // Be cautious here: only recreate if not already refreshing + if (!(_scrollViewComponentView.scrollView.refreshControl == _refreshControl && + _refreshControl.isRefreshing)) { + [self _recreateAndBeginRefreshing]; + } + } +} - [self updateProps:_props oldProps:PullToRefreshViewShadowNode::defaultSharedProps()]; +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + + if (self.window && _shouldBeRefreshing) { + // Be aggressive here: always recreate on window re-attach + [self _recreateAndBeginRefreshing]; } } @@ -185,8 +191,6 @@ using namespace facebook::react; if (@available(macCatalyst 13.1, *)) { _scrollViewComponentView.scrollView.refreshControl = _refreshControl; - - // This ensures that layoutSubviews is called. Without this, recycled instances won't refresh on mount [self setNeedsLayout]; } } @@ -197,7 +201,6 @@ using namespace facebook::react; return; } - // iOS requires to end refreshing before unmounting. [_refreshControl endRefreshing]; if (@available(macCatalyst 13.1, *)) { @@ -206,17 +209,64 @@ using namespace facebook::react; _scrollViewComponentView = nil; } +#pragma mark - Refresh control helpers + - (void)beginRefreshingProgrammatically +{ + _shouldBeRefreshing = YES; + [self _ensureSpinnerIsAnimating]; +} + +- (void)_ensureSpinnerIsAnimating { if (!_scrollViewComponentView) { return; } - // When refreshing programmatically (i.e. without pulling down), we must explicitly adjust the ScrollView content - // offset, or else the _refreshControl won't be visible UIScrollView *scrollView = _scrollViewComponentView.scrollView; - CGPoint offset = {scrollView.contentOffset.x, scrollView.contentOffset.y - _refreshControl.frame.size.height}; - [scrollView setContentOffset:offset]; + if (!scrollView) { + return; + } + + if (_shouldBeRefreshing) { + if (scrollView.refreshControl == _refreshControl && _refreshControl.isRefreshing) { + return; // Already animating fine + } + [self _recreateAndBeginRefreshing]; + } +} + +- (void)_recreateAndBeginRefreshing +{ + if (!_scrollViewComponentView) { + return; + } + + UIScrollView *scrollView = _scrollViewComponentView.scrollView; + if (!scrollView) { + return; + } + + [self _initializeUIRefreshControl]; + + // Apply all properties BEFORE assigning to scrollView.refreshControl + const auto &concreteProps = static_cast<const PullToRefreshViewProps &>(*_props); + _refreshControl.tintColor = RCTUIColorFromSharedColor(concreteProps.tintColor); + _refreshControl.layer.zPosition = concreteProps.zIndex.value_or(0); + [self _updateTitle]; + [self _updateProgressViewOffset:concreteProps.progressViewOffset]; + + // Now assign to scrollView + scrollView.refreshControl = _refreshControl; + + // Only adjust offset if not already pulled enough + CGFloat currentOffsetY = scrollView.contentOffset.y; + CGFloat refreshHeight = _refreshControl.frame.size.height; + if (currentOffsetY > -refreshHeight) { + CGPoint offset = {scrollView.contentOffset.x, + currentOffsetY - refreshHeight}; + [scrollView setContentOffset:offset animated:NO]; + } [_refreshControl beginRefreshing]; } @@ -230,6 +280,7 @@ using namespace facebook::react; - (void)setNativeRefreshing:(BOOL)refreshing { + _shouldBeRefreshing = refreshing; if (refreshing) { [self beginRefreshingProgrammatically]; } else {
The "hack" is _ensureSpinnerIsAnimating now destroys and recreates the UIRefreshControl when you come back while refreshing is still true. I don't know if this is a great solution to be honest, but it does seem to fix this frozen spinning animation.
So TLDR: Copying the 0.82.rc4 file and then adding another hack / fix to the animation seems to fix it. Here is the result:
trim.0D38E934-E152-47AC-AA7F-F0D2E57CE118.MOV
Note, didn't run any tests anywhere so use at your own risk for now.
Another note, because RN 0.81 / Expo 54 uses precompiled react-native binaries, patch-package doesn't work on the
react-nativecore package. You have to turn off the precompile like so:Reacted by liyang and Irek RógStill getting this as well
reproduced the issue.platform:ios 26, component: RefreshControl
Reacted by AmmAlso experiencing this issue
I used the patch from @ChristopherGabba in 0.81.5 and it works
+1
"react-native": "0.80.2",I used the patch from @ChristopherGabba in 0.81.5 and it works
not working for me
"react-native": "0.80.2"Note both of these are an issue on iOS 26 and I confirmed on iOS 18 as well.
2 remaining items
Same here
same here
Would you try the patch here? #43388 (comment)
Reacted by fcformal42same here
Same
"react-native": "0.81.5"
IOSThis worked for me for the color issue:
<RefreshControl {...Platform.OS === 'ios' ? { tintColor: SECONDARY_COLOR, } : { colors: [SECONDARY_COLOR], }} progressBackgroundColor={PRIMARY_COLOR} refreshing={isLoading} onRefresh={loadScreen} />This worked for me for the color issue:
<RefreshControl {...Platform.OS === 'ios' ? { tintColor: SECONDARY_COLOR, } : { colors: [SECONDARY_COLOR], }} progressBackgroundColor={PRIMARY_COLOR} refreshing={isLoading} onRefresh={loadScreen} />did not workd
Reacted by LucasNothing here work for me (RN 0.81.5 & expo 54.0.31).
Sometime tintColor quickly work while editing the code with hot reload but is randomly back to default color...Reacted by Dayanch, Pierre, Norman Wilde and Rohini Arusame issue expo 54.0.13 & RN 0.81.5
Reacted by Rohini Arusame, RN 0.81.6
anyone tried the latest RN versions just in case?
I am not sure this issue is getting any visibility unfortunately, since it MAY sound low priority, when in reality it can have quite an impact if the tintcolor doesn't stand out user doesn't know refresh is happeningReacted by Rohini Aruanyone tried the latest RN versions just in case?
Just tried with:
- RN 0.84
- react: 19.2.3
- expo 54
Same issue. Will try the patch next.
A hack that works for us till the original issue is fixed:
const [tintColor, setTintColor] = useState<string | undefined>( Platform.select({ ios: undefined, android: refreshColor, }), ); useEffect(() => { if (Platform.OS !== 'ios') { return; } let raf2: number; const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => setTintColor(refreshColor)); }); return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); }; }, [refreshColor]);
Reacted by Rohini Aru, Alexey Volkov, Mateo and Hans Gabriel B. DaduyaReacted by WillFor anyone having problems with the
RefreshControlgetting stuck on navigation, there was a similar issue reported here and I came up with this solution.It should give you similar results to @ChristopherGabba's solution (#53987 (comment)), but using a hook instead of having to dive into the native code / patch anything.
Any update?

Description
There are two bugs I'm reporting with the same component, and providing a good repro for each within the same repository.
This issue is the single main problem I am having throughout my app with the new architecture. I use this
RefreshControlcomponent everywhere and it is not behaving right anywhere.Configuration:
Bug 1 -
tintColorproperty is not respected:Code:
Video Displaying Issue:
ScreenRecording_09-30-2025.05-35-09_1.MP4
There is a hack around this issue by applying the tintColor after a small delay:
Bug 2 - Refresh Control gets stuck on navigation:
Changing tabs while a pull-to-refresh is active makes the
RefreshControlget stuck.The only hack around this that I have found is force-resetting the refreshing prop after the screen is unfocused.
Video Displaying Issue:
ScreenRecording_09-30-2025.05-31-08_1.MP4
Note I have found several past issues that are somewhat related:
#46631 (Closed, but probably shouldn't be)
#51914
#35779
Steps to reproduce
Download the repro found here:
https://github.com/ChristopherGabba/RNV7_TestApp
Run:
yarn installnpx expo prebuild --cleannpx expo run:ios --deviceReact Native Version
0.81.4
Affected Platforms
Runtime - iOS
Areas
Fabric - The New Renderer
Output of
npx @react-native-community/cli infoStacktrace or Logs
MANDATORY Reproducer
https://github.com/ChristopherGabba/RNV7_TestApp
Screenshots and Videos
Provided above.