diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js new file mode 100644 index 00000000000000..39f0992271acbb --- /dev/null +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js @@ -0,0 +1,660 @@ +/** + * 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. + * + * @flow strict-local + * @format + * @oncall react_native + * @fantom_flags enableAccessToHostTreeInFabric:true + * @fantom_flags enableViewCulling:true + * @fantom_flags enableSynchronousStateUpdates:true + */ + +import '../../../Core/InitializeCore.js'; +import ensureInstance from '../../../../src/private/utilities/ensureInstance'; +import ReactNativeElement from '../../../../src/private/webapis/dom/nodes/ReactNativeElement'; +import View from '../../View/View'; +import ScrollView from '../ScrollView'; +import Fantom from '@react-native/fantom'; +import * as React from 'react'; + +test('basic culling', () => { + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + let maybeNode; + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }}> + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 60, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', + 'Delete {type: "View", nativeID: "child"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Delete {type: "View", nativeID: (N/A)}', + 'Update {type: "ScrollView", nativeID: (N/A)}', + ]); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 0, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + ]); +}); + +test('recursive culling', () => { + const root = Fantom.createRoot({viewportHeight: 100, viewportWidth: 100}); + let maybeNode; + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }}> + + + + + + + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element A"}', + 'Create {type: "View", nativeID: "child AA"}', + 'Create {type: "View", nativeID: "child AB"}', + 'Insert {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AA"}', + 'Insert {type: "View", parentNativeID: "element A", index: 1, nativeID: "child AB"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + // === Scroll down to the edge of child AA === + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 30, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + ]); + + // === Scroll down past child AA === + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 36, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AA"}', + 'Delete {type: "View", nativeID: "child AA"}', + ]); + + // === Scroll down past child AB === + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 51, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AB"}', + 'Delete {type: "View", nativeID: "child AB"}', + ]); + + // === Scroll down past element A === + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 56, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Delete {type: "View", nativeID: "element A"}', + ]); + + // Scroll element B into viewport. Just child BA should be created. + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 155, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element B"}', + 'Create {type: "View", nativeID: "child BA"}', + 'Insert {type: "View", parentNativeID: "element B", index: 0, nativeID: "child BA"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element B"}', + ]); + + // Scroll child BA into viewport. + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 165, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "child BB"}', + 'Insert {type: "View", parentNativeID: "element B", index: 1, nativeID: "child BB"}', + ]); + + // Scroll back to start + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 0, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: "element B", index: 1, nativeID: "child BB"}', + 'Remove {type: "View", parentNativeID: "element B", index: 0, nativeID: "child BA"}', + 'Delete {type: "View", nativeID: "child BA"}', + 'Delete {type: "View", nativeID: "child BB"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element B"}', + 'Delete {type: "View", nativeID: "element B"}', + 'Create {type: "View", nativeID: "element A"}', + 'Create {type: "View", nativeID: "child AA"}', + 'Create {type: "View", nativeID: "child AB"}', + 'Insert {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AA"}', + 'Insert {type: "View", parentNativeID: "element A", index: 1, nativeID: "child AB"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + ]); + + // Scroll past element A + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 85, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: "element A", index: 1, nativeID: "child AB"}', + 'Remove {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AA"}', + 'Delete {type: "View", nativeID: "child AA"}', + 'Delete {type: "View", nativeID: "child AB"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Delete {type: "View", nativeID: "element A"}', + ]); +}); + +test('recursive culling when initial offset is negative', () => { + const root = Fantom.createRoot({viewportHeight: 874, viewportWidth: 402}); + let maybeNode; + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }}> + + + + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 0, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "child A"}', + 'Create {type: "View", nativeID: "child B"}', + 'Create {type: "View", nativeID: "child BA"}', + 'Create {type: "View", nativeID: "child BB"}', + 'Insert {type: "View", parentNativeID: "child B", index: 0, nativeID: "child BA"}', + 'Insert {type: "View", parentNativeID: "child B", index: 1, nativeID: "child BB"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 1, nativeID: "child B"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + ]); +}); + +test('deep nesting', () => { + const root = Fantom.createRoot({viewportHeight: 100, viewportWidth: 100}); + let maybeNode; + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }}> + + + + + + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 40, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element B"}', + 'Create {type: "View", nativeID: "child BA"}', + 'Create {type: "View", nativeID: "child BAA"}', + 'Insert {type: "View", parentNativeID: "child BA", index: 0, nativeID: "child BAA"}', + 'Insert {type: "View", parentNativeID: "element B", index: 0, nativeID: "child BA"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 1, nativeID: "element B"}', + ]); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 150, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Delete {type: "View", nativeID: "element A"}', + 'Create {type: "View", nativeID: "child BAB"}', + 'Insert {type: "View", parentNativeID: "child BA", index: 1, nativeID: "child BAB"}', + ]); +}); + +test('adding new item into area that is not culled', () => { + const root = Fantom.createRoot({viewportHeight: 100, viewportWidth: 100}); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + Fantom.runTask(() => { + root.render( + + + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Create {type: "View", nativeID: "child AA"}', + 'Insert {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AA"}', + ]); +}); + +test('adding new item into area that is culled', () => { + const root = Fantom.createRoot({viewportHeight: 100, viewportWidth: 100}); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element B"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element B"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + Fantom.runTask(() => { + root.render( + + + + , + ); + }); + + // element B is updated but it should be inconsequential. + // Differentiator generates an update for it because Yoga cloned + // shadow node backing element B. + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "View", nativeID: "element B"}', + ]); +}); + +test('initial render', () => { + let maybeNode; + const root = Fantom.createRoot({viewportHeight: 100, viewportWidth: 100}); + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }} + style={{height: 100, width: 100}}> + + + + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 100, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Delete {type: "View", nativeID: "element A"}', + 'Create {type: "View", nativeID: "element B"}', + 'Create {type: "View", nativeID: "child BA"}', + 'Create {type: "View", nativeID: "child BB"}', + 'Insert {type: "View", parentNativeID: "element B", index: 0, nativeID: "child BA"}', + 'Insert {type: "View", parentNativeID: "element B", index: 1, nativeID: "child BB"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element B"}', + ]); +}); + +test('unmounting culled elements', () => { + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + Fantom.runTask(() => { + root.render(<>); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + 'Delete {type: "ScrollView", nativeID: (N/A)}', + ]); +}); + +// TODO: only elements in ScrollView are culled. +test('basic culling smaller ScrollView', () => { + let maybeNode; + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }} + style={{height: 50, width: 50, marginTop: 25}}> + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element 1"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element 1"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 11, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element 1"}', + 'Delete {type: "View", nativeID: "element 1"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Delete {type: "View", nativeID: (N/A)}', + 'Update {type: "ScrollView", nativeID: (N/A)}', + ]); +}); + +test('views are not culled when outside of viewport', () => { + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "View", nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (root), index: 0, nativeID: "child"}', + ]); +}); diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index e1d731ae17ee07..7552cee49fac3e 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -64,6 +64,7 @@ - (instancetype)initWithFrame:(CGRect)frame _reactSubviews = [NSMutableArray new]; self.multipleTouchEnabled = YES; _useCustomContainerView = NO; + _removeClippedSubviews = NO; } return self; } @@ -229,10 +230,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & needsInvalidateLayer = YES; } - if (oldViewProps.removeClippedSubviews != newViewProps.removeClippedSubviews) { - _removeClippedSubviews = newViewProps.removeClippedSubviews; - if (_removeClippedSubviews && self.currentContainerView.subviews.count > 0) { - _reactSubviews = [NSMutableArray arrayWithArray:self.currentContainerView.subviews]; + // Disable `removeClippedSubviews` when Fabric View Culling is enabled. + if (!ReactNativeFeatureFlags::enableViewCulling()) { + if (oldViewProps.removeClippedSubviews != newViewProps.removeClippedSubviews) { + _removeClippedSubviews = newViewProps.removeClippedSubviews; + if (_removeClippedSubviews && self.currentContainerView.subviews.count > 0) { + _reactSubviews = [NSMutableArray arrayWithArray:self.currentContainerView.subviews]; + } } } diff --git a/packages/react-native/ReactCommon/React-Fabric.podspec b/packages/react-native/ReactCommon/React-Fabric.podspec index 08b5134becc658..eff21eaeee78e8 100644 --- a/packages/react-native/ReactCommon/React-Fabric.podspec +++ b/packages/react-native/ReactCommon/React-Fabric.podspec @@ -150,6 +150,14 @@ Pod::Spec.new do |s| sss.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/Headers/Private/Yoga\"" } end + ss.subspec "scrollview" do |sss| + sss.dependency folly_dep_name, folly_version + sss.compiler_flags = folly_compiler_flags + sss.source_files = "react/renderer/components/scrollview/*.{m,mm,cpp,h}" + sss.header_dir = "react/renderer/components/scrollview" + ss.exclude_files = "react/renderer/components/scrollview/tests" + end + ss.subspec "legacyviewmanagerinterop" do |sss| sss.dependency folly_dep_name, folly_version sss.compiler_flags = folly_compiler_flags diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/Point.h b/packages/react-native/ReactCommon/react/renderer/graphics/Point.h index 34ffe2ad54daf5..fbd0cf94866bd5 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/Point.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/Point.h @@ -33,6 +33,13 @@ struct Point { return *this; } + Point operator-() noexcept { + return { + .x = -x, + .y = -y, + }; + } + Point& operator*=(const Point& point) noexcept { x *= point.x; y *= point.y; diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp index f38e0fa7109a34..379f15eef562ef 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -233,6 +234,23 @@ static bool shouldFirstPairComesBeforeSecondOne( return lhs->shadowNode->getOrderIndex() < rhs->shadowNode->getOrderIndex(); } +static Rect adjustCullingFrameIfNeeded( + Rect cullingFrame, + const ShadowViewNodePair& pair) { + if (ReactNativeFeatureFlags::enableViewCulling()) { + if (auto scrollViewShadowNode = + dynamic_cast(pair.shadowNode)) { + cullingFrame.origin = -scrollViewShadowNode->getContentOriginOffset( + /* includeTransform */ true); + cullingFrame.size = scrollViewShadowNode->getLayoutMetrics().frame.size; + } else { + cullingFrame.origin -= pair.shadowView.layoutMetrics.frame.origin; + } + } + + return cullingFrame; +} + /* * Reorders pairs in-place based on `orderIndex` using a stable sort algorithm. */ @@ -263,10 +281,10 @@ static void sliceChildShadowNodeViewPairsRecursively( size_t& startOfStaticIndex, ViewNodePairScope& scope, Point layoutOffset, - const ShadowNode& shadowNode) { + const ShadowNode& shadowNode, + Rect cullingFrame) { for (const auto& sharedChildShadowNode : shadowNode.getChildren()) { auto& childShadowNode = *sharedChildShadowNode; - #ifndef ANDROID // T153547836: Disabled on Android because the mounting infrastructure // is not fully ready yet. @@ -274,9 +292,28 @@ static void sliceChildShadowNodeViewPairsRecursively( continue; } #endif - auto shadowView = ShadowView(childShadowNode); + + if (ReactNativeFeatureFlags::enableViewCulling()) { + auto isCullingFrameValid = + cullingFrame.size.width != 0 && cullingFrame.size.height != 0; + if (isCullingFrameValid && + shadowView.layoutMetrics != EmptyLayoutMetrics) { + auto doesIntersect = + Rect::intersect( + cullingFrame, + shadowView.layoutMetrics.getOverflowInsetFrame()) != Rect{}; + if (!doesIntersect) { + continue; // Culling. + } + } + } + auto origin = layoutOffset; + auto cullingFrameCopy = adjustCullingFrameIfNeeded( + cullingFrame, + {.shadowView = shadowView, .shadowNode = &childShadowNode}); + if (shadowView.layoutMetrics != EmptyLayoutMetrics) { origin += shadowView.layoutMetrics.frame.origin; shadowView.layoutMetrics.frame.origin += layoutOffset; @@ -317,14 +354,24 @@ static void sliceChildShadowNodeViewPairsRecursively( startOfStaticIndex++; if (areChildrenFlattened) { sliceChildShadowNodeViewPairsRecursively( - pairList, startOfStaticIndex, scope, origin, childShadowNode); + pairList, + startOfStaticIndex, + scope, + origin, + childShadowNode, + cullingFrameCopy); } } else { pairList.push_back(&scope.back()); if (areChildrenFlattened) { size_t pairListSize = pairList.size(); sliceChildShadowNodeViewPairsRecursively( - pairList, pairListSize, scope, origin, childShadowNode); + pairList, + pairListSize, + scope, + origin, + childShadowNode, + cullingFrameCopy); } } } @@ -334,7 +381,8 @@ std::vector sliceChildShadowNodeViewPairs( const ShadowViewNodePair& shadowNodePair, ViewNodePairScope& scope, bool allowFlattened, - Point layoutOffset) { + Point layoutOffset, + Rect cullingFrame) { const auto& shadowNode = *shadowNodePair.shadowNode; auto pairList = std::vector{}; @@ -346,7 +394,12 @@ std::vector sliceChildShadowNodeViewPairs( size_t startOfStaticIndex = 0; sliceChildShadowNodeViewPairsRecursively( - pairList, startOfStaticIndex, scope, layoutOffset, shadowNode); + pairList, + startOfStaticIndex, + scope, + layoutOffset, + shadowNode, + cullingFrame); // Sorting pairs based on `orderIndex` if needed. reorderInPlaceIfNeeded(pairList); @@ -369,12 +422,14 @@ static std::vector sliceChildShadowNodeViewPairsFromViewNodePair( const ShadowViewNodePair& shadowViewNodePair, ViewNodePairScope& scope, - bool allowFlattened = false) { + bool allowFlattened, + Rect cullingFrame) { return sliceChildShadowNodeViewPairs( shadowViewNodePair, scope, allowFlattened, - shadowViewNodePair.contextOrigin); + shadowViewNodePair.contextOrigin, + cullingFrame); } /* @@ -409,7 +464,9 @@ static void calculateShadowViewMutations( ShadowViewMutation::List& mutations, Tag parentTag, std::vector&& oldChildPairs, - std::vector&& newChildPairs); + std::vector&& newChildPairs, + Rect oldCullingFrame = {}, + Rect newCullingFrame = {}); struct OrderedMutationInstructionContainer { ShadowViewMutation::List createMutations{}; @@ -428,7 +485,9 @@ static void updateMatchedPairSubtrees( std::vector& oldChildPairs, Tag parentTag, const ShadowViewNodePair& oldPair, - const ShadowViewNodePair& newPair); + const ShadowViewNodePair& newPair, + Rect oldCullingFrame, + Rect newCullingFrame); static void updateMatchedPair( OrderedMutationInstructionContainer& mutationContainer, @@ -446,8 +505,10 @@ static void calculateShadowViewMutationsFlattener( TinyMap& unvisitedOtherNodes, const ShadowViewNodePair& node, Tag parentTagForUpdate, - TinyMap* parentSubVisitedOtherNewNodes = nullptr, - TinyMap* parentSubVisitedOtherOldNodes = nullptr); + TinyMap* parentSubVisitedOtherNewNodes, + TinyMap* parentSubVisitedOtherOldNodes, + Rect oldCullingFrame, + Rect newCullingFrame); /** * Updates the subtrees of any matched ShadowViewNodePair. This handles @@ -464,7 +525,9 @@ static void updateMatchedPairSubtrees( std::vector& oldChildPairs, Tag parentTag, const ShadowViewNodePair& oldPair, - const ShadowViewNodePair& newPair) { + const ShadowViewNodePair& newPair, + Rect oldCullingFrame, + Rect newCullingFrame) { // Are we flattening or unflattening either one? If node was // flattened in both trees, there's no change, just continue. if (oldPair.flattened && newPair.flattened) { @@ -493,7 +556,11 @@ static void updateMatchedPairSubtrees( parentTag, newRemainingPairs, oldPair, - oldPair.shadowView.tag); + oldPair.shadowView.tag, + nullptr, + nullptr, + oldCullingFrame, + newCullingFrame); } // Unflattening else { @@ -504,8 +571,8 @@ static void updateMatchedPairSubtrees( // relative order. The reason for this is because of flattening // + zIndex: the children could be listed before the parent, // interwoven with children from other nodes, etc. - auto oldFlattenedNodes = - sliceChildShadowNodeViewPairsFromViewNodePair(oldPair, scope, true); + auto oldFlattenedNodes = sliceChildShadowNodeViewPairsFromViewNodePair( + oldPair, scope, true, oldCullingFrame); for (size_t i = 0, j = 0; i < oldChildPairs.size() && j < oldFlattenedNodes.size(); i++) { @@ -524,7 +591,11 @@ static void updateMatchedPairSubtrees( parentTag, unvisitedOldChildPairs, newPair, - parentTag); + parentTag, + nullptr, + nullptr, + oldCullingFrame, + newCullingFrame); // If old nodes were not visited, we know that we can delete // them now. They will be removed from the hierarchy by the @@ -551,13 +622,18 @@ static void updateMatchedPairSubtrees( // Update subtrees if View is not flattened, and if node addresses // are not equal - if (oldPair.shadowNode != newPair.shadowNode) { + if (oldPair.shadowNode != newPair.shadowNode || + oldCullingFrame != newCullingFrame) { + oldCullingFrame = adjustCullingFrameIfNeeded(oldCullingFrame, oldPair); + newCullingFrame = adjustCullingFrameIfNeeded(newCullingFrame, newPair); + ViewNodePairScope innerScope{}; - auto oldGrandChildPairs = - sliceChildShadowNodeViewPairsFromViewNodePair(oldPair, innerScope); - auto newGrandChildPairs = - sliceChildShadowNodeViewPairsFromViewNodePair(newPair, innerScope); + auto oldGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( + oldPair, innerScope, false, oldCullingFrame); + auto newGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( + newPair, innerScope, false, newCullingFrame); const size_t newGrandChildPairsSize = newGrandChildPairs.size(); + calculateShadowViewMutations( innerScope, *(newGrandChildPairsSize != 0u @@ -565,7 +641,9 @@ static void updateMatchedPairSubtrees( : &mutationContainer.destructiveDownwardMutations), oldPair.shadowView.tag, std::move(oldGrandChildPairs), - std::move(newGrandChildPairs)); + std::move(newGrandChildPairs), + oldCullingFrame, + newCullingFrame); } } @@ -679,10 +757,13 @@ static void calculateShadowViewMutationsFlattener( const ShadowViewNodePair& node, Tag parentTagForUpdate, TinyMap* parentSubVisitedOtherNewNodes, - TinyMap* parentSubVisitedOtherOldNodes) { + TinyMap* parentSubVisitedOtherOldNodes, + Rect oldCullingFrame, + Rect newCullingFrame) { // Step 1: iterate through entire tree std::vector treeChildren = - sliceChildShadowNodeViewPairsFromViewNodePair(node, scope); + sliceChildShadowNodeViewPairsFromViewNodePair( + node, scope, false, newCullingFrame); DEBUG_LOGS({ LOG(ERROR) << "Differ Flattener: " @@ -885,9 +966,11 @@ static void calculateShadowViewMutationsFlattener( mutationContainer.downwardMutations, newTreeNodePair.shadowView.tag, sliceChildShadowNodeViewPairsFromViewNodePair( - oldTreeNodePair, innerScope), + oldTreeNodePair, innerScope, false, oldCullingFrame), sliceChildShadowNodeViewPairsFromViewNodePair( - newTreeNodePair, innerScope)); + newTreeNodePair, innerScope, false, newCullingFrame), + oldCullingFrame, + newCullingFrame); } } else if (oldTreeNodePair.flattened != newTreeNodePair.flattened) { // We need to handle one of the children being flattened or @@ -912,14 +995,18 @@ static void calculateShadowViewMutationsFlattener( ? oldTreeNodePair.shadowView.tag : parentTag), subVisitedNewMap, - subVisitedOldMap); + subVisitedOldMap, + oldCullingFrame, + newCullingFrame); } else { // Get flattened nodes from either new or old tree auto flattenedNodes = sliceChildShadowNodeViewPairsFromViewNodePair( (childReparentMode == ReparentMode::Flatten ? newTreeNodePair : oldTreeNodePair), scope, - true); + true, + childReparentMode == ReparentMode::Flatten ? newCullingFrame + : oldCullingFrame); // Construct unvisited nodes map auto unvisitedRecursiveChildPairs = TinyMap{}; @@ -956,7 +1043,9 @@ static void calculateShadowViewMutationsFlattener( ? oldTreeNodePair.shadowView.tag : parentTag), subVisitedNewMap, - subVisitedOldMap); + subVisitedOldMap, + oldCullingFrame, + newCullingFrame); } // Flatten parent, unflatten child else { @@ -974,7 +1063,9 @@ static void calculateShadowViewMutationsFlattener( ? oldTreeNodePair.shadowView.tag : parentTag), subVisitedNewMap, - subVisitedOldMap); + subVisitedOldMap, + oldCullingFrame, + newCullingFrame); // If old nodes were not visited, we know that we can delete them // now. They will be removed from the hierarchy by the outermost @@ -1070,8 +1161,10 @@ static void calculateShadowViewMutationsFlattener( mutationContainer.destructiveDownwardMutations, treeChildPair.shadowView.tag, sliceChildShadowNodeViewPairsFromViewNodePair( - treeChildPair, innerScope), - {}); + treeChildPair, innerScope, false, newCullingFrame), + {}, + oldCullingFrame, + newCullingFrame); } } else { mutationContainer.createMutations.push_back( @@ -1085,7 +1178,9 @@ static void calculateShadowViewMutationsFlattener( treeChildPair.shadowView.tag, {}, sliceChildShadowNodeViewPairsFromViewNodePair( - treeChildPair, innerScope)); + treeChildPair, innerScope, false, newCullingFrame), + oldCullingFrame, + newCullingFrame); } } } @@ -1096,7 +1191,9 @@ static void calculateShadowViewMutations( ShadowViewMutation::List& mutations, Tag parentTag, std::vector&& oldChildPairs, - std::vector&& newChildPairs) { + std::vector&& newChildPairs, + Rect oldCullingFrame, + Rect newCullingFrame) { if (oldChildPairs.empty() && newChildPairs.empty()) { return; } @@ -1152,13 +1249,21 @@ static void calculateShadowViewMutations( // Recursively update tree if ShadowNode pointers are not equal if (!oldChildPair.flattened && - oldChildPair.shadowNode != newChildPair.shadowNode) { + (oldChildPair.shadowNode != newChildPair.shadowNode || + oldCullingFrame != newCullingFrame)) { + auto oldCullingFrameCopy = + adjustCullingFrameIfNeeded(oldCullingFrame, oldChildPair); + auto newCullingFrameCopy = + adjustCullingFrameIfNeeded(newCullingFrame, newChildPair); + ViewNodePairScope innerScope{}; auto oldGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( - oldChildPair, innerScope); + oldChildPair, innerScope, false, oldCullingFrameCopy); auto newGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( - newChildPair, innerScope); + newChildPair, innerScope, false, newCullingFrameCopy); + const size_t newGrandChildPairsSize = newGrandChildPairs.size(); + calculateShadowViewMutations( innerScope, *(newGrandChildPairsSize != 0u @@ -1166,7 +1271,9 @@ static void calculateShadowViewMutations( : &mutationContainer.destructiveDownwardMutations), oldChildPair.shadowView.tag, std::move(oldGrandChildPairs), - std::move(newGrandChildPairs)); + std::move(newGrandChildPairs), + oldCullingFrameCopy, + newCullingFrameCopy); } } @@ -1194,6 +1301,8 @@ static void calculateShadowViewMutations( parentTag, oldChildPair.shadowView, static_cast(oldChildPair.mountIndex))); + auto oldCullingFrameCopy = + adjustCullingFrameIfNeeded(oldCullingFrame, oldChildPair); // We also have to call the algorithm recursively to clean up the entire // subtree starting from the removed view. @@ -1203,8 +1312,10 @@ static void calculateShadowViewMutations( mutationContainer.destructiveDownwardMutations, oldChildPair.shadowView.tag, sliceChildShadowNodeViewPairsFromViewNodePair( - oldChildPair, innerScope), - {}); + oldChildPair, innerScope, false, oldCullingFrameCopy), + {}, + oldCullingFrameCopy, + newCullingFrame); } } else if (index == oldChildPairs.size()) { // If we don't have any more existing children we can choose a fast path @@ -1228,6 +1339,8 @@ static void calculateShadowViewMutations( static_cast(newChildPair.mountIndex))); mutationContainer.createMutations.push_back( ShadowViewMutation::CreateMutation(newChildPair.shadowView)); + auto newCullingFrameCopy = + adjustCullingFrameIfNeeded(newCullingFrame, newChildPair); ViewNodePairScope innerScope{}; calculateShadowViewMutations( @@ -1236,7 +1349,9 @@ static void calculateShadowViewMutations( newChildPair.shadowView.tag, {}, sliceChildShadowNodeViewPairsFromViewNodePair( - newChildPair, innerScope)); + newChildPair, innerScope, false, newCullingFrameCopy), + oldCullingFrame, + newCullingFrameCopy); } } else { // Collect map of tags in the new list @@ -1290,7 +1405,9 @@ static void calculateShadowViewMutations( oldChildPairs, parentTag, oldChildPair, - newChildPair); + newChildPair, + oldCullingFrame, + newCullingFrame); newIndex++; oldIndex++; @@ -1334,7 +1451,9 @@ static void calculateShadowViewMutations( oldChildPairs, parentTag, oldChildPair, - newChildPair); + newChildPair, + oldCullingFrame, + newCullingFrame); newInsertedPairs.erase(insertedIt); oldIndex++; @@ -1456,17 +1575,23 @@ static void calculateShadowViewMutations( if (!oldChildPair.inOtherTree() && oldChildPair.isConcreteView) { mutationContainer.deleteMutations.push_back( ShadowViewMutation::DeleteMutation(oldChildPair.shadowView)); + auto oldCullingFrameCopy = + adjustCullingFrameIfNeeded(oldCullingFrame, oldChildPair); // We also have to call the algorithm recursively to clean up the // entire subtree starting from the removed view. ViewNodePairScope innerScope{}; + + auto newGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( + oldChildPair, innerScope, false, oldCullingFrameCopy); calculateShadowViewMutations( innerScope, mutationContainer.destructiveDownwardMutations, oldChildPair.shadowView.tag, - sliceChildShadowNodeViewPairsFromViewNodePair( - oldChildPair, innerScope), - {}); + std::move(newGrandChildPairs), + {}, + oldCullingFrameCopy, + newCullingFrame); } } @@ -1501,14 +1626,20 @@ static void calculateShadowViewMutations( mutationContainer.createMutations.push_back( ShadowViewMutation::CreateMutation(newChildPair.shadowView)); + auto newCullingFrameCopy = + adjustCullingFrameIfNeeded(newCullingFrame, newChildPair); + ViewNodePairScope innerScope{}; + calculateShadowViewMutations( innerScope, mutationContainer.downwardMutations, newChildPair.shadowView.tag, {}, sliceChildShadowNodeViewPairsFromViewNodePair( - newChildPair, innerScope)); + newChildPair, innerScope, false, newCullingFrameCopy), + oldCullingFrame, + newCullingFrameCopy); } } @@ -1567,16 +1698,22 @@ ShadowViewMutation::List calculateShadowViewMutations( oldRootShadowView, newRootShadowView, {})); } + auto sliceOne = sliceChildShadowNodeViewPairs( + ShadowViewNodePair{.shadowNode = &oldRootShadowNode}, + viewNodePairScope, + false, + {}); + auto sliceTwo = sliceChildShadowNodeViewPairs( + ShadowViewNodePair{.shadowNode = &newRootShadowNode}, + viewNodePairScope, + false, + {}); calculateShadowViewMutations( innerViewNodePairScope, mutations, oldRootShadowNode.getTag(), - sliceChildShadowNodeViewPairs( - ShadowViewNodePair{.shadowNode = &oldRootShadowNode}, - viewNodePairScope), - sliceChildShadowNodeViewPairs( - ShadowViewNodePair{.shadowNode = &newRootShadowNode}, - viewNodePairScope)); + std::move(sliceOne), + std::move(sliceTwo)); DEBUG_LOGS({ LOG(ERROR) << "Differ Completed: " << mutations.size() << " mutations"; diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.h b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.h index cf8dcf8cc0d975..e0d304b9cec915 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.h +++ b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.h @@ -95,7 +95,8 @@ ShadowViewMutation::List calculateShadowViewMutations( std::vector sliceChildShadowNodeViewPairs( const ShadowViewNodePair& shadowNodePair, ViewNodePairScope& viewNodePairScope, - bool allowFlattened = false, - Point layoutOffset = {0, 0}); + bool allowFlattened, + Point layoutOffset, + Rect cullingFrame = {}); } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/stubs/stubs.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/stubs/stubs.cpp index 9c3cc7e6882cfd..7a79245e2f820a 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/stubs/stubs.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/stubs/stubs.cpp @@ -60,7 +60,7 @@ static void calculateShadowViewMutationsForNewTree( static_cast(newChildPair->mountIndex))); auto newGrandChildPairs = - sliceChildShadowNodeViewPairs(*newChildPair, scope); + sliceChildShadowNodeViewPairs(*newChildPair, scope, false, {}, {}); calculateShadowViewMutationsForNewTree( mutations, scope, newChildPair->shadowView, newGrandChildPairs); @@ -78,7 +78,7 @@ StubViewTree buildStubViewTreeWithoutUsingDifferentiator( mutations, scope, ShadowView(rootShadowNode), - sliceChildShadowNodeViewPairs(rootShadowNodePair, scope)); + sliceChildShadowNodeViewPairs(rootShadowNodePair, scope, false, {}, {})); auto emptyRootShadowNode = rootShadowNode.clone(ShadowNodeFragment{ ShadowNodeFragment::propsPlaceholder(),