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
This commit is contained in:
Zeya Peng
2026-03-03 06:49:29 -08:00
committed by meta-codesync[bot]
parent cdad57d8f1
commit f81f73e5a0
12 changed files with 301 additions and 12 deletions
+1 -1
View File
@@ -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(
@@ -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
$<TARGET_OBJECTS:react_renderer_textlayoutmanager>
$<TARGET_OBJECTS:react_renderer_uimanager>
$<TARGET_OBJECTS:react_renderer_uimanager_consistency>
$<TARGET_OBJECTS:react_renderer_viewtransition>
$<TARGET_OBJECTS:react_utils>
$<TARGET_OBJECTS:reactnativeblob>
$<TARGET_OBJECTS:reactnativejni>
@@ -318,6 +320,7 @@ target_include_directories(reactnative
$<TARGET_PROPERTY:react_renderer_textlayoutmanager,INTERFACE_INCLUDE_DIRECTORIES>
$<TARGET_PROPERTY:react_renderer_uimanager,INTERFACE_INCLUDE_DIRECTORIES>
$<TARGET_PROPERTY:react_renderer_uimanager_consistency,INTERFACE_INCLUDE_DIRECTORIES>
$<TARGET_PROPERTY:react_renderer_viewtransition,INTERFACE_INCLUDE_DIRECTORIES>
$<TARGET_PROPERTY:react_utils,INTERFACE_INCLUDE_DIRECTORIES>
$<TARGET_PROPERTY:reactnativeblob,INTERFACE_INCLUDE_DIRECTORIES>
$<TARGET_PROPERTY:reactnativejni,INTERFACE_INCLUDE_DIRECTORIES>
@@ -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
@@ -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
@@ -157,6 +157,13 @@ Scheduler::Scheduler(
}
uiManager_->setAnimationDelegate(animationDelegate);
// Initialize ViewTransitionModule
if (ReactNativeFeatureFlags::viewTransitionEnabled()) {
viewTransitionModule_ = std::make_unique<ViewTransitionModule>();
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_);
@@ -27,6 +27,7 @@
#include <react/renderer/uimanager/UIManagerAnimationDelegate.h>
#include <react/renderer/uimanager/UIManagerBinding.h>
#include <react/renderer/uimanager/UIManagerDelegate.h>
#include <react/renderer/viewtransition/ViewTransitionModule.h>
#include <react/utils/ContextContainer.h>
namespace facebook::react {
@@ -146,6 +147,8 @@ class Scheduler final : public UIManagerDelegate {
RuntimeScheduler *runtimeScheduler_{nullptr};
std::unique_ptr<ViewTransitionModule> viewTransitionModule_;
mutable std::shared_mutex onSurfaceStartCallbackMutex_;
OnSurfaceStartCallback onSurfaceStartCallback_;
};
@@ -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);
}
}
@@ -16,18 +16,16 @@ class UIManagerViewTransitionDelegate {
public:
virtual ~UIManagerViewTransitionDelegate() = default;
virtual void applyViewTransitionName(
const std::shared_ptr<const ShadowNode> &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<const ShadowNode> &shadowNode, const std::string &name) {}
virtual void cancelViewTransitionName(const ShadowNode &shadowNode, const std::string &name) {}
virtual void restoreViewTransitionName(const std::shared_ptr<const ShadowNode> &shadowNode) {}
virtual void restoreViewTransitionName(const ShadowNode &shadowNode) {}
virtual void captureLayoutMetricsFromRoot(const std::shared_ptr<const ShadowNode> &shadowNode) {}
virtual void captureLayoutMetricsFromRoot(const ShadowNode &shadowNode) {}
virtual void startViewTransition(
std::function<void()> mutationCallback,
@@ -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)
@@ -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 <glog/logging.h>
#include <react/renderer/core/LayoutableShadowNode.h>
#include <react/renderer/uimanager/UIManager.h>
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<const LayoutableShadowNode*>(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<void()> mutationCallback,
std::function<void()> onReadyCallback,
std::function<void()> 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<std::string>& 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
@@ -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 <unordered_set>
#include <react/renderer/core/LayoutMetrics.h>
#include <react/renderer/core/ShadowNode.h>
#include <react/renderer/mounting/ShadowViewMutation.h>
#include <react/renderer/uimanager/UIManagerViewTransitionDelegate.h>
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<void()> mutationCallback,
std::function<void()> onReadyCallback,
std::function<void()> 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<std::string> &names, Tag newTag, Tag oldTag);
// registry of layout of old/new views
std::unordered_map<std::string, AnimationKeyFrameView> oldLayout_{};
std::unordered_map<std::string, AnimationKeyFrameView> newLayout_{};
// temporary registry of measured layout metrics keyed by tag
std::unordered_map<Tag, LayoutMetrics> 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<Tag, std::unordered_set<std::string>> nameRegistry_{};
// used for cancel/restore viewTransitionName
std::unordered_map<Tag, std::unordered_set<std::string>> cancelledNameRegistry_{};
UIManager *uiManager_{nullptr};
bool transitionStarted_{false};
};
} // namespace facebook::react
@@ -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