diff --git a/FabricExample/android/app/src/main/java/com/fabricexample/MainApplication.kt b/FabricExample/android/app/src/main/java/com/fabricexample/MainApplication.kt index 154a66709b..b95cb0c50d 100644 --- a/FabricExample/android/app/src/main/java/com/fabricexample/MainApplication.kt +++ b/FabricExample/android/app/src/main/java/com/fabricexample/MainApplication.kt @@ -21,6 +21,7 @@ class MainApplication : Application(), ReactApplication { PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: // add(MyReactNativePackage()) + add(CustomViewPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/FabricExample/android/app/src/main/java/com/fabricexample/MyCustomView.kt b/FabricExample/android/app/src/main/java/com/fabricexample/MyCustomView.kt new file mode 100644 index 0000000000..10a7c54e7b --- /dev/null +++ b/FabricExample/android/app/src/main/java/com/fabricexample/MyCustomView.kt @@ -0,0 +1,106 @@ +package com.fabricexample + +import android.annotation.SuppressLint +import android.util.Log +import android.view.View +import androidx.core.view.doOnDetach +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.views.view.ReactViewGroup +import com.facebook.react.views.view.ReactViewManager + +@ReactModule(name = ReactViewManager.REACT_CLASS, canOverrideExistingModule = true) +class FixedReactViewManager : ReactViewManager() { + + override fun removeViewAt(parent: ReactViewGroup, index: Int) { + val child = parent.getChildAt(index) + if (child is MyCustomView) { + Log.d("HannoDebug", """ + FixedReactViewManager: removeViewAt: ${child} + → parent: $parent + """.trimIndent()) + } + super.removeViewAt(parent, index) + } + + override fun addView(parent: ReactViewGroup, child: View, index: Int) { + if (child.parent == null) { + super.addView(parent, child, index) + return + } + + Log.d("HannoDebug", """) + FixedReactViewManager: addView: child has parent, waiting for detach: $child, id: ${child.id} + → current parent: ${child.parent} + → new parent: $parent + """.trimIndent()) + // When the child-parent relation is removed, onDetachedFromWindow will be called: + child.doOnDetach { + // Looking at how endViewTransition is implemented, dispatchDetachedFromWindow + // gets called _before_ the parent relation is removed, so we need to post this to the end of the frame: + child.post { + Log.d("HannoDebug", """ + FixedReactViewManager: addView: child doOnDetach called: $child, id: ${child.id} + → parent: ${child.parent} (should be null) + → new parent: $parent + """.trimIndent()) + super.addView(parent, child, index) + } + } + } +} + +@SuppressLint("ViewConstructor") +class MyCustomView(context: ThemedReactContext) : View(context) { + override fun onAttachedToWindow() { + super.onAttachedToWindow() + Log.d("HannoDebug", "MyCustomView: onAttachedToWindow called for view: $this, id: ${this.id}, to: ${this.parent}") + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + Log.d("HannoDebug", "MyCustomView: onDetachedFromWindow called for view: $this, id: ${this.id}, from: ${this.parent}") + // print callstack: + Exception().printStackTrace() + } +} + +@ReactModule(name = "CustomView") +class CustomViewManager : SimpleViewManager() { + override fun getName() = "CustomView" + +// companion object { +// @SuppressLint("StaticFieldLeak") +// var viewInstance: MyCustomView? = null +// } + + init { + mRecyclableViews = HashMap() + } + + override fun createViewInstance(reactContext: ThemedReactContext): MyCustomView { + Log.d("HannoDebug", "CustomViewManager: createViewInstance called") +// if (viewInstance == null) { + val viewInstance = MyCustomView(reactContext) + viewInstance.setBackgroundColor(0xFFFF0000.toInt()) // Red background for visibility +// } + return viewInstance + } + + override fun onDropViewInstance(view: MyCustomView) { + super.onDropViewInstance(view) + prepareToRecycleView(view.context as ThemedReactContext, view) + Log.d("HannoDebug", "CustomViewManager: onDropViewInstance called ${view}") + } +} + +class CustomViewPackage : ReactPackage { + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return listOf(CustomViewManager(), FixedReactViewManager()) + } + +} \ No newline at end of file diff --git a/FabricExample/android/settings.gradle b/FabricExample/android/settings.gradle index 98477550f6..c720a1d066 100644 --- a/FabricExample/android/settings.gradle +++ b/FabricExample/android/settings.gradle @@ -4,3 +4,12 @@ extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> ex.autol rootProject.name = 'FabricExample' include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') + +includeBuild('../node_modules/react-native') { + dependencySubstitution { + substitute(module("com.facebook.react:react-android")).using(project(":packages:react-native:ReactAndroid")) + substitute(module("com.facebook.react:react-native")).using(project(":packages:react-native:ReactAndroid")) + substitute(module("com.facebook.react:hermes-android")).using(project(":packages:react-native:ReactAndroid:hermes-engine")) + substitute(module("com.facebook.react:hermes-engine")).using(project(":packages:react-native:ReactAndroid:hermes-engine")) + } + } \ No newline at end of file diff --git a/FabricExample/patches/react-native+0.81.1.patch b/FabricExample/patches/react-native+0.81.1.patch new file mode 100644 index 0000000000..3b8d4bd861 --- /dev/null +++ b/FabricExample/patches/react-native+0.81.1.patch @@ -0,0 +1,51 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java +index dee20e3..2f2c87c 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java +@@ -379,9 +379,9 @@ public class SurfaceMountingManager { + + actualParentId + + "] " + + " Parent: " +- + viewParent.getClass().getSimpleName() ++ + viewParent + + " View: " +- + view.getClass().getSimpleName())); ++ + view)); + + // We've hit an error case, and `addView` will crash below + // if we don't take evasive action (it is an error to add a View +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt +index 2089157..c8e1bf5 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt +@@ -151,7 +151,7 @@ constructor(private val config: MainPackageConfig? = null) : + ReactTextInputManager(), + if (ReactNativeFeatureFlags.enablePreparedTextLayout()) PreparedLayoutTextViewManager() + else ReactTextViewManager(), +- ReactViewManager(), ++// ReactViewManager(), + ReactVirtualTextViewManager(), + ReactUnimplementedViewManager()) + +@@ -191,7 +191,7 @@ constructor(private val config: MainPackageConfig? = null) : + PreparedLayoutTextViewManager() + else ReactTextViewManager() + }, +- ReactViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactViewManager() }, ++// ReactViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactViewManager() }, + ReactVirtualTextViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { ReactVirtualTextViewManager() }, + ReactUnimplementedViewManager.REACT_CLASS to +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewManager.java +index 28e12d6..abc62c8 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewManager.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewManager.java +@@ -54,7 +54,7 @@ public abstract class ViewManager + * null signals that View Recycling is disabled. `enableViewRecycling` must be explicitly called + * in a concrete constructor to enable View Recycling per ViewManager. + */ +- @Nullable private HashMap> mRecyclableViews = null; ++ @Nullable public HashMap> mRecyclableViews = null; + + public ViewManager() { + super(null); diff --git a/android/src/main/java/com/swmansion/rnscreens/Screen.kt b/android/src/main/java/com/swmansion/rnscreens/Screen.kt index 2b7dcff55f..fe4b336bdd 100644 --- a/android/src/main/java/com/swmansion/rnscreens/Screen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/Screen.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.pm.ActivityInfo import android.graphics.Paint import android.os.Parcelable +import android.util.Log import android.util.SparseArray import android.view.MotionEvent import android.view.View @@ -469,27 +470,23 @@ class Screen( return } isBeingRemoved = false - endTransitionRecursive(this) + endViewTransition() } - private fun endTransitionRecursive(parent: ViewGroup) { - parent.children.forEach { childView -> - parent.endViewTransition(childView) - - if (childView is ScreenStackHeaderConfig) { - endTransitionRecursive(childView.toolbar) - } - - if (childView is ViewGroup) { - endTransitionRecursive(childView) - } - } - } + private val inTransitionViews = mutableListOf>() + /** + * Called when a screen gets removed. This is to mark all children as in transition, + * so they are not removed from the view hierarchy until endRemovalTransition is called. + * This is needed for the screen transition animation to work properly (otherwise the children + * would instantly disappear from the screen). + * + * This is copied from react-native-screens BUT it manually keeps track of the views. + */ private fun startTransitionRecursive(parent: ViewGroup?) { - parent?.let { - for (i in 0 until it.childCount) { - val child = it.getChildAt(i) + parent?.let { parentView -> + for (i in 0 until parentView.childCount) { + val child = parentView.getChildAt(i) if (parent is SwipeRefreshLayout && child is ImageView) { // SwipeRefreshLayout class which has CircleImageView as a child, @@ -498,9 +495,14 @@ class Screen( // wrong index if we called `startViewTransition` on the views on new arch. // We add a simple View to bump the number of children to make it work. // TODO: find a better way to handle this scenario - it.addView(View(context), i) + parentView.addView(View(context), i) } else { - child?.let { view -> it.startViewTransition(view) } + child?.let { childView -> + Log.d("HannoDebug", "Screen: startTransitionRecursive for parent: $parentView") + Log.d("HannoDebug", " ↳ child: $childView") + parentView.startViewTransition(childView) + inTransitionViews.add(Pair(parentView, childView)) + } } if (child is ScreenStackHeaderConfig) { @@ -516,6 +518,25 @@ class Screen( } } + /** + * Called when the removal transition is finished. This will clear the transition state + * from all children and allow them to be removed from the view hierarchy and their mParent + * field to be set to null. + */ + private fun endViewTransition() { + // IMPORTANT: Reverse order is needed, inner children first! + // Otherwise parents will call dispatchOnDetachedFromWindow on all their children, + // which will cause endViewTransition to have no effect on them anymore. + for ((parent, child) in inTransitionViews.asReversed()) { + Log.d("HannoDebug", "Screen: endViewTransition for parent: $parent") + Log.d("HannoDebug", " ↳ child: $child") + // The react-native layer will have called parent.removeView(child) already, + // so endViewTransition will finally remove the child from the parent. + parent.endViewTransition(child) + } + inTransitionViews.clear() + } + // We do not want to perform any action, therefore do not need to override the associated method. @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent?): Boolean = diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt index 9894cd4be0..22d27e0727 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt @@ -73,6 +73,10 @@ class ScreenStack( } override fun endViewTransition(view: View) { + if (view is ScreensCoordinatorLayout) { + view.fragment.screen.endRemovalTransition() + } + super.endViewTransition(view) disappearingTransitioningChildren.remove(view) diff --git a/apps/src/tests/TestRecyclingViews.tsx b/apps/src/tests/TestRecyclingViews.tsx new file mode 100644 index 0000000000..9edd3a9543 --- /dev/null +++ b/apps/src/tests/TestRecyclingViews.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import {Button, requireNativeComponent, View} from 'react-native'; +import {NavigationContainer, useNavigation} from '@react-navigation/native'; +import {createNativeStackNavigator} from '@react-navigation/native-stack'; + +const Stack = createNativeStackNavigator(); + +let CustomView = requireNativeComponent('CustomView'); + +function HomeScreen() { + const navigation = useNavigation(); + return ( + +