From f81f73e5a02e792cfaf2c010dea2ddfb20cb3e7f Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Tue, 3 Mar 2026 06:49:29 -0800 Subject: [PATCH] Add ViewTransitionModule for enter/exit/share transitions (#55752) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/55752 Adds `ViewTransitionModule` - the native implementation of `UIManagerViewTransitionDelegate` that tracks view transition state and orchestrates enter/exit/share transitions. The module: - Captures layout metrics from root for participating views via `captureLayoutMetricsFromRoot` - Manages view-transition-name registration (`applyViewTransitionName`, `cancelViewTransitionName`, `restoreViewTransitionName`) - Detects transition type (enter/exit/share) based on old/new layout snapshots - Orchestrates transition lifecycle via `startViewTransition` Scheduler initializes the module when `viewTransitionEnabled` feature flag is enabled. ## Changelog: [General] [Added] - ViewTransitionModule for React Native View Transitions Reviewed By: sammy-SC Differential Revision: D92537219 fbshipit-source-id: c6772394efaf6aaedbf4991b951d2eb03eaf2fba --- packages/react-native/Package.swift | 2 +- .../ReactAndroid/src/main/jni/CMakeLists.txt | 3 + .../ReactCommon/React-Fabric.podspec | 6 + .../react/renderer/scheduler/CMakeLists.txt | 1 + .../react/renderer/scheduler/Scheduler.cpp | 8 + .../react/renderer/scheduler/Scheduler.h | 3 + .../renderer/uimanager/UIManagerBinding.cpp | 8 +- .../UIManagerViewTransitionDelegate.h | 12 +- .../renderer/viewtransition/CMakeLists.txt | 22 +++ .../viewtransition/ViewTransitionModule.cpp | 158 ++++++++++++++++++ .../viewtransition/ViewTransitionModule.h | 84 ++++++++++ .../scripts/ios-prebuild/headers-config.js | 6 + 12 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 packages/react-native/ReactCommon/react/renderer/viewtransition/CMakeLists.txt create mode 100644 packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.h diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index 2f1dac130e5..852fd7388f9 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -456,7 +456,7 @@ let reactFabric = RNTarget( "components/root/tests", ], dependencies: [.reactNativeDependencies, .reactJsiExecutor, .rctTypesafety, .reactTurboModuleCore, .jsi, .logger, .reactDebug, .reactFeatureFlags, .reactUtils, .reactRuntimeScheduler, .reactCxxReact, .reactRendererDebug, .reactGraphics, .yoga], - sources: ["animationbackend", "animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/view/platform/cxx", "components/scrollview", "components/scrollview/platform/cxx", "components/scrollview/platform/ios", "components/legacyviewmanagerinterop", "components/legacyviewmanagerinterop/platform/ios", "dom", "scheduler", "mounting", "observers/events", "observers/intersection", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency"] + sources: ["animationbackend", "animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/view/platform/cxx", "components/scrollview", "components/scrollview/platform/cxx", "components/scrollview/platform/ios", "components/legacyviewmanagerinterop", "components/legacyviewmanagerinterop/platform/ios", "dom", "scheduler", "mounting", "observers/events", "observers/intersection", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency", "viewtransition"] ) let reactFabricInputAccessory = RNTarget( diff --git a/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt index 979ec533075..21e895e7835 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt @@ -89,6 +89,7 @@ add_react_common_subdir(react/renderer/componentregistry) add_react_common_subdir(react/renderer/mounting) add_react_common_subdir(react/renderer/scheduler) add_react_common_subdir(react/renderer/telemetry) +add_react_common_subdir(react/renderer/viewtransition) add_react_common_subdir(react/renderer/uimanager) add_react_common_subdir(react/renderer/bridging) add_react_common_subdir(react/renderer/core) @@ -223,6 +224,7 @@ add_library(reactnative $ $ $ + $ $ $ $ @@ -318,6 +320,7 @@ target_include_directories(reactnative $ $ $ + $ $ $ $ diff --git a/packages/react-native/ReactCommon/React-Fabric.podspec b/packages/react-native/ReactCommon/React-Fabric.podspec index fe7534f089c..56fc9d3dbc7 100644 --- a/packages/react-native/ReactCommon/React-Fabric.podspec +++ b/packages/react-native/ReactCommon/React-Fabric.podspec @@ -161,6 +161,7 @@ Pod::Spec.new do |s| ss.header_dir = "react/renderer/scheduler" ss.dependency "React-Fabric/animationbackend" + ss.dependency "React-Fabric/viewtransition" ss.dependency "React-performancecdpmetrics" ss.dependency "React-performancetimeline" ss.dependency "React-Fabric/observers/events" @@ -220,4 +221,9 @@ Pod::Spec.new do |s| ss.header_dir = "react/renderer/leakchecker" ss.pod_target_xcconfig = { "GCC_WARN_PEDANTIC" => "YES" } end + + s.subspec "viewtransition" do |ss| + ss.source_files = podspec_sources("react/renderer/viewtransition/**/*.{m,mm,cpp,h}", "react/renderer/viewtransition/**/*.h") + ss.header_dir = "react/renderer/viewtransition" + end end diff --git a/packages/react-native/ReactCommon/react/renderer/scheduler/CMakeLists.txt b/packages/react-native/ReactCommon/react/renderer/scheduler/CMakeLists.txt index b39e940016a..e979712b3c4 100644 --- a/packages/react-native/ReactCommon/react/renderer/scheduler/CMakeLists.txt +++ b/packages/react-native/ReactCommon/react/renderer/scheduler/CMakeLists.txt @@ -30,6 +30,7 @@ target_link_libraries(react_renderer_scheduler react_renderer_observers_events react_renderer_runtimescheduler react_renderer_uimanager + react_renderer_viewtransition react_utils rrc_root rrc_view diff --git a/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.cpp b/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.cpp index dccc147345f..a0cf4e16089 100644 --- a/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.cpp +++ b/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.cpp @@ -157,6 +157,13 @@ Scheduler::Scheduler( } uiManager_->setAnimationDelegate(animationDelegate); + // Initialize ViewTransitionModule + if (ReactNativeFeatureFlags::viewTransitionEnabled()) { + viewTransitionModule_ = std::make_unique(); + viewTransitionModule_->setUIManager(uiManager_.get()); + uiManager_->setViewTransitionDelegate(viewTransitionModule_.get()); + } + uiManager->registerMountHook(*eventPerformanceLogger_); } @@ -186,6 +193,7 @@ Scheduler::~Scheduler() { // The thread-safety of this operation is guaranteed by this requirement. uiManager_->setDelegate(nullptr); uiManager_->setAnimationDelegate(nullptr); + uiManager_->setViewTransitionDelegate(nullptr); if (cdpMetricsReporter_) { performanceEntryReporter_->removeEventListener(&*cdpMetricsReporter_); diff --git a/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.h b/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.h index 080c5407c13..ad16e3e4087 100644 --- a/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.h +++ b/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.h @@ -27,6 +27,7 @@ #include #include #include +#include #include namespace facebook::react { @@ -146,6 +147,8 @@ class Scheduler final : public UIManagerDelegate { RuntimeScheduler *runtimeScheduler_{nullptr}; + std::unique_ptr viewTransitionModule_; + mutable std::shared_mutex onSurfaceStartCallbackMutex_; OnSurfaceStartCallback onSurfaceStartCallback_; }; diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index b1d81babf66..d8b41c200e3 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp @@ -925,7 +925,7 @@ jsi::Value UIManagerBinding::get( auto* viewTransitionDelegate = uiManager->getViewTransitionDelegate(); if (viewTransitionDelegate != nullptr) { - viewTransitionDelegate->captureLayoutMetricsFromRoot(shadowNode); + viewTransitionDelegate->captureLayoutMetricsFromRoot(*shadowNode); } return result; @@ -960,7 +960,7 @@ jsi::Value UIManagerBinding::get( uiManager->getViewTransitionDelegate(); if (viewTransitionDelegate != nullptr) { viewTransitionDelegate->applyViewTransitionName( - shadowNode, transitionName, className); + *shadowNode, transitionName, className); } } } @@ -994,7 +994,7 @@ jsi::Value UIManagerBinding::get( uiManager->getViewTransitionDelegate(); if (viewTransitionDelegate != nullptr) { viewTransitionDelegate->cancelViewTransitionName( - shadowNode, transitionName); + *shadowNode, transitionName); } } } @@ -1023,7 +1023,7 @@ jsi::Value UIManagerBinding::get( auto* viewTransitionDelegate = uiManager->getViewTransitionDelegate(); if (viewTransitionDelegate != nullptr) { - viewTransitionDelegate->restoreViewTransitionName(shadowNode); + viewTransitionDelegate->restoreViewTransitionName(*shadowNode); } } diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerViewTransitionDelegate.h b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerViewTransitionDelegate.h index 41358428c01..8df388b57b9 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerViewTransitionDelegate.h +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerViewTransitionDelegate.h @@ -16,18 +16,16 @@ class UIManagerViewTransitionDelegate { public: virtual ~UIManagerViewTransitionDelegate() = default; - virtual void applyViewTransitionName( - const std::shared_ptr &shadowNode, - const std::string &name, - const std::string &className) + virtual void + applyViewTransitionName(const ShadowNode &shadowNode, const std::string &name, const std::string &className) { } - virtual void cancelViewTransitionName(const std::shared_ptr &shadowNode, const std::string &name) {} + virtual void cancelViewTransitionName(const ShadowNode &shadowNode, const std::string &name) {} - virtual void restoreViewTransitionName(const std::shared_ptr &shadowNode) {} + virtual void restoreViewTransitionName(const ShadowNode &shadowNode) {} - virtual void captureLayoutMetricsFromRoot(const std::shared_ptr &shadowNode) {} + virtual void captureLayoutMetricsFromRoot(const ShadowNode &shadowNode) {} virtual void startViewTransition( std::function mutationCallback, diff --git a/packages/react-native/ReactCommon/react/renderer/viewtransition/CMakeLists.txt b/packages/react-native/ReactCommon/react/renderer/viewtransition/CMakeLists.txt new file mode 100644 index 00000000000..901f82ae5c6 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/viewtransition/CMakeLists.txt @@ -0,0 +1,22 @@ +# 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. + +cmake_minimum_required(VERSION 3.13) +set(CMAKE_VERBOSE_MAKEFILE on) + +include(${REACT_COMMON_DIR}/cmake-utils/react-native-flags.cmake) + +file(GLOB react_renderer_viewtransition_SRC CONFIGURE_DEPENDS *.cpp) +add_library(react_renderer_viewtransition STATIC ${react_renderer_viewtransition_SRC}) + +target_include_directories(react_renderer_viewtransition PUBLIC ${REACT_COMMON_DIR}) + +target_link_libraries(react_renderer_viewtransition + glog + react_renderer_core + react_renderer_mounting + react_renderer_uimanager +) +target_compile_reactnative_options(react_renderer_viewtransition PRIVATE) diff --git a/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.cpp b/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.cpp new file mode 100644 index 00000000000..ec1e0dba67c --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.cpp @@ -0,0 +1,158 @@ +/* + * 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. + */ + +#include "ViewTransitionModule.h" + +#include + +#include +#include + +namespace facebook::react { + +void ViewTransitionModule::setUIManager(UIManager* uiManager) { + uiManager_ = uiManager; +} + +void ViewTransitionModule::applyViewTransitionName( + const ShadowNode& shadowNode, + const std::string& name, + const std::string& /*className*/) { + auto tag = shadowNode.getTag(); + auto surfaceId = shadowNode.getSurfaceId(); + + // Look up the captured layout metrics for this shadowNode + auto metricsIt = capturedLayoutMetricsFromRoot_.find(tag); + if (metricsIt == capturedLayoutMetricsFromRoot_.end()) { + // No measurement captured yet, nothing to do + return; + } + + const auto& layoutMetrics = metricsIt->second; + + // Convert LayoutMetrics to AnimationKeyFrameViewLayoutMetrics + AnimationKeyFrameViewLayoutMetrics keyframeMetrics{ + .originFromRoot = layoutMetrics.frame.origin, + .size = layoutMetrics.frame.size, + .pointScaleFactor = layoutMetrics.pointScaleFactor}; + + nameRegistry_[tag].insert(name); + + // If applyViewTransitionName is called after transition started, this is the + // "new" state (end snapshot). Otherwise, this is the "old" state (start + // snapshot) + if (!transitionStarted_) { + AnimationKeyFrameView oldView{ + .layoutMetrics = keyframeMetrics, .tag = tag, .surfaceId = surfaceId}; + oldLayout_[name] = oldView; + } else { + AnimationKeyFrameView newView{ + .layoutMetrics = keyframeMetrics, .tag = tag, .surfaceId = surfaceId}; + newLayout_[name] = newView; + } + + capturedLayoutMetricsFromRoot_.erase(tag); +} + +void ViewTransitionModule::cancelViewTransitionName( + const ShadowNode& shadowNode, + const std::string& name) { + oldLayout_.erase(name); + newLayout_.erase(name); + cancelledNameRegistry_[shadowNode.getTag()].insert(name); +} + +void ViewTransitionModule::restoreViewTransitionName( + const ShadowNode& shadowNode) { + nameRegistry_[shadowNode.getTag()].merge( + cancelledNameRegistry_[shadowNode.getTag()]); + cancelledNameRegistry_.erase(shadowNode.getTag()); +} + +void ViewTransitionModule::captureLayoutMetricsFromRoot( + const ShadowNode& shadowNode) { + if (uiManager_ == nullptr) { + return; + } + + // Get the current revision (root node) for this surface + auto currentRevision = + uiManager_->getShadowTreeRevisionProvider()->getCurrentRevision( + shadowNode.getSurfaceId()); + + if (currentRevision == nullptr) { + return; + } + + // Cast root to LayoutableShadowNode + auto layoutableRoot = + dynamic_cast(currentRevision.get()); + if (layoutableRoot == nullptr) { + return; + } + + // Compute layout metrics from root + auto layoutMetrics = LayoutableShadowNode::computeLayoutMetricsFromRoot( + shadowNode.getFamily(), *layoutableRoot, {}); + + // Store the layout metrics keyed by tag + capturedLayoutMetricsFromRoot_[shadowNode.getTag()] = layoutMetrics; +} + +void ViewTransitionModule::startViewTransition( + std::function mutationCallback, + std::function onReadyCallback, + std::function onCompleteCallback) { + // Mark transition as started + transitionStarted_ = true; + + // Call mutation callback (including commitRoot, measureInstance, + // applyViewTransitionName for old & new) + if (mutationCallback) { + mutationCallback(); + } + + // TODO: capture pseudo elements + + if (onReadyCallback) { + onReadyCallback(); + } + + // Transition animation starts + + // Call onComplete callback when transition finishes + if (onCompleteCallback) { + onCompleteCallback(); + } +} + +void ViewTransitionModule::startViewTransitionEnd() { + for (const auto& it : nameRegistry_) { + onTransitionAnimationEnd(it.second, it.first, 0); + } + + transitionStarted_ = false; +} + +void ViewTransitionModule::onTransitionAnimationEnd( + const std::unordered_set& names, + Tag newTag, + Tag oldTag) { + for (const auto& name : names) { + oldLayout_.erase(name); + newLayout_.erase(name); + } + + if (newTag != 0) { + nameRegistry_.erase(newTag); + } + if (oldTag != 0) { + nameRegistry_.erase(oldTag); + } +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.h b/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.h new file mode 100644 index 00000000000..1efa8c65002 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.h @@ -0,0 +1,84 @@ +/* + * 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. + */ + +#pragma once + +#include + +#include +#include +#include +#include + +namespace facebook::react { + +class UIManager; + +class ViewTransitionModule : public UIManagerViewTransitionDelegate { + public: + ~ViewTransitionModule() override = default; + + void setUIManager(UIManager *uiManager); + + // will be called when a view will transition. if a view already has a view-transition-name, it may not be called + // again until it's removed + void applyViewTransitionName(const ShadowNode &shadowNode, const std::string &name, const std::string &className) + override; + + // if a viewTransitionName is cancelled, the element doesn't have view-transition-name and browser won't be taking + // snapshot + void cancelViewTransitionName(const ShadowNode &shadowNode, const std::string &name) override; + + // restore cancellation + void restoreViewTransitionName(const ShadowNode &shadowNode) override; + + void captureLayoutMetricsFromRoot(const ShadowNode &shadowNode) override; + + void startViewTransition( + std::function mutationCallback, + std::function onReadyCallback, + std::function onCompleteCallback) override; + + void startViewTransitionEnd() override; + + // Animation state structure for storing minimal view data + struct AnimationKeyFrameViewLayoutMetrics { + Point originFromRoot; + Size size; + Float pointScaleFactor{}; + }; + + struct AnimationKeyFrameView { + AnimationKeyFrameViewLayoutMetrics layoutMetrics; + Tag tag{0}; + SurfaceId surfaceId{0}; + }; + + private: + void onTransitionAnimationEnd(const std::unordered_set &names, Tag newTag, Tag oldTag); + + // registry of layout of old/new views + std::unordered_map oldLayout_{}; + std::unordered_map newLayout_{}; + // temporary registry of measured layout metrics keyed by tag + std::unordered_map capturedLayoutMetricsFromRoot_{}; + + // tag -> names registry, populated during applyViewTransitionName + // Note that tag and name are not 1:1 mapping + // - In some nested composition 2 names are mappped to the same tag + // - tags of old and new views are mapped to the same name(s) + std::unordered_map> nameRegistry_{}; + + // used for cancel/restore viewTransitionName + std::unordered_map> cancelledNameRegistry_{}; + + UIManager *uiManager_{nullptr}; + + bool transitionStarted_{false}; +}; + +} // namespace facebook::react diff --git a/packages/react-native/scripts/ios-prebuild/headers-config.js b/packages/react-native/scripts/ios-prebuild/headers-config.js index f5e8b1d7c13..0d57d019e6e 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-config.js +++ b/packages/react-native/scripts/ios-prebuild/headers-config.js @@ -215,6 +215,12 @@ const PodspecExceptions /*: {[key: string]: PodSpecConfiguration} */ = { excludePatterns: ['react/renderer/leakchecker/tests'], headerDir: 'react/renderer/leakchecker', }, + + { + name: 'viewtransition', + headerPatterns: ['react/renderer/viewtransition/**/*.h'], + headerDir: 'react/renderer/viewtransition', + }, ], }, // Yoga should preserve its directory structure