disable view culling when accessibility API is detected (#52612)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/52612

changelog: [internal]

View Culling is purely performance optimisation but it must work correctly with accessibility features like VoiceOver and Switch Control.
In this diff, View Culling is lazily disabled whenever use of an accessibility feature is detected to make sure all views are present in the view hierarchy.

Reviewed By: NickGerleman, philIip

Differential Revision: D78336010

fbshipit-source-id: 7a201afc8e2ffd8b586d75ed4de2c03d7966750c
This commit is contained in:
Samuel Susla authored and Facebook GitHub Bot committed 2025-07-16 08:16:49 -07:00
1 parent 9967908e89
commit 0fd060f558
3 files changed
+54 -4

No files matched your search

@@ -112,6 +112,11 @@ RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrollView, NSInt
__weak UIView *_firstVisibleView;
CGFloat _endDraggingSensitivityMultiplier;
// A flag indicating that accessibility API is used.
// It is not restored to the default value in prepareForRecycle.
// Once an accessibility API is used, view culling will be disabled for the entire session.
BOOL _isAccessibilityAPIUsed;
}
+ (RCTScrollViewComponentView *_Nullable)findScrollViewComponentViewForView:(UIView *)view
@@ -134,6 +139,7 @@ RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrollView, NSInt
_isUserTriggeredScrolling = NO;
_shouldUpdateContentInsetAdjustmentBehavior = YES;
_automaticallyAdjustKeyboardInsets = NO;
_isAccessibilityAPIUsed = NO;
[self addSubview:_scrollView];
_containerView = [[UIView alloc] initWithFrame:CGRectZero];
@@ -593,6 +599,38 @@ static inline UIViewAnimationOptions animationOptionsWithCurve(UIViewAnimationCu
return metrics;
}
/**
* When use of accessibility APIs is detected, view culling is disabled to make accessibility work correctly.
*/
- (void)_disableViewCullingIfNecessary
{
if (!_isAccessibilityAPIUsed) {
_isAccessibilityAPIUsed = YES;
[self _updateStateWithContentOffset];
}
}
- (NSInteger)accessibilityElementCount
{
// From empirical testing, method `accessibilityElementCount` is called lazily only
// when accessibility is used.
// Why we don't use UIAccessibilitySwitchControlStatusDidChangeNotification and
// UIAccessibilityVoiceOverStatusDidChangeNotification? The notifications are not called when using Accessibility
// Inspector. We anticipate developers will want to debug accessbility with Accessibility Inspector and don't want to
// break that developer workflow with view culling. Therefore, we are using API use detection to disable view culling
// instead of the notifications.
[self _disableViewCullingIfNecessary];
return [super accessibilityElementCount];
}
- (NSArray<id<UIFocusItem>> *)focusItemsInRect:(CGRect)rect
{
// From empirical testing, method `focusItemsInRect:` is called lazily only
// when keyboard navigation is used.
[self _disableViewCullingIfNecessary];
return [super focusItemsInRect:rect];
}
- (void)_updateStateWithContentOffset
{
if (!_state) {
@@ -600,15 +638,18 @@ static inline UIViewAnimationOptions animationOptionsWithCurve(UIViewAnimationCu
}
auto contentOffset = RCTPointFromCGPoint(_scrollView.contentOffset);
BOOL isAccessibilityAPIUsed = _isAccessibilityAPIUsed;
_state->updateState(
[contentOffset](
[contentOffset, isAccessibilityAPIUsed](
const ScrollViewShadowNode::ConcreteState::Data &oldData) -> ScrollViewShadowNode::ConcreteState::SharedData {
if (oldData.contentOffset == contentOffset) {
// avoid doing a state update if content offset didn't change.
if (oldData.contentOffset == contentOffset && oldData.disableViewCulling == isAccessibilityAPIUsed) {
// avoid doing a state update if content offset and use of accessibility didn't change.
return nullptr;
}
auto newData = oldData;
newData.contentOffset = contentOffset;
newData.disableViewCulling =
UIAccessibilityIsVoiceOverRunning() || UIAccessibilityIsSwitchControlRunning() || isAccessibilityAPIUsed;
return std::make_shared<const ScrollViewShadowNode::ConcreteState::Data>(newData);
},
ReactNativeFeatureFlags::enableImmediateUpdateModeForContentOffsetChanges()
@@ -33,6 +33,14 @@ class ScrollViewState final {
Rect contentBoundingRect;
int scrollAwayPaddingTop;
/*
* View Culling has to be disabled when accessibility features are used.
* View Culling removes views from view hierarchy and for example VoiceOver
* wouldn't recognise there is a view outside of the viewport that it can
* scroll to.
*/
bool disableViewCulling{false};
/*
* Returns size of scrollable area.
*/
@@ -24,7 +24,8 @@ CullingContext CullingContext::adjustCullingContextIfNeeded(
if (auto scrollViewShadowNode =
dynamic_cast<const ScrollViewShadowNode*>(pair.shadowNode)) {
if (scrollViewShadowNode->getConcreteProps().yogaStyle.overflow() !=
yoga::Overflow::Visible) {
yoga::Overflow::Visible &&
!scrollViewShadowNode->getStateData().disableViewCulling) {
auto layoutMetrics = scrollViewShadowNode->getLayoutMetrics();
cullingContext.frame.origin =
-scrollViewShadowNode->getContentOriginOffset(