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(