diff --git a/FabricExample/App.tsx b/FabricExample/App.tsx index aeff08afb8..3f9928f546 100644 --- a/FabricExample/App.tsx +++ b/FabricExample/App.tsx @@ -4,5 +4,6 @@ import { featureFlags } from '../src'; featureFlags.experiment.synchronousScreenUpdatesEnabled = false featureFlags.experiment.synchronousHeaderConfigUpdatesEnabled = false featureFlags.experiment.synchronousHeaderSubviewUpdatesEnabled = false +featureFlags.experiment.androidResetScreenShadowStateOnOrientationChangeEnabled = true export default App; diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt index 67ae075bee..7bb4f41246 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt @@ -342,6 +342,11 @@ open class ScreenViewManager : // END mark: iOS-only + override fun setAndroidResetScreenShadowStateOnOrientationChangeEnabled( + view: Screen?, + value: Boolean, + ) = Unit // represents a feature flag and is checked via getProps() in RNSScreenComponentDescriptor.h + @ReactProp(name = "sheetAllowedDetents") override fun setSheetAllowedDetents( view: Screen, diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerDelegate.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerDelegate.java index c33cb6e318..74665eca8a 100644 --- a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerDelegate.java +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerDelegate.java @@ -137,6 +137,9 @@ public void setProperty(T view, String propName, @Nullable Object value) { case "synchronousShadowStateUpdatesEnabled": mViewManager.setSynchronousShadowStateUpdatesEnabled(view, value == null ? false : (boolean) value); break; + case "androidResetScreenShadowStateOnOrientationChangeEnabled": + mViewManager.setAndroidResetScreenShadowStateOnOrientationChangeEnabled(view, value == null ? true : (boolean) value); + break; default: super.setProperty(view, propName, value); } diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerInterface.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerInterface.java index beb5ef140d..d1167fa46c 100644 --- a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerInterface.java +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerInterface.java @@ -53,4 +53,5 @@ public interface RNSScreenManagerInterface { void setRightScrollEdgeEffect(T view, @Nullable String value); void setTopScrollEdgeEffect(T view, @Nullable String value); void setSynchronousShadowStateUpdatesEnabled(T view, boolean value); + void setAndroidResetScreenShadowStateOnOrientationChangeEnabled(T view, boolean value); } diff --git a/apps/src/tests/Test2933.tsx b/apps/src/tests/Test2933.tsx new file mode 100644 index 0000000000..220e068b91 --- /dev/null +++ b/apps/src/tests/Test2933.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useState } from 'react'; +import type { PropsWithChildren } from 'react'; +import { + StyleSheet, + Text, + useColorScheme, + View, +} from 'react-native'; + +import { Screen, ScreenStack } from 'react-native-screens'; + +type SectionProps = PropsWithChildren<{ + title: string; +}>; + +function Section({ children, title }: SectionProps): React.JSX.Element { + return ( + + {title} + {children} + + ); +} + +function floodJsThread() { + setInterval(() => { + const end = Date.now() + 10; + while (Date.now() < end) { + // Intentionally do nothing; just burn CPU cycles. + Math.sqrt(Math.random()); + } + }, 12); +} + +/* + * create artificial pressure in the JS thread to show off thep problem. + * */ +floodJsThread(); +floodJsThread(); + +function AppMain(): React.JSX.Element { + const isDarkMode = useColorScheme() === 'dark'; + + const backgroundStyle = { + flex: 1, + backgroundColor: 'white', + }; + + const [num, setNum] = useState(0); + + useEffect(() => { + let i = 0; + setInterval(() => { + i++; + setNum(i); + }, 2) + }, []); + + /* + * To keep the template simple and small we're adding padding to prevent view + * from rendering under the System UI. + * For bigger apps the reccomendation is to use `react-native-safe-area-context`: + * https://github.com/AppAndFlow/react-native-safe-area-context + * + * You can read more about it here: + * https://github.com/react-native-community/discussions-and-proposals/discussions/827 + */ + const safePadding = '5%'; + + return ( + + {/* // TODO nowy task + */} + +
+ This test shows how the native layout update triggers a layout shift. +
+
+ There is a view with a blue background. We don't expect to ever see + flashes of the blue background. +
+
{num}
+
+
+ ); +} + +const styles = StyleSheet.create({ + sectionContainer: { + marginTop: 32, + paddingHorizontal: 24, + }, + sectionTitle: { + fontSize: 24, + fontWeight: '600', + }, + sectionDescription: { + marginTop: 8, + fontSize: 18, + fontWeight: '400', + }, + highlight: { + fontWeight: '700', + }, +}); + +function App() { + return ( + + + + + + ); +} + +export default App; diff --git a/apps/src/tests/index.ts b/apps/src/tests/index.ts index dc8b83a7ce..2020552747 100644 --- a/apps/src/tests/index.ts +++ b/apps/src/tests/index.ts @@ -138,6 +138,7 @@ export { default as Test2855 } from './Test2855'; export { default as Test2877 } from './Test2877'; // [E2E created](iOS): issue is related to formSheet on iOS export { default as Test2895 } from './Test2895'; export { default as Test2899 } from './Test2899'; +export { default as Test2933 } from './Test2933'; export { default as Test2926 } from './Test2926'; // [E2E created](iOS): PR related to iOS search bar export { default as Test2949 } from './Test2949'; // [E2E skipped]: can't check system bars styles export { default as Test2963 } from './Test2963'; // [E2E created](iOS): issue related to iOS diff --git a/common/cpp/react/renderer/components/rnscreens/RNSScreenComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSScreenComponentDescriptor.h index 3d9b270f67..2389f94a28 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSScreenComponentDescriptor.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSScreenComponentDescriptor.h @@ -2,10 +2,13 @@ #ifdef ANDROID #include +#include +#include "RNSScreenShadowNodeCommitHook.h" #endif // ANDROID #include #include #include +#include #include #include "RNSScreenShadowNode.h" @@ -16,6 +19,16 @@ using namespace rnscreens; class RNSScreenComponentDescriptor final : public ConcreteComponentDescriptor { + private: +#ifdef ANDROID + /* + * A commit hook that triggers on `shadowTreeWillCommit` event, + * and can read the properties of RootShadowNodes for determining screen + * orientation. + */ + mutable std::shared_ptr commitHook_; +#endif // ANDROID + public: using ConcreteComponentDescriptor::ConcreteComponentDescriptor; @@ -26,14 +39,34 @@ class RNSScreenComponentDescriptor final react_native_assert( dynamic_cast(&screenShadowNode)); auto &layoutableShadowNode = - dynamic_cast(screenShadowNode); + static_cast(screenShadowNode); auto state = std::static_pointer_cast( shadowNode.getState()); auto stateData = state->getData(); - #ifdef ANDROID + // get + // featureFlags.experiment.androidResetScreenShadowStateOnOrientationChange + // from Screen props enable the commit hook only when the developer asks to + react_native_assert( + dynamic_cast( + screenShadowNode.getProps().get())); + auto props = + static_cast(screenShadowNode.getProps().get()); + + if (!commitHook_ && + props->androidResetScreenShadowStateOnOrientationChangeEnabled) { + // For the the application that needs to react to orientation change + // as early as possible, we attach a commit hook that checks for the + // change in the old vs new RootShadowNode. The hook cannot be attached in + // the constructor because UIManager is still missing from + // ContextContainer. Instead, we do it here, on the first call to the + // function. + commitHook_ = + std::make_shared(contextContainer_); + } + if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) { // When we receive dimensions from JVM side we can remove padding used for // correction, and we can stop applying height and offset corrections for @@ -66,9 +99,15 @@ class RNSScreenComponentDescriptor final FrameCorrectionModes::Mode::FrameHeightCorrection); screenShadowNode.getFrameCorrectionModes().unset( FrameCorrectionModes::Mode::FrameOriginCorrection); - layoutableShadowNode.setSize( Size{stateData.frameSize.width, stateData.frameSize.height}); + } else if ( + stateData.frameSize.width == 0 && stateData.frameSize.height == 0) { + // Reset YogaNode so it recalculates its layout. Useful for the case + // when native orientation changes and react has not been notified yet. + // The if condition holds true on first render and when it is reset inside + // RNSScreenShadowNodeCommitHook. + layoutableShadowNode.setSize({YGUndefined, YGUndefined}); } #else if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) { diff --git a/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.cpp index add2c75c59..e69df80b12 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.cpp @@ -155,6 +155,10 @@ FrameCorrectionModes &RNSScreenShadowNode::getFrameCorrectionModes() { return getStateDataMutable().getFrameCorrectionModes(); } +void RNSScreenShadowNode::resetFrameSizeState() { + getStateDataMutable().frameSize = {0, 0}; +} + RNSScreenShadowNode::StateData &RNSScreenShadowNode::getStateDataMutable() { // We assume that this method is called to mutate the data, so we ensure // we're unsealed. diff --git a/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.h index 3379f89f24..93c9f4ba6c 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.h @@ -38,8 +38,10 @@ class JSI_EXPORT RNSScreenShadowNode final : public ConcreteViewShadowNode< FrameCorrectionModes &getFrameCorrectionModes(); - private: #ifdef ANDROID + void resetFrameSizeState(); + + private: void applyFrameCorrections(); StateData &getStateDataMutable(); diff --git a/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNodeCommitHook.cpp b/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNodeCommitHook.cpp new file mode 100644 index 0000000000..207e78ec73 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNodeCommitHook.cpp @@ -0,0 +1,103 @@ +#ifdef ANDROID + +#include "RNSScreenShadowNodeCommitHook.h" +#include + +namespace facebook { +namespace react { + +RNSScreenShadowNodeCommitHook::RNSScreenShadowNodeCommitHook( + std::shared_ptr contextContainer) + : contextContainer_(contextContainer) { + getUIManagerFromSharedContext(contextContainer)->registerCommitHook(*this); +} + +RNSScreenShadowNodeCommitHook::~RNSScreenShadowNodeCommitHook() noexcept { + const auto contextContainer = contextContainer_.lock(); + if (contextContainer) { + getUIManagerFromSharedContext(contextContainer) + ->unregisterCommitHook(*this); + } +} + +RootShadowNode::Unshared RNSScreenShadowNodeCommitHook::shadowTreeWillCommit( + const facebook::react::ShadowTree &shadowTree, + const RootShadowNode::Shared &oldRootShadowNode, + const RootShadowNode::Unshared &newRootShadowNode, + const ShadowTreeCommitOptions &) noexcept { + auto oldRootProps = + std::static_pointer_cast(oldRootShadowNode->getProps()); + auto newRootProps = + std::static_pointer_cast(newRootShadowNode->getProps()); + + const bool wasHorizontal = isHorizontal_(*oldRootProps.get()); + const bool willBeHorizontal = isHorizontal_(*newRootProps.get()); + + if (wasHorizontal != willBeHorizontal) { + return newRootShadowNodeWithScreenFrameSizesReset(newRootShadowNode); + } + + return newRootShadowNode; +} + +RootShadowNode::Unshared +RNSScreenShadowNodeCommitHook::newRootShadowNodeWithScreenFrameSizesReset( + RootShadowNode::Unshared rootShadowNode) { + std::vector screens; + findScreenNodes(rootShadowNode, screens); + + for (auto screen : screens) { + const auto rootShadowNodeClone = rootShadowNode->cloneTree( + screen->getFamily(), [](const ShadowNode &oldShadowNode) { + auto clone = oldShadowNode.clone({.state = oldShadowNode.getState()}); + auto screenNode = static_pointer_cast(clone); + auto yogaNode = static_pointer_cast(clone); + + screenNode->resetFrameSizeState(); + yogaNode->setSize({YGUndefined, YGUndefined}); + + return clone; + }); + + if (rootShadowNodeClone) { + rootShadowNode = + std::static_pointer_cast(rootShadowNodeClone); + } + } + + return rootShadowNode; +} + +void RNSScreenShadowNodeCommitHook::findScreenNodes( + const std::shared_ptr &rootShadowNode, + std::vector &screenNodes) { + std::stack shadowNodesToVisit; + shadowNodesToVisit.emplace(rootShadowNode.get()); + + while (!shadowNodesToVisit.empty()) { + auto node = shadowNodesToVisit.top(); + shadowNodesToVisit.pop(); + + for (auto const &child : node->getChildren()) { + if (node->getComponentHandle() == RNSScreenShadowNode::Handle()) { + screenNodes.push_back(static_cast(node)); + } + shadowNodesToVisit.emplace(child.get()); + } + } +} + +std::shared_ptr +RNSScreenShadowNodeCommitHook::getUIManagerFromSharedContext( + std::shared_ptr sharedContext) { + auto fabricUIManager = + sharedContext + ->at>( + "FabricUIManager"); + return fabricUIManager->getBinding()->getScheduler()->getUIManager(); +} + +} // namespace react +} // namespace facebook + +#endif // ANDROID diff --git a/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNodeCommitHook.h b/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNodeCommitHook.h new file mode 100644 index 0000000000..905a18efa4 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNodeCommitHook.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include +#include "RNSScreenShadowNode.h" + +namespace facebook { +namespace react { + +class RNSScreenComponentDescriptor; + +class RNSScreenShadowNodeCommitHook : public UIManagerCommitHook { + public: + RNSScreenShadowNodeCommitHook(std::shared_ptr); + + virtual ~RNSScreenShadowNodeCommitHook() noexcept override; + + virtual void commitHookWasRegistered(const UIManager &) noexcept override {} + + virtual void commitHookWasUnregistered(const UIManager &) noexcept override {} + + virtual RootShadowNode::Unshared shadowTreeWillCommit( + const ShadowTree &shadowTree, + const RootShadowNode::Shared &oldRootShadowNode, + const RootShadowNode::Unshared &newRootShadowNode, + const ShadowTreeCommitOptions & /*commitOptions*/) noexcept override; + + private: + std::weak_ptr contextContainer_; + + static inline bool isHorizontal_(const RootProps &props) { + const auto &layoutConstraints = props.layoutConstraints; + const float width = layoutConstraints.maximumSize.width; + const float height = layoutConstraints.maximumSize.height; + + return width > height; + }; + + static RootShadowNode::Unshared newRootShadowNodeWithScreenFrameSizesReset( + RootShadowNode::Unshared rootShadowNode); + + static void findScreenNodes( + const std::shared_ptr &rootShadowNode, + std::vector &screenNodes); + + static std::shared_ptr getUIManagerFromSharedContext( + std::shared_ptr sharedContext); +}; + +} // namespace react +} // namespace facebook diff --git a/common/cpp/react/renderer/components/rnscreens/RNSScreenState.h b/common/cpp/react/renderer/components/rnscreens/RNSScreenState.h index 0c88117081..4d4dfbb645 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSScreenState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSScreenState.h @@ -38,7 +38,7 @@ class JSI_EXPORT RNSScreenState final { headerCorrectionModes_{previousState.headerCorrectionModes_} {}; #endif - const Size frameSize{}; + Size frameSize{}; Point contentOffset; #ifdef ANDROID diff --git a/src/components/Screen.tsx b/src/components/Screen.tsx index 1fb3a3745b..ff848d5ca7 100644 --- a/src/components/Screen.tsx +++ b/src/components/Screen.tsx @@ -267,6 +267,10 @@ export const InnerScreen = React.forwardRef( topScrollEdgeEffect={scrollEdgeEffects?.top} synchronousShadowStateUpdatesEnabled={ featureFlags.experiment.synchronousScreenUpdatesEnabled + } + androidResetScreenShadowStateOnOrientationChangeEnabled={ + featureFlags.experiment + .androidResetScreenShadowStateOnOrientationChangeEnabled }> {!isNativeStack ? ( // see comment of this prop in types.tsx for information why it is needed children diff --git a/src/fabric/ScreenNativeComponent.ts b/src/fabric/ScreenNativeComponent.ts index 6186b34b07..0f06ede537 100644 --- a/src/fabric/ScreenNativeComponent.ts +++ b/src/fabric/ScreenNativeComponent.ts @@ -120,6 +120,10 @@ export interface NativeProps extends ViewProps { rightScrollEdgeEffect?: WithDefault; topScrollEdgeEffect?: WithDefault; synchronousShadowStateUpdatesEnabled?: WithDefault; + androidResetScreenShadowStateOnOrientationChangeEnabled?: WithDefault< + boolean, + true + >; } export default codegenNativeComponent('RNSScreen', { diff --git a/src/flags.ts b/src/flags.ts index f904883557..6be766cd02 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -2,6 +2,8 @@ const RNS_CONTROLLED_BOTTOM_TABS_DEFAULT = true; const RNS_SYNCHRONOUS_SCREEN_STATE_UPDATES_DEFAULT = false; const RNS_SYNCHRONOUS_HEADER_CONFIG_STATE_UPDATES_DEFAULT = false; const RNS_SYNCHRONOUS_HEADER_SUBVIEW_STATE_UPDATES_DEFAULT = false; +const RNS_ANDROID_RESET_SCREEN_SHADOW_STATE_ON_ORIENTATION_CHANGE_DEFAULT = + true; // TODO: Migrate freeze here @@ -42,6 +44,8 @@ const _featureFlags = { RNS_SYNCHRONOUS_HEADER_CONFIG_STATE_UPDATES_DEFAULT, synchronousHeaderSubviewUpdatesEnabled: RNS_SYNCHRONOUS_HEADER_SUBVIEW_STATE_UPDATES_DEFAULT, + androidResetScreenShadowStateOnOrientationChangeEnabled: + RNS_ANDROID_RESET_SCREEN_SHADOW_STATE_ON_ORIENTATION_CHANGE_DEFAULT, }, stable: {}, }; @@ -88,6 +92,11 @@ const synchronousHeaderSubviewUpdatesAccessor = 'synchronousHeaderSubviewUpdatesEnabled', RNS_SYNCHRONOUS_HEADER_SUBVIEW_STATE_UPDATES_DEFAULT, ); +const androidResetScreenShadowStateOnOrientationChangeAccessor = + createExperimentalFeatureFlagAccessor( + 'androidResetScreenShadowStateOnOrientationChangeEnabled', + RNS_ANDROID_RESET_SCREEN_SHADOW_STATE_ON_ORIENTATION_CHANGE_DEFAULT, + ); /** * Exposes configurable global behaviour of the library. @@ -123,6 +132,14 @@ export const featureFlags = { set synchronousHeaderSubviewUpdatesEnabled(value: boolean) { synchronousHeaderSubviewUpdatesAccessor.set(value); }, + get androidResetScreenShadowStateOnOrientationChangeEnabled() { + return androidResetScreenShadowStateOnOrientationChangeAccessor.get(); + }, + set androidResetScreenShadowStateOnOrientationChangeEnabled( + value: boolean, + ) { + androidResetScreenShadowStateOnOrientationChangeAccessor.set(value); + }, }, /** * Section for stable flags, which can be used to configure library behaviour.