Repository navigation
feat(Reanimated): play CSS transitions and layout animations on the native host on Android - #10951
Conversation
…ative host on Android Bring the shared native animation host to Android, for CSS transitions and for layout animations. The CSS transition backend of the host moves from Apple code to `CSSHostTransitionBackend` in common code. Each platform gives a route table as data: the CSS properties that it plays, their targets, and the timing kinds that it plays (`REACSSPlatformRouteTable`, `AndroidCSSRouteTable`). The iOS table has the same 8 properties and 2 timing kinds as before. `AnimationTiming` gets `StepsTiming` and `LinearStopsTiming`. iOS refuses them, so such transitions stay on the CSS loop there. CSS transitions: with `ANDROID_CSS_PLATFORM_TRANSITIONS`, Android transitions now run through the host. `AndroidNativeAnimationPlatform` calls the Kotlin `NativeAnimationExecutor` through JNI. The executor plays each track with one `ValueAnimator` and one writer for its target, and starts each track on its origin on the Reanimated clock. Remove the old backend: `CSSPlatformTransitions`, `CSSPlatformEasings`, and the Kotlin `css` package. `NativeProxy` now builds `PlatformDepMethodsHolder` one time. Before, it built it two times, so the CSS proxy and the run-end listener used different backends. One behavior changes: the host does not wait for a view that is not mounted. It refuses the run, and CSS plays the property on its loop. Layout animations: the new static flag `ANDROID_NATIVE_LAYOUT_ANIMATIONS` (default `false`) lets platform animators play opacity, position, and 2D transform tracks from the final mounted state, on the same conditions as on iOS. The route needs the React Native pull model (`enableMountingCoordinatorPullModelAndroid` and `enableAccumulatedUpdatesInRawPropsAndroid`), so the route choice and the mount are in one frame. `pullsOnMountThread()` gates each site, and a pull outside a mount dispatch admits no native start. `didMountItems` reports the pulled surfaces (`pulledSurfacesDidMount`) and then rewrites the synchronous props. Opacity plays by alpha. Position and transform play by `View.setAnimationMatrix` on API 29 or later. A skew, a singular matrix, a 3D operation, and a change of width or height stay on the frame driver. With the flags off, Android runs as before. In the push model, the React Native default, layout animations stay on the frame driver. The feature flags guide lists the new flag and its limits. Tests: `packages/react-native-reanimated/__tests__/nativeTiming.android.test.ts`.
📝 Walkthrough
Priority: ➖ Normal Change: Feature Merge Risk: 🔵 Low · up to
|
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
e1446c8e-18c4-49ee-963d-07523d0a1a21
📒 Files selected for processing: 99
apps/common-app/runtime-tests/ReJest/secondSurface.tsapps/common-app/runtime-tests/reanimated/suites.tsapps/common-app/runtime-tests/reanimated/tests/css/nativeTransitions.test.tsxapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/layout/propUpdates.test.tsxapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/nativeLayoutContinuity.test.tsxapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/nativeLayoutEntering.test.tsxapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/nativeLayoutExiting.test.tsxapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/nativeLayoutMountWindow.test.tsxapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/nativeLayoutReferencePlay.tsxapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/nativeLayoutRoute.test.tsxapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/nativeLayoutStarts.test.tsxapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/nativeLayoutTestKit.tsxapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/nativeLayoutTransform.test.tsxapps/common-app/runtime-tests/reanimated/tests/nativeAnimationObservation.tsapps/common-app/runtime-tests/reanimated/tests/viewSampler.tsapps/fabric-example/android/app/build.gradleapps/fabric-example/android/app/src/main/AndroidManifest.xmlapps/fabric-example/android/app/src/main/java/com/fabricexample/MainApplication.ktapps/fabric-example/android/app/src/main/java/com/fabricexample/RuntimeTestsCoverActivity.ktapps/fabric-example/android/app/src/main/java/com/fabricexample/RuntimeTestsFabricShim.javaapps/fabric-example/android/app/src/main/java/com/fabricexample/RuntimeTestsSlowAnimationsModule.ktapps/fabric-example/android/app/src/main/java/com/fabricexample/RuntimeTestsSlowAnimationsPackage.ktapps/fabric-example/android/app/src/main/java/com/fabricexample/RuntimeTestsSurfaceModule.ktapps/fabric-example/android/app/src/main/java/com/fabricexample/RuntimeTestsSurfacePackage.ktapps/fabric-example/android/app/src/main/java/com/fabricexample/RuntimeTestsThrowingViewManager.javaapps/fabric-example/android/app/src/main/java/com/fabricexample/RuntimeTestsViewSamplerModule.ktapps/fabric-example/android/app/src/main/java/com/fabricexample/RuntimeTestsViewSamplerPackage.ktapps/fabric-example/package.jsonapps/fabric-example/scripts/runtime-tests-server.jsdocs/docs-reanimated/docs/guides/feature-flags.mdpackages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSHostTransitionBackend.cpppackages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSHostTransitionBackend.hpackages/react-native-reanimated/Common/cpp/reanimated/CSS/utils/platform.cpppackages/react-native-reanimated/Common/cpp/reanimated/CSS/utils/platform.hpackages/react-native-reanimated/Common/cpp/reanimated/Compat/ReactNativeFeatureFlagsCompat.hpackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy.cpppackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy.hpackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.hpackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyRegistry.cpppackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyRegistry.hpackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpppackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/NativeLayoutRoute.hpackages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationHost.cpppackages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationHost.hpackages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationPlatform.hpackages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationTiming.cpppackages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationTiming.hpackages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationValue.cpppackages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationValue.hpackages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpppackages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.hpackages/react-native-reanimated/__tests__/nativeTiming.android.test.tspackages/react-native-reanimated/android/CMakeLists.txtpackages/react-native-reanimated/android/build.gradle.ktspackages/react-native-reanimated/android/src/main/cpp/reanimated/android/CSS/AndroidCSSRouteTable.cpppackages/react-native-reanimated/android/src/main/cpp/reanimated/android/CSS/AndroidCSSRouteTable.hpackages/react-native-reanimated/android/src/main/cpp/reanimated/android/CSS/CSSPlatformEasings.cpppackages/react-native-reanimated/android/src/main/cpp/reanimated/android/CSS/CSSPlatformEasings.hpackages/react-native-reanimated/android/src/main/cpp/reanimated/android/CSS/CSSPlatformTransitions.cpppackages/react-native-reanimated/android/src/main/cpp/reanimated/android/CSS/CSSPlatformTransitions.hpackages/react-native-reanimated/android/src/main/cpp/reanimated/android/NativeAnimations/AndroidNativeAnimationPlatform.cpppackages/react-native-reanimated/android/src/main/cpp/reanimated/android/NativeAnimations/AndroidNativeAnimationPlatform.hpackages/react-native-reanimated/android/src/main/cpp/reanimated/android/NativeAnimations/JNativeAnimationExecutor.hpackages/react-native-reanimated/android/src/main/cpp/reanimated/android/NativeAnimations/NativeAnimationPlatformCallbacks.hpackages/react-native-reanimated/android/src/main/cpp/reanimated/android/NativeProxy.cpppackages/react-native-reanimated/android/src/main/cpp/reanimated/android/NativeProxy.hpackages/react-native-reanimated/android/src/main/cpp/reanimated/android/OnLoad.cpppackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSEasing.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPropertyWriter.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/MainThreadCommandQueue.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/DurationScale.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/GeometryWriter.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/NativeAnimationExecutor.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/NativeAnimationPlatformCallbacks.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/NativeAnimationSample.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/PreDrawReconciler.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/PreparedPlayback.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/Timeline.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/TrackBinding.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/TrackBufferParser.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/TrackEncoding.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/TrackPlayback.ktpackages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeAnimations/TrackWriter.ktpackages/react-native-reanimated/apple/reanimated/apple/CSS/REACSSPlatformRouteTable.hpackages/react-native-reanimated/apple/reanimated/apple/CSS/REACSSPlatformRouteTable.mmpackages/react-native-reanimated/apple/reanimated/apple/CSS/REACSSPlatformTransitionBackend.hpackages/react-native-reanimated/apple/reanimated/apple/NativeAnimations/REANativeAnimationPlatform.hpackages/react-native-reanimated/apple/reanimated/apple/NativeAnimations/REANativeAnimationPlatform.mmpackages/react-native-reanimated/apple/reanimated/apple/ReanimatedModule.mmpackages/react-native-reanimated/apple/reanimated/apple/native/PlatformDepMethodsHolderImpl.mmpackages/react-native-reanimated/changelog/android-css-transitions-shared-native-host.feature.mdpackages/react-native-reanimated/changelog/android-native-layout-animations.feature.mdpackages/react-native-reanimated/changelog/native-animation-common-css-backend.other.mdpackages/react-native-reanimated/changelog/native-animation-runtime-test-hooks.other.mdpackages/react-native-reanimated/src/animation/nativeTiming.tspackages/react-native-reanimated/src/featureFlags/staticFeatureFlags.tspackages/react-native-reanimated/src/featureFlags/staticFlags.json
💤 Files with no reviewable changes: 10
- packages/react-native-reanimated/apple/reanimated/apple/CSS/REACSSPlatformTransitionBackend.h
- packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPropertyWriter.kt
- packages/react-native-reanimated/android/src/main/cpp/reanimated/android/CSS/CSSPlatformTransitions.h
- packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSEasing.kt
- packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/MainThreadCommandQueue.kt
- packages/react-native-reanimated/android/src/main/cpp/reanimated/android/CSS/CSSPlatformEasings.cpp
- packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt
- packages/react-native-reanimated/android/src/main/cpp/reanimated/android/CSS/CSSPlatformEasings.h
- packages/react-native-reanimated/android/src/main/cpp/reanimated/android/CSS/CSSPlatformTransitions.cpp
- packages/react-native-reanimated/Common/cpp/reanimated/CSS/utils/platform.h
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.
| fun queuedPullItems(rootTag: Double, promise: Promise) { | ||
| UiThreadUtil.runOnUiThread { | ||
| val shim = detachedSurfaces[rootTag.toInt()] | ||
| if (shim == null) { | ||
| promise.reject("E_NO_SURFACE", "No surface with no root view has the root tag $rootTag.") | ||
| return@runOnUiThread | ||
| } | ||
| promise.resolve(shim.queuedPullItemCount()) | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Reflection failures in queuedPullItemCount crash the test app; reject the promise instead.
RuntimeTestsFabricShim.queuedPullItemCount() reads the private React Native fields mMountingManager and onViewAttachMountItems through reflection. It throws a ReflectiveOperationException or an IllegalStateException if a field is renamed or the surface manager is missing. Kotlin does not force the module to catch these exceptions. At Line 223 and Line 266, the exception escapes the runOnUiThread block. At Line 409, it escapes willMountItems during a mount dispatch. In each case the exception crashes the app, and the promise stays unsettled. The whole runtime-test run then stops with an uncaught exception, and no message names the reflection failure. A React Native upgrade is enough to cause this, because these fields are internal.
Catch the exception at each call site. Reject the pending promise with the cause.
🛡️ Proposed fix
- promise.resolve(shim.queuedPullItemCount())
+ runCatching { shim.queuedPullItemCount() }
+ .onSuccess { promise.resolve(it) }
+ .onFailure { promise.reject("E_REFLECTION", it) } override fun willMountItems(uiManager: UIManager) {
...
if (attach.hasAttached) return
- attach.queuedPullItemsAtAttach = attach.shim.queuedPullItemCount()
+ attach.queuedPullItemsAtAttach = runCatching { attach.shim.queuedPullItemCount() }.getOrElse {
+ dispatchAttach = null
+ attach.promise.reject("E_REFLECTION", it)
+ return
+ }Also applies to: 266-266, 404-412
Note
This pull request was authored by AI on behalf of @pawicao.
Summary
The shared native animation host played only on iOS, and Android CSS transitions used their own backend (
CSSPlatformTransitions,CSSPlatformEasings, and the Kotlincsspackage). I moved the CSS transition backend of the host to common code (CSSHostTransitionBackend), with a route table from each platform, and added the Android platform:AndroidNativeAnimationPlatformcalls the KotlinNativeAnimationExecutor, which plays each track with oneValueAnimator. WithANDROID_CSS_PLATFORM_TRANSITIONSon, Android CSS transitions now run through the host, and the new static flagANDROID_NATIVE_LAYOUT_ANIMATIONS(defaultfalse) lets the host play opacity, position, and 2D transform tracks of layout animations from the final mounted state, on the same conditions as on iOS. The layout route needs the React Native flagsenableMountingCoordinatorPullModelAndroidandenableAccumulatedUpdatesInRawPropsAndroid, so that the pull and the mount of a commit happen in one dispatch on the UI thread; without them, layout animations stay on the frame driver. With the flags off, Android runs as before, and the feature flags guide lists the new flag and its limits.How it works
Common CSS backend. The CSS backend of the host was Apple code. It is now
CSSHostTransitionBackendin common code, and each platform gives a route table as data: the CSS properties that it plays, their targets, and the timing kinds that it plays.REACSSPlatformRouteTablekeeps the 8 properties and the 2 timing kinds of iOS.AndroidCSSRouteTablegives opacity, the background and border colors, the border radius, and the shadow color from API 28.AnimationTiminggotStepsTimingandLinearStopsTiming, and only Android plays them. iOS refuses them, so such transitions stay on the CSS loop there.Android executor.
AndroidNativeAnimationPlatformsends the tracks through JNI to the KotlinNativeAnimationExecutor(packagenativeAnimations). The executor plays each track with oneValueAnimatorand one writer for its target, and starts each track on its origin on the Reanimated clock. Opacity plays by alpha. Position and transform play byView.setAnimationMatrix, which needs API 29 or later. A skew, a singular matrix, a 3D operation, and a change of width or height stay on the frame driver.NativeProxynow buildsPlatformDepMethodsHolderone time. Before, it built it two times, so the CSS proxy and the run-end listener used different backends. One behavior changed: the host does not wait for a view that is not mounted. It refuses the run, and CSS plays the property on its loop.Mount window. The iOS route starts native playback in the mount report of the pull (
runAfterMount). On Android, the FabricUIManagerListenerofNativeProxygiveswillMountItemsanddidMountItemsto the platform, which numbers each dispatch. At the start of each pull, the proxy asksmountReportOfPullfor the number of the dispatch that mounts it. A pull with no number (outside a dispatch, or in the push model) admits no native start. The proxy keeps aPullRecordfor each pull until a report takes it.didMountItemsreports all pulled surfaces (pulledSurfacesDidMount) and then rewrites the synchronous props.pullsOnMountThread()replacesisMountingCoordinatorPullModelEnabled()at each site, and it needs both React Native flags.On Android, React Native writes opacity to the view property that the track also writes. When a mount changes the opacity of a view with an active opacity track, the pull records a
MountedModel, and the host gives the new model value to the platform (modelDidChange). iOS does nothing there, because the layer keeps its model value during a track.Test hooks and test host. The Gradle property
reanimatedRuntimeTestHookssetsREANIMATED_RUNTIME_TEST_HOOKSin the Android CMake build, and the runtime-tests driver passes it. A new hook,nativeAnimationRoutes, gives the routes of the build (layoutRouteisoff,configured, oradmitted), so each suite skips by what the build has. The Android test host got modules for a second surface, a view sampler, slow animations, and a view manager that throws. The Gradle propertyfabricExampleMountThreadPullturns on the two React Native flags infabric-example.flowchart TD Commit["React commit"] --> Dispatch["Fabric dispatch: willMountItems"] Dispatch --> Pull["LayoutAnimationsProxy pull"] Pull --> Report{"mountReportOfPull has a number?"} Report -->|no| FD["frame driver"] Report -->|yes| Record["PullRecord: MountedStart / MountedStop / MountedModel"] Record --> Mount["mount items run"] Mount --> Did["didMountItems: pulledSurfacesDidMount(dispatch)"] Did --> Host["NativeAnimationHost::runAfterMount"] Host --> Platform["AndroidNativeAnimationPlatform"] Platform --> Executor["Kotlin NativeAnimationExecutor: one ValueAnimator per track"] CSS["CSS transition"] --> Backend["CSSHostTransitionBackend + AndroidCSSRouteTable"] Backend --> HostTest plan
Jest:
packages/react-native-reanimated/__tests__/nativeTiming.android.test.ts(new: only the Android flag gives a native timing description on Android).Runtime tests:
native layout mount window(apps/common-app/runtime-tests/reanimated/tests/layoutAnimations/nativeLayoutMountWindow.test.tsx, Android) checks that a start mounts its final state and is admitted in thedidMountItemsof its dispatch, and that a pull outside a dispatch admits no native start.css native transitions(apps/common-app/runtime-tests/reanimated/tests/css/nativeTransitions.test.tsx) adds the Android cases: a clipped view, the steps andlinear()easings of the route table, and the commands of a second surface.native layout routeadds the cases of a build whose route has no mount-thread pull.CI runs the runtime tests on Android in
DebugRuntimeTests, so the native test hooks are there. The CI build does not setfabricExampleMountThreadPull, so the layout route is configured but admits no native start. On Android CI, these suites run:css native transitions(the host cases, also of a second surface),native layout route(the hooks, the route reads, and the cases with no mount-thread pull), andlayout animation callback commit(on the frame driver). The suitesnative layout starts,native layout mount window,native layout entering,native layout exiting,native layout continuity, andnative layout transformskip there.To run the native layout suites on Android, build the app with the pull model, then run the suites on the build:
Changelog
yarn changelog:addfor each changed package, or this PR does not changereact-native-reanimatedorreact-native-worklets.