RefreshControl Issues - `tintColor` prop not respected & gets stuck on navigation
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 RefreshControl component everywhere and it is not behaving right anywhere.
Configuration:
- New Arch
- RN 0.81.4
- Expo 54.0.10
- React 19.1.0
- iOS
Bug 1 - tintColor property is not respected:
Code:
<RefreshControl
refreshing={refreshing}
onRefresh={manualRefresh}
tintColor="orange" // Should be orange but isn't respected
/>
Video Displaying Issue:
https://github.com/user-attachments/assets/bb7add88-634d-44c0-b7a4-52b2821a885f
There is a hack around this issue by applying the tintColor after a small delay:
const [tintColor, setTintColor] = useState<ColorValue>("white")
useEffect(() => {
setTimeout(() => setTintColor("orange"), 500)
}, [])
Bug 2 - Refresh Control gets stuck on navigation:
Changing tabs while a pull-to-refresh is active makes the RefreshControl get stuck.
The only hack around this that I have found is force-resetting the refreshing prop after the screen is unfocused.
const isFocused = useIsFocused()
useEffect(() => {
if (!isFocused) {
setRefreshing(false)
}
},[isFocused])
Video Displaying Issue:
https://github.com/user-attachments/assets/43406fa5-7d1e-41b0-b2c3-005490ccce33
Note I have found several past issues that are somewhat related:
https://github.com/facebook/react-native/issues/46631 (Closed, but probably shouldn't be) https://github.com/facebook/react-native/issues/51914 https://github.com/facebook/react-native/issues/35779
Steps to reproduce
Download the repro found here: https://github.com/ChristopherGabba/RNV7_TestApp
Run:
yarn installnpx expo prebuild --cleannpx expo run:ios --device
React Native Version
0.81.4
Affected Platforms
Runtime - iOS
Areas
Fabric - The New Renderer
Output of npx @react-native-community/cli info
info Fetching system and libraries information...
System:
OS: macOS 15.6.1
CPU: (10) arm64 Apple M2 Pro
Memory: 131.34 MB / 16.00 GB
Shell:
version: "5.9"
path: /bin/zsh
Binaries:
Node:
version: 23.4.0
path: ~/.nvm/versions/node/v23.4.0/bin/node
Yarn:
version: 1.22.22
path: /opt/homebrew/bin/yarn
npm:
version: 11.5.2
path: ~/.nvm/versions/node/v23.4.0/bin/npm
Watchman:
version: 2025.04.14.00
path: /opt/homebrew/bin/watchman
Managers:
CocoaPods:
version: 1.16.2
path: /Users/christophergabba/.gem/ruby/3.4.3/bin/pod
SDKs:
iOS SDK:
Platforms:
- DriverKit 25.0
- iOS 26.0
- macOS 26.0
- tvOS 26.0
- visionOS 26.0
- watchOS 26.0
Android SDK: Not Found
IDEs:
Android Studio: 2025.1 AI-251.26094.121.2513.14007798
Xcode:
version: 26.0.1/17A400
path: /usr/bin/xcodebuild
Languages:
Java:
version: 17.0.11
path: /usr/bin/javac
Ruby:
version: 3.4.3
path: /Users/christophergabba/.rubies/ruby-3.4.3/bin/ruby
npmPackages:
"@react-native-community/cli": Not Found
react:
installed: 19.1.0
wanted: 19.1.0
react-native:
installed: 0.81.4
wanted: 0.81.4
react-native-macos: Not Found
npmGlobalPackages:
"*react-native*": Not Found
Android:
hermesEnabled: Not found
newArchEnabled: Not found
iOS:
hermesEnabled: Not found
newArchEnabled: Not found
Stacktrace or Logs
N/A
MANDATORY Reproducer
https://github.com/ChristopherGabba/RNV7_TestApp
Screenshots and Videos
Provided above.
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.
Same issue
Same here
Okay 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):
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:
https://github.com/user-attachments/assets/f13a275a-f4c0-42a5-87f0-c259c29383e0
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-native core package. You have to turn off the precompile like so:
app.json:
Still getting this as well
reproduced the issue.platform:ios 26, component: RefreshControl
Also experiencing this issue
@kayson-argyle https://longitudesigned5.github.io/duplicate857/
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.
Same "react-native": "0.81.5" IOS
@ChristopherGabba thank you so much for that patch. It seems to have fixed the tintColor indeed, but I still had the "empty refresh control" appear after switching back/forth from tabs.
I was able to fix it by expanding on the didMoveToWindow. Essentially, if _shouldBeRefreshing is NO, I ensure the host scrollView offset is reset:
+- (void)didMoveToWindow
+{
+ [super didMoveToWindow];
+
+ if (!self.window) {
+ return;
+ }
+
+ if (_shouldBeRefreshing) {
+ // Be aggressive here: always recreate on window re-attach
+ [self _recreateAndBeginRefreshing];
+ } else {
+ [_refreshControl endRefreshing];
+
+ UIScrollView *scrollView = _scrollViewComponentView.scrollView;
+ UIEdgeInsets inset = scrollView.adjustedContentInset;
+ [scrollView setContentOffset:CGPointMake(0, -inset.top) animated:NO];
+ }
+}
Same here
same here
Would you try the patch here? https://github.com/facebook/react-native/issues/43388#issuecomment-2476297017