fix refresh control offset

This commit is contained in:
Oleksii Bulenok
2026-08-05 15:01:15 +02:00
parent 10e1930f74
commit 2af780a009
2 changed files with 178 additions and 74 deletions
+131 -60
View File
@@ -198,66 +198,43 @@ index ac553045a9c0ce77e288277912538d9e131ebc01..d99c8f4db5a07f1e4ffe7e03ff23adce
NSRange visibleGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
index 60160efb163d91813fa2ca7ca758b51afcf261e1..4791a4f123f74d9737e2d0cb1b7fa6e129315562 100644
index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90180691f1 100644
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
@@ -39,8 +39,45 @@ - (void)setRefreshing:(BOOL)refreshing
#else
+// Fix for https://github.com/facebook/react-native/issues/43388 — on iOS 17.4+
+// the pull-to-refresh trigger haptic is permanently suppressed if tintColor is
+// set before the control is actually inside the scroll view's view hierarchy.
+// Assigning scrollView.refreshControl does NOT insert it immediately (UIKit
+// does that lazily on a later layout pass), so attach-time is still too early.
+// Instead, store the wanted color and apply it from didMoveToSuperview, and
+// refuse direct tintColor sets until the superview is the scroll view.
+// Mirrors Bluesky's Paper patch (bluesky-social/social-app#5605).
+@interface RCTHapticCompatibleRefreshControl : UIRefreshControl
+@property (nonatomic, strong) UIColor *customTintColor;
+@end
+
+@implementation RCTHapticCompatibleRefreshControl
+
+- (void)didMoveToSuperview
+{
+ [super didMoveToSuperview];
+ if (self.customTintColor) {
+ [self setTintColor:self.customTintColor];
+ }
+}
+
+- (void)setCustomTintColor:(UIColor *)customTintColor
+{
+ _customTintColor = customTintColor;
+ [self setTintColor:customTintColor];
+}
+
+- (void)setTintColor:(UIColor *)tintColor
+{
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
+ [super setTintColor:tintColor];
+ }
+}
+
+@end
+
@@ -42,6 +42,32 @@ - (void)setRefreshing:(BOOL)refreshing
@implementation RCTPullToRefreshViewComponentView {
- UIRefreshControl *_refreshControl;
+ RCTHapticCompatibleRefreshControl *_refreshControl;
UIRefreshControl *_refreshControl;
RCTScrollViewComponentView *__weak _scrollViewComponentView;
+ /*
+ * Deferred props: updateProps runs during the Create mount mutation, before
+ * _attach puts the control on the scroll view, and writes to a detached
+ * UIRefreshControl are hazardous:
+ *
+ * - tintColor: writing it to a detached control permanently suppresses the
+ * pull-to-refresh trigger haptic on iOS 17.4+
+ * (https://github.com/facebook/react-native/issues/43388).
+ *
+ * - progressViewOffset (the bounds.origin shift): on iOS 26 the control's
+ * _UIRefreshControlModernContentView positions itself at whatever
+ * bounds.origin it observes when it is CREATED - at insertion into the
+ * scroll view, or earlier if a pre-attach property write materializes it -
+ * and keeps that y forever (width tracks, y never re-pins; verified via
+ * on-device frame logging, Aug 2026). A pre-attach shift is therefore
+ * baked into the content view's own frame and cancelled exactly, hiding
+ * the spinner. Applied post-attach, the content view has already been
+ * created at origin 0 and the same bounds shift works as intended.
+ *
+ * Both props are parked here and applied only once the control is inside
+ * the scroll view.
+ */
+ UIColor *_pendingTintColor;
+ BOOL _hasPendingTintColor;
+ CGFloat _pendingProgressViewOffset;
+ BOOL _hasPendingProgressViewOffset;
// This variable keeps track of whether the view is recycled or not. Once the view is recycled, the component
// creates a new instance of UIRefreshControl, resetting the native props to the default values.
@@ -66,7 +103,7 @@ - (instancetype)initWithFrame:(CGRect)frame
- (void)_initializeUIRefreshControl
{
- _refreshControl = [UIRefreshControl new];
+ _refreshControl = [RCTHapticCompatibleRefreshControl new];
[_refreshControl addTarget:self
action:@selector(handleUIControlEventValueChanged)
forControlEvents:UIControlEventValueChanged];
@@ -79,6 +116,17 @@ + (ComponentDescriptorProvider)componentDescriptorProvider
// However, when recycling, we are keeping around the old _props. The flag is used to force the application
@@ -79,10 +105,25 @@ + (ComponentDescriptorProvider)componentDescriptorProvider
return concreteComponentDescriptorProvider<PullToRefreshViewComponentDescriptor>();
}
@@ -275,23 +252,82 @@ index 60160efb163d91813fa2ca7ca758b51afcf261e1..4791a4f123f74d9737e2d0cb1b7fa6e1
- (void)prepareForRecycle
{
[super prepareForRecycle];
@@ -93,7 +141,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
_scrollViewComponentView = nil;
+ _pendingTintColor = nil;
+ _hasPendingTintColor = NO;
+ _pendingProgressViewOffset = 0;
+ _hasPendingProgressViewOffset = NO;
[self _initializeUIRefreshControl];
_recycled = YES;
}
@@ -93,7 +134,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
const auto &newConcreteProps = static_cast<const PullToRefreshViewProps &>(*props);
if (_recycled || newConcreteProps.tintColor != oldConcreteProps.tintColor) {
- _refreshControl.tintColor = RCTUIColorFromSharedColor(newConcreteProps.tintColor);
+ // Goes through RCTHapticCompatibleRefreshControl, which defers the real
+ // tintColor set until the control is inside the scroll view (#43388).
+ _refreshControl.customTintColor = RCTUIColorFromSharedColor(newConcreteProps.tintColor);
+ // Deferred until the control is inside the scroll view (#43388).
+ [self _updateTintColor:RCTUIColorFromSharedColor(newConcreteProps.tintColor)];
}
if (_recycled || newConcreteProps.progressViewOffset != oldConcreteProps.progressViewOffset) {
@@ -153,7 +203,12 @@ - (void)_updateTitle
@@ -141,11 +183,50 @@ - (void)handleUIControlEventValueChanged
- (void)_updateProgressViewOffset:(Float)progressViewOffset
{
+ _pendingProgressViewOffset = progressViewOffset;
+ _hasPendingProgressViewOffset = YES;
+ // Applies immediately for runtime changes while the control is attached;
+ // pre-attach sets wait until the control is inside the scroll view (see the
+ // _pendingProgressViewOffset declaration).
+ [self _applyPendingProgressViewOffsetIfPossible];
+ if (_hasPendingProgressViewOffset) {
+ [self setNeedsLayout];
+ }
+}
+
+- (void)_applyPendingProgressViewOffsetIfPossible
+{
+ if (!_hasPendingProgressViewOffset || ![_refreshControl.superview isKindOfClass:[UIScrollView class]]) {
+ return;
+ }
_refreshControl.bounds = CGRectMake(
_refreshControl.bounds.origin.x,
- -progressViewOffset,
+ -_pendingProgressViewOffset,
_refreshControl.bounds.size.width,
_refreshControl.bounds.size.height);
+ _hasPendingProgressViewOffset = NO;
+}
+
+- (void)_updateTintColor:(UIColor *)tintColor
+{
+ _pendingTintColor = tintColor;
+ _hasPendingTintColor = YES;
+ // Applies immediately for runtime changes while the control is attached;
+ // pre-attach sets wait until the control is inside the scroll view.
+ [self _applyPendingTintColorIfPossible];
+ if (_hasPendingTintColor) {
+ [self setNeedsLayout];
+ }
+}
+
+- (void)_applyPendingTintColorIfPossible
+{
+ if (!_hasPendingTintColor || ![_refreshControl.superview isKindOfClass:[UIScrollView class]]) {
+ return;
+ }
+ _refreshControl.tintColor = _pendingTintColor;
+ _pendingTintColor = nil;
+ _hasPendingTintColor = NO;
}
- (void)_updateTitle
@@ -153,7 +234,12 @@ - (void)_updateTitle
const auto &concreteProps = static_cast<const PullToRefreshViewProps &>(*_props);
if (concreteProps.title.empty()) {
- _refreshControl.attributedTitle = nil;
+ // Avoid touching the control when there is nothing to clear writing
+ // Avoid touching the control when there is nothing to clear - writing
+ // attributedTitle (even nil) before the control is in the scroll view
+ // hierarchy can suppress the pull-to-refresh haptic (#43388).
+ if (_refreshControl.attributedTitle != nil) {
@@ -300,3 +336,38 @@ index 60160efb163d91813fa2ca7ca758b51afcf261e1..4791a4f123f74d9737e2d0cb1b7fa6e1
return;
}
@@ -172,6 +258,18 @@ - (void)layoutSubviews
{
[super layoutSubviews];
+ /*
+ * Fallback for the pending props: _attach applies them right after the
+ * refreshControl assignment (insertion is synchronous there on current iOS),
+ * but should UIKit ever defer the insertion to a later layout pass, re-arm
+ * and retry until the control is actually inside the scroll view.
+ */
+ [self _applyPendingTintColorIfPossible];
+ [self _applyPendingProgressViewOffsetIfPossible];
+ if ((_hasPendingTintColor || _hasPendingProgressViewOffset) && _scrollViewComponentView != nil) {
+ [self setNeedsLayout];
+ }
+
// 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 (self.window) {
@@ -209,6 +307,15 @@ - (void)_attach
// This ensures that layoutSubviews is called. Without this, recycled instances won't refresh on mount
[self setNeedsLayout];
+
+ /*
+ * The assignment above inserts the control (and creates its content view)
+ * synchronously on current iOS - verified via frame logging - so the
+ * pending props can be applied immediately. layoutSubviews is the fallback
+ * if insertion is ever deferred.
+ */
+ [self _applyPendingTintColorIfPossible];
+ [self _applyPendingProgressViewOffsetIfPossible];
}
}