From 0fd060f5589119a079a5292fdc2cc92f101d8efa Mon Sep 17 00:00:00 2001 From: Samuel Susla Date: Wed, 16 Jul 2025 08:16:49 -0700 Subject: [PATCH] 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 --- .../ScrollView/RCTScrollViewComponentView.mm | 47 +++++++++++++++++-- .../components/scrollview/ScrollViewState.h | 8 ++++ .../mounting/internal/CullingContext.cpp | 3 +- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 5abc1682ed1..a3191aa4372 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -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> *)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(newData); }, ReactNativeFeatureFlags::enableImmediateUpdateModeForContentOffsetChanges() diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewState.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewState.h index bfe5d7f308f..e4a53e61704 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewState.h +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewState.h @@ -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. */ diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/internal/CullingContext.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/internal/CullingContext.cpp index 52c539965de..8294edb254b 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/internal/CullingContext.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/internal/CullingContext.cpp @@ -24,7 +24,8 @@ CullingContext CullingContext::adjustCullingContextIfNeeded( if (auto scrollViewShadowNode = dynamic_cast(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(