diff --git a/.buildkite/jobs/pipeline.android_rn_87.yml b/.buildkite/jobs/pipeline.android_rn_87.yml new file mode 100644 index 00000000000..2fe850d8751 --- /dev/null +++ b/.buildkite/jobs/pipeline.android_rn_87.yml @@ -0,0 +1,10 @@ + - label: ":android: Android (RN 0.87.1)" + env: + JAVA_HOME: /opt/openjdk/jdk-17.0.9.jdk/Contents/Home/ + REACT_NATIVE_VERSION: 0.87.1 + command: + - "nvm install" + - "./scripts/ci.android.sh" + key: "android_rn_87" + timeout_in_minutes: 60 + artifact_paths: "/Users/builder/uibuilder/work/playground/artifacts/**/*" diff --git a/.buildkite/jobs/pipeline.ios_rn_87.yml b/.buildkite/jobs/pipeline.ios_rn_87.yml new file mode 100644 index 00000000000..1e95410fee1 --- /dev/null +++ b/.buildkite/jobs/pipeline.ios_rn_87.yml @@ -0,0 +1,11 @@ + - label: ":ios: iOS (RN 0.87.1)" + env: + REACT_NATIVE_VERSION: 0.87.1 + IOS_TEST_DEVICE: iPhone 17 Pro Max + IOS_TEST_OS: "26.1" + command: + - "nvm install" + - "./scripts/ci.ios.sh" + key: "ios_rn_87" + timeout_in_minutes: 60 + artifact_paths: "/Users/builder/uibuilder/work/playground/artifacts/**/*" diff --git a/.buildkite/pipeline.sh b/.buildkite/pipeline.sh index b9b14330b9a..c3ddcbadd83 100755 --- a/.buildkite/pipeline.sh +++ b/.buildkite/pipeline.sh @@ -7,8 +7,10 @@ cat .buildkite/jobs/pipeline.android_rn_77.yml cat .buildkite/jobs/pipeline.android_rn_78.yml cat .buildkite/jobs/pipeline.android_rn_84.yml cat .buildkite/jobs/pipeline.android_rn_85.yml +cat .buildkite/jobs/pipeline.android_rn_87.yml cat .buildkite/jobs/pipeline.ios_rn_77.yml cat .buildkite/jobs/pipeline.ios_rn_78.yml cat .buildkite/jobs/pipeline.ios_rn_84.yml cat .buildkite/jobs/pipeline.ios_rn_85.yml +cat .buildkite/jobs/pipeline.ios_rn_87.yml cat .buildkite/jobs/pipeline.publish.yml diff --git a/.yarnrc.yml b/.yarnrc.yml index 237c13dcfcd..bdf1ca147f5 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -5,6 +5,14 @@ enableGlobalCache: false nmHoistingLimits: workspaces npmMinimalAgeGate: 14d +# Exact versions exercised by the RN 0.87 compatibility jobs. +npmPreapprovedPackages: + - "react-native@0.87.1" + - "@react-native/*@0.87.1" + - "hermes-compiler@250829098.0.17" + - "react-native-gesture-handler@3.2.1" + - "react-native-reanimated@4.6.0" + - "react-native-worklets@0.12.1" nodeLinker: node-modules npmRegistryServer: "https://registry.npmjs.org" diff --git a/android/build.gradle b/android/build.gradle index bec9808bfbf..2c73a022ead 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -3,7 +3,7 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat buildscript { ext { - kotlinVersion = "2.1.20" + kotlinVersion = rootProject.ext.has('kotlinVersion') ? rootProject.ext.kotlinVersion : "2.1.20" RNNKotlinVersion = kotlinVersion detoxKotlinVersion = kotlinVersion compileSdkVersion = 36 @@ -79,7 +79,7 @@ android { minifyEnabled false } } - lintOptions { + lint { abortOnError false } diff --git a/android/src/androidTest/java/com/reactnativenavigation/TestApplication.kt b/android/src/androidTest/java/com/reactnativenavigation/TestApplication.kt index aec08e356c2..21e1fe9763b 100644 --- a/android/src/androidTest/java/com/reactnativenavigation/TestApplication.kt +++ b/android/src/androidTest/java/com/reactnativenavigation/TestApplication.kt @@ -10,7 +10,7 @@ import com.reactnativenavigation.react.NavigationReactNativeHost class TestApplication : NavigationApplication() { override val reactNativeHost: ReactNativeHost - get() = object : NavigationReactNativeHost(this) { + get() = object : NavigationReactNativeHost(this@TestApplication) { override fun getJSMainModuleName(): String { return "index" } @@ -34,4 +34,4 @@ class TestApplication : NavigationApplication() { override val reactHost: ReactHost get() = getDefaultReactHost(this, reactNativeHost) -} \ No newline at end of file +} diff --git a/android/src/main/java/com/reactnativenavigation/NavigationApplication.java b/android/src/main/java/com/reactnativenavigation/NavigationApplication.java index a49628613c6..1e0863c4d2a 100644 --- a/android/src/main/java/com/reactnativenavigation/NavigationApplication.java +++ b/android/src/main/java/com/reactnativenavigation/NavigationApplication.java @@ -7,7 +7,6 @@ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; import com.facebook.react.soloader.OpenSourceMergedSoMapping; import com.facebook.soloader.SoLoader; -import com.reactnativenavigation.customrow.BottomTabsCustomRowAttacher; import com.reactnativenavigation.react.ReactGateway; import com.reactnativenavigation.viewcontrollers.externalcomponent.ExternalComponentCreator; @@ -47,7 +46,6 @@ public void onCreate() { reactGateway = createReactGateway(); - BottomTabsCustomRowAttacher.INSTANCE.registerOnce(this, null); } /** diff --git a/android/src/main/java/com/reactnativenavigation/NavigationPackage.kt b/android/src/main/java/com/reactnativenavigation/NavigationPackage.kt index c4026a19f38..8b899034239 100644 --- a/android/src/main/java/com/reactnativenavigation/NavigationPackage.kt +++ b/android/src/main/java/com/reactnativenavigation/NavigationPackage.kt @@ -7,8 +7,6 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.model.ReactModuleInfo import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager -import android.app.Application -import com.reactnativenavigation.customrow.BottomTabsCustomRowAttacher import com.reactnativenavigation.customrow.BottomTabsCustomRowModule import com.reactnativenavigation.options.LayoutFactory import com.reactnativenavigation.react.NavigationTurboModule @@ -18,9 +16,6 @@ class NavigationPackage() : BaseReactPackage() { override fun getModule(name: String, context: ReactApplicationContext): NativeModule? { val reactApp = context.applicationContext as ReactApplication - (context.applicationContext as? Application)?.let { - BottomTabsCustomRowAttacher.registerOnce(it, context.currentActivity) - } return when (name) { NavigationTurboModule.NAME -> { NavigationTurboModule(context, LayoutFactory(reactApp.reactHost)) @@ -59,4 +54,3 @@ class NavigationPackage() : BaseReactPackage() { return mutableListOf(ModalViewManager(reactContext)) } } - diff --git a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRow.kt b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRow.kt index 8764ad4a1a5..69113f92bab 100644 --- a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRow.kt +++ b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRow.kt @@ -69,11 +69,16 @@ class BottomTabsCustomRow( clipChildren = false clipToPadding = false addView(backgroundView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) - BottomTabsCustomRowConfigStore.addListener(configListener) applyOptions(currentOptions) rebuildCells() } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + BottomTabsCustomRowConfigStore.addListener(configListener) + applyOptions(BottomTabsCustomRowConfigStore.get()) + } + override fun onDetachedFromWindow() { super.onDetachedFromWindow() BottomTabsCustomRowConfigStore.removeListener(configListener) diff --git a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacher.kt b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacher.kt index 00bee6bd2a7..ff7aaef73e2 100644 --- a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacher.kt +++ b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacher.kt @@ -1,30 +1,25 @@ package com.reactnativenavigation.customrow import android.app.Activity -import android.app.Application +import android.content.Context +import android.content.ContextWrapper import android.os.Build -import android.os.Bundle import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowInsets import android.widget.FrameLayout -import com.reactnativenavigation.NavigationActivity import com.reactnativenavigation.views.bottomtabs.BottomTabs -/** - * Activity-lifecycle observer that watches every started activity for - * `BottomTabs` instances using the existing custom-tab path and injects a - * [BottomTabsCustomRow] above them, hiding the native chrome via public - * `View` APIs (`alpha = 0f`). - * - * Layout listeners are registered once per activity (on the decor view) and - * placement updates are deduplicated so Espresso / Detox can reach idle. - */ -internal object BottomTabsCustomRowAttacher : Application.ActivityLifecycleCallbacks { - - @Volatile private var registered: Boolean = false - @Volatile private var lastResumedActivity: Activity? = null +/** Tracks only custom tabs that are actually attached; never scans an activity's view tree. */ +internal object BottomTabsCustomRowAttacher { + private data class Attachment( + val row: BottomTabsCustomRow, + val observer: ViewTreeObserver, + val listener: ViewTreeObserver.OnGlobalLayoutListener, + val originalAlpha: Float, + val originalElevation: Float, + ) private data class LastPlacement( val left: Int, @@ -34,113 +29,70 @@ internal object BottomTabsCustomRowAttacher : Application.ActivityLifecycleCallb val safeBottomInsetPx: Int, ) - fun registerOnce(application: Application, currentActivity: Activity? = null) { - if (!registered) { - registered = true - application.registerActivityLifecycleCallbacks(this) - } - if (currentActivity != null && lastResumedActivity == null) { - lastResumedActivity = currentActivity - ensureLayoutObserver(currentActivity) - tryAttach(currentActivity) + @JvmStatic + fun attach(bottomTabs: BottomTabs) { + if (!bottomTabs.hasCustomItemViews()) return + if (bottomTabs.getTag(TAG_ATTACHMENT) != null) return + val activity = activityFrom(bottomTabs.context) ?: return + val decor = activity.window?.decorView as? ViewGroup ?: return + val overlayHost = decor.findViewById(android.R.id.content) as? ViewGroup ?: decor + val originalAlpha = bottomTabs.alpha + val originalElevation = bottomTabs.elevation + bottomTabs.setExternalCustomItemViewHost(true) + val row = BottomTabsCustomRow(overlayHost.context, bottomTabs) + val listener = ViewTreeObserver.OnGlobalLayoutListener { + row.visibility = if (bottomTabs.isShown) View.VISIBLE else View.GONE + positionRow(row, bottomTabs, overlayHost, activity) } + val observer = decor.viewTreeObserver + bottomTabs.setTag(TAG_ATTACHMENT, Attachment(row, observer, listener, originalAlpha, originalElevation)) + overlayHost.addView(row, FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT + )) + bottomTabs.alpha = 0f + bottomTabs.elevation = 0f + observer.addOnGlobalLayoutListener(listener) + listener.onGlobalLayout() } - fun rescan() { - val activity = lastResumedActivity ?: return - tryAttach(activity) - } - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - ensureLayoutObserver(activity) - tryAttach(activity) - } - - override fun onActivityStarted(activity: Activity) { - ensureLayoutObserver(activity) - tryAttach(activity) - } - - override fun onActivityResumed(activity: Activity) { - lastResumedActivity = activity - ensureLayoutObserver(activity) - tryAttach(activity) - } - - override fun onActivityPaused(activity: Activity) { - if (lastResumedActivity === activity) lastResumedActivity = null - } - - override fun onActivityStopped(activity: Activity) {} - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} - override fun onActivityDestroyed(activity: Activity) { - activity.window?.decorView?.setTag(TAG_OBSERVING, null) - } - - private fun ensureLayoutObserver(activity: Activity) { - // BottomTabs only ever live inside RNN's own NavigationActivity (an - // AppCompatActivity). Touching any other activity — e.g. a third-party - // relay such as AppAuth's RedirectUriReceiverActivity, whose theme is not - // a Theme.AppCompat descendant — forces AppCompat sub-decor inflation and - // crashes with "You need to use a Theme.AppCompat theme". Guard here so the - // global lifecycle observer never operates on foreign activities. - if (activity !is NavigationActivity) return - val decor = activity.window?.decorView as? ViewGroup ?: return - if (decor.getTag(TAG_OBSERVING) == true) return - decor.setTag(TAG_OBSERVING, true) - decor.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - tryAttach(activity) - } - } - ) + @JvmStatic + fun detach(bottomTabs: BottomTabs) { + val attachment = bottomTabs.getTag(TAG_ATTACHMENT) as? Attachment ?: return + // Select View.setTag: AHBottomNavigation also overloads setTag(Int, String?). + (bottomTabs as View).setTag(TAG_ATTACHMENT, null) + if (attachment.observer.isAlive) { + attachment.observer.removeOnGlobalLayoutListener(attachment.listener) + } + (attachment.row.parent as? ViewGroup)?.removeView(attachment.row) + bottomTabs.setExternalCustomItemViewHost(false) + bottomTabs.alpha = attachment.originalAlpha + bottomTabs.elevation = attachment.originalElevation } - private fun tryAttach(activity: Activity) { - if (activity !is NavigationActivity) return - val scanRoot = activity.window?.decorView as? ViewGroup ?: return - // Resolve through the view tree: AppCompatActivity.findViewById() forces - // createSubDecor(), which throws unless the activity's theme is Theme.AppCompat. - val overlayHost = scanRoot.findViewById(android.R.id.content) as? ViewGroup - ?: scanRoot - - forEachBottomTabs(scanRoot) { bottomTabs -> - if (!bottomTabs.hasCustomItemViews()) return@forEachBottomTabs - - val existing = bottomTabs.getTag(TAG_ATTACHED_ROW_ID) as? BottomTabsCustomRow - if (existing != null) { - ensureRowHostedOn(existing, overlayHost) - positionRow(existing, bottomTabs, overlayHost, activity) - return@forEachBottomTabs - } - - bottomTabs.setExternalCustomItemViewHost(true) - val row = BottomTabsCustomRow(overlayHost.context, bottomTabs) - overlayHost.addView( - row, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ) - ) - bottomTabs.setTag(TAG_ATTACHED_ROW_ID, row) - bottomTabs.alpha = 0f - bottomTabs.elevation = 0f - positionRow(row, bottomTabs, overlayHost, activity) + @JvmStatic + fun onCustomItemsChanged(bottomTabs: BottomTabs) { + if (!bottomTabs.hasCustomItemViews()) { + detach(bottomTabs) + return + } + if (!bottomTabs.isAttachedToWindow) return + val attachment = bottomTabs.getTag(TAG_ATTACHMENT) as? Attachment + if (attachment == null) attach(bottomTabs) + else { + attachment.row.rebuildCells() + attachment.listener.onGlobalLayout() } } - private fun ensureRowHostedOn(row: BottomTabsCustomRow, overlayHost: ViewGroup) { - if (row.parent === overlayHost) return - (row.parent as? ViewGroup)?.removeView(row) - overlayHost.addView( - row, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ) - ) + private fun activityFrom(context: Context): Activity? { + var current = context + while (current is ContextWrapper) { + if (current is Activity) return current + val base = current.baseContext + if (base === current) return null + current = base + } + return current as? Activity } private fun positionRow( @@ -196,19 +148,6 @@ internal object BottomTabsCustomRowAttacher : Application.ActivityLifecycleCallb return 0 } - private fun forEachBottomTabs(view: View, block: (BottomTabs) -> Unit) { - if (view is BottomTabs) { - block(view) - return - } - if (view is ViewGroup) { - for (i in 0 until view.childCount) { - forEachBottomTabs(view.getChildAt(i), block) - } - } - } - - private val TAG_ATTACHED_ROW_ID = "rnnBottomTabsCustomRow".hashCode() - private val TAG_OBSERVING = "rnnCustomRowObserving".hashCode() + private val TAG_ATTACHMENT = "rnnCustomRowAttachment".hashCode() private val TAG_LAST_PLACEMENT = "rnnCustomRowLastPlacement".hashCode() } diff --git a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowModule.kt b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowModule.kt index d120908391b..7bfe49ec19d 100644 --- a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowModule.kt +++ b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowModule.kt @@ -1,6 +1,5 @@ package com.reactnativenavigation.customrow -import android.app.Application import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod @@ -18,17 +17,11 @@ class BottomTabsCustomRowModule( reactContext: ReactApplicationContext, ) : ReactContextBaseJavaModule(reactContext) { - init { - val app = reactContext.applicationContext as? Application - if (app != null) BottomTabsCustomRowAttacher.registerOnce(app) - } - override fun getName(): String = NAME @ReactMethod fun configure(config: ReadableMap?) { BottomTabsCustomRowConfigStore.update(BottomTabsCustomRowOptions.fromMap(config)) - BottomTabsCustomRowAttacher.rescan() } companion object { diff --git a/android/src/main/java/com/reactnativenavigation/react/JsDevReloadHandler.java b/android/src/main/java/com/reactnativenavigation/react/JsDevReloadHandler.java index c562ccd5511..84a422cf5a7 100644 --- a/android/src/main/java/com/reactnativenavigation/react/JsDevReloadHandler.java +++ b/android/src/main/java/com/reactnativenavigation/react/JsDevReloadHandler.java @@ -7,7 +7,7 @@ import android.content.IntentFilter; import android.view.KeyEvent; import android.widget.EditText; -import android.os.Build; +import androidx.core.content.ContextCompat; import com.facebook.react.devsupport.interfaces.DevSupportManager; import com.reactnativenavigation.utils.UiUtils; @@ -19,6 +19,8 @@ public interface ReloadListener { void onReload(); } + private static final ReloadListener NO_OP_RELOAD_LISTENER = () -> {}; + private final BroadcastReceiver reloadReceiver = new BroadcastReceiver() { @Override public void onReceive(final Context context, final Intent intent) { @@ -28,7 +30,7 @@ public void onReceive(final Context context, final Intent intent) { private final DevSupportManager devSupportManager; private long firstRTimestamp = 0; - private ReloadListener reloadListener = () -> {}; + private ReloadListener reloadListener = NO_OP_RELOAD_LISTENER; JsDevReloadHandler(DevSupportManager devSupportManager) { this.devSupportManager = devSupportManager; @@ -36,7 +38,7 @@ public void onReceive(final Context context, final Intent intent) { @Override public void onSuccess() { - UiUtils.runOnMainThread(reloadListener::onReload); + UiUtils.runOnMainThread(() -> reloadListener.onReload()); } public void setReloadListener(ReloadListener listener) { @@ -45,16 +47,14 @@ public void setReloadListener(ReloadListener listener) { public void removeReloadListener(ReloadListener listener) { if (reloadListener == listener) { - reloadListener = null; + reloadListener = NO_OP_RELOAD_LISTENER; } } public void onActivityResumed(Activity activity) { - if (Build.VERSION.SDK_INT >= 34 && activity.getApplicationInfo().targetSdkVersion >= 34) { - activity.registerReceiver(reloadReceiver, new IntentFilter(RELOAD_BROADCAST), Context.RECEIVER_EXPORTED); - } else { - activity.registerReceiver(reloadReceiver, new IntentFilter(RELOAD_BROADCAST)); - } + // Exported intentionally so the development reload broadcast can be sent by adb. + ContextCompat.registerReceiver(activity, reloadReceiver, + new IntentFilter(RELOAD_BROADCAST), ContextCompat.RECEIVER_EXPORTED); } public void onActivityPaused(Activity activity) { diff --git a/android/src/main/java/com/reactnativenavigation/react/NavigationTurboModule.kt b/android/src/main/java/com/reactnativenavigation/react/NavigationTurboModule.kt index de36aa609a5..2e7182248d1 100644 --- a/android/src/main/java/com/reactnativenavigation/react/NavigationTurboModule.kt +++ b/android/src/main/java/com/reactnativenavigation/react/NavigationTurboModule.kt @@ -65,7 +65,7 @@ class NavigationTurboModule( UiUtils.getBottomTabsHeight(reactApplicationContext).toFloat() ).toDouble() constants[Constants.STATUS_BAR_HEIGHT_KEY] = - UiUtils.pxToDp(reactApplicationContext, getStatusBarHeight(currentActivity).toFloat()) + UiUtils.pxToDp(reactApplicationContext, getStatusBarHeight(getCurrentActivity()).toFloat()) .toDouble() constants[Constants.TOP_BAR_HEIGHT_KEY] = UiUtils.pxToDp( reactApplicationContext, @@ -84,7 +84,7 @@ class NavigationTurboModule( handle { Log.d("NavigationTurboModule", "setRoot handle ${Thread.currentThread()}") val viewController = layoutFactory.create(layoutTree) - val activity = currentActivity + val activity = getCurrentActivity() if (activity == null) { promise.reject("ACTIVITY_NULL", "Activity is null") return@handle @@ -299,7 +299,7 @@ class NavigationTurboModule( if (activity == null) { Log.e("NavigationTurboModule", "current activity is null!") } - return currentActivity as NavigationActivity? + return activity } companion object { diff --git a/android/src/main/java/com/reactnativenavigation/react/modal/ModalHostLayout.kt b/android/src/main/java/com/reactnativenavigation/react/modal/ModalHostLayout.kt index 50e77bd8a64..30cfa463bc2 100644 --- a/android/src/main/java/com/reactnativenavigation/react/modal/ModalHostLayout.kt +++ b/android/src/main/java/com/reactnativenavigation/react/modal/ModalHostLayout.kt @@ -19,6 +19,7 @@ import java.util.* @SuppressLint("ViewConstructor") open class ModalHostLayout(reactContext: ThemedReactContext) : ViewGroup(reactContext), LifecycleEventListener { + var isPresented = false val viewController = ModalLayoutController( reactContext, reactContext.currentActivity, CompatUtils.generateViewId().toString(), @@ -75,4 +76,4 @@ open class ModalHostLayout(reactContext: ThemedReactContext) : ViewGroup(reactCo override fun onHostPause() {} override fun onHostDestroy() { onDropInstance() } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/reactnativenavigation/react/modal/ModalViewManager.kt b/android/src/main/java/com/reactnativenavigation/react/modal/ModalViewManager.kt index 2ab4afcd71e..b52cfabf5ce 100644 --- a/android/src/main/java/com/reactnativenavigation/react/modal/ModalViewManager.kt +++ b/android/src/main/java/com/reactnativenavigation/react/modal/ModalViewManager.kt @@ -63,12 +63,16 @@ class ModalViewManager(val reactContext: ReactContext) : ViewGroupManager resources.configuration.isDarkMode() } fun Configuration.isDarkMode() = - (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES \ No newline at end of file + (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES diff --git a/android/src/main/java/com/reactnativenavigation/utils/ViewController.kt b/android/src/main/java/com/reactnativenavigation/utils/ViewController.kt index 181f55521f7..a1bdbc06c0f 100644 --- a/android/src/main/java/com/reactnativenavigation/utils/ViewController.kt +++ b/android/src/main/java/com/reactnativenavigation/utils/ViewController.kt @@ -2,13 +2,15 @@ package com.reactnativenavigation.utils import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.suspendCancellableCoroutine -suspend fun ViewController<*>.awaitRender() = suspendCoroutine { cont -> - addOnAppearedListener(object : Runnable { +suspend fun ViewController<*>.awaitRender() = suspendCancellableCoroutine { cont -> + val listener = object : Runnable { override fun run() { removeOnAppearedListener(this) - cont.resume(Unit) + if (cont.isActive) cont.resume(Unit) } - }) -} \ No newline at end of file + } + cont.invokeOnCancellation { removeOnAppearedListener(listener) } + if (cont.isActive) addOnAppearedListener(listener) +} diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalAnimator.kt b/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalAnimator.kt index 374a30ee6f8..2aa17e602ed 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalAnimator.kt +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalAnimator.kt @@ -13,10 +13,8 @@ import com.reactnativenavigation.utils.ScreenAnimationListener import com.reactnativenavigation.utils.awaitRender import com.reactnativenavigation.viewcontrollers.common.BaseAnimator import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController +import com.reactnativenavigation.viewcontrollers.viewcontroller.cancelPendingOrRunning import com.reactnativenavigation.views.element.TransitionAnimatorCreator -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import java.util.* open class ModalAnimator @JvmOverloads constructor( @@ -48,7 +46,7 @@ open class ModalAnimator @JvmOverloads constructor( } private fun showModalWithElementTransition(appearing: ViewController<*>, disappearing: ViewController<*>, animationOptions: TransitionAnimationOptions, set: AnimatorSet) { - GlobalScope.launch(Dispatchers.Main.immediate) { + appearing.coroutineScope.launchAnimation(set, { runningAnimators.remove(appearing, set) }) { appearing.setWaitForRender(Bool(true)) appearing.view.alpha = 0f appearing.awaitRender() @@ -62,27 +60,26 @@ open class ModalAnimator @JvmOverloads constructor( } private fun showModalWithoutElementTransition(appearing: ViewController<*>, disappearing: ViewController<*>?, animationOptions: TransitionAnimationOptions, set: AnimatorSet) { - GlobalScope.launch(Dispatchers.Main.immediate) { - val appearingAnimation = if (animationOptions.enter.hasValue()) { - animationOptions.enter.getAnimation(appearing.view) - } else getDefaultPushAnimation(appearing.view) - val disappearingAnimation = if (disappearing != null && animationOptions.exit.hasValue()) { - animationOptions.exit.getAnimation(disappearing.view) - } else null - disappearingAnimation?.let { - set.playTogether(appearingAnimation, disappearingAnimation) - } ?: set.playTogether(appearingAnimation) - set.start() - } + val appearingAnimation = if (animationOptions.enter.hasValue()) { + animationOptions.enter.getAnimation(appearing.view) + } else getDefaultPushAnimation(appearing.view) + val disappearingAnimation = if (disappearing != null && animationOptions.exit.hasValue()) { + animationOptions.exit.getAnimation(disappearing.view) + } else null + disappearingAnimation?.let { + set.playTogether(appearingAnimation, disappearingAnimation) + } ?: set.playTogether(appearingAnimation) + set.start() } open fun dismiss(appearing: ViewController<*>?, disappearing: ViewController<*>, animationOptions: TransitionAnimationOptions, listener: ScreenAnimationListener) { - GlobalScope.launch(Dispatchers.Main.immediate) { - if (runningAnimators.containsKey(disappearing)) { - runningAnimators[disappearing]?.cancel() - listener.onEnd() - } else { - val set = createDismissAnimator(disappearing, listener) + if (runningAnimators.containsKey(disappearing)) { + runningAnimators[disappearing]?.cancelPendingOrRunning() + listener.onEnd() + } else { + val set = createDismissAnimator(disappearing, listener) + runningAnimators[disappearing] = set + disappearing.coroutineScope.launchAnimation(set, { runningAnimators.remove(disappearing, set) }) { if (animationOptions.hasElementTransitions() && appearing != null) { setupDismissAnimationWithSharedElementTransition(disappearing, appearing, animationOptions, set) } else { diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/stack/StackAnimator.kt b/android/src/main/java/com/reactnativenavigation/viewcontrollers/stack/StackAnimator.kt index 59e82531b2d..0f349d72e91 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/stack/StackAnimator.kt +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/stack/StackAnimator.kt @@ -15,10 +15,8 @@ import com.reactnativenavigation.utils.awaitRender import com.reactnativenavigation.utils.resetViewProperties import com.reactnativenavigation.viewcontrollers.common.BaseAnimator import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController +import com.reactnativenavigation.viewcontrollers.viewcontroller.cancelPendingOrRunning import com.reactnativenavigation.views.element.TransitionAnimatorCreator -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import java.util.* open class StackAnimator @JvmOverloads constructor( @@ -34,7 +32,7 @@ open class StackAnimator @JvmOverloads constructor( @VisibleForTesting val runningSetRootAnimations: MutableMap, AnimatorSet> = HashMap() - fun cancelPushAnimations() = runningPushAnimations.values.forEach(Animator::cancel) + fun cancelPushAnimations() = runningPushAnimations.values.toList().forEach { it.cancelPendingOrRunning() } open fun isChildInTransition(child: ViewController<*>?): Boolean { return runningPushAnimations.containsKey(child) || @@ -43,9 +41,11 @@ open class StackAnimator @JvmOverloads constructor( } fun cancelAllAnimations() { + val animations = (runningPushAnimations.values + runningPopAnimations.values + runningSetRootAnimations.values).toList() runningPushAnimations.clear() runningPopAnimations.clear() runningSetRootAnimations.clear() + animations.forEach { it.cancelPendingOrRunning() } } fun setRoot( @@ -60,7 +60,8 @@ open class StackAnimator @JvmOverloads constructor( val setRoot = options.animations.setStackRoot if (setRoot.waitForRender.isTrue) { appearing.view.alpha = 0f - appearing.addOnAppearedListener { + appearing.coroutineScope.launchAnimation(set, { runningSetRootAnimations.remove(appearing, set) }) { + appearing.awaitRender() appearing.view.alpha = 1f animateSetRoot(set, setRoot, appearing, disappearing, additionalAnimations) } @@ -93,7 +94,7 @@ open class StackAnimator @JvmOverloads constructor( onAnimationEnd: Runnable ) { if (runningPushAnimations.containsKey(disappearing)) { - runningPushAnimations[disappearing]!!.cancel() + runningPushAnimations[disappearing]!!.cancelPendingOrRunning() onAnimationEnd.run() } else { animatePop( @@ -113,13 +114,13 @@ open class StackAnimator @JvmOverloads constructor( additionalAnimations: List, onAnimationEnd: Runnable ) { - GlobalScope.launch(Dispatchers.Main.immediate) { - val set = createPopAnimator(disappearing, onAnimationEnd) - if (disappearingOptions.animations.pop.sharedElements.hasValue()) { + val set = createPopAnimator(disappearing, onAnimationEnd) + if (disappearingOptions.animations.pop.sharedElements.hasValue()) { + disappearing.coroutineScope.launchAnimation(set, { runningPopAnimations.remove(disappearing, set) }) { popWithElementTransitions(appearing, disappearing, disappearingOptions, set) - } else { - popWithoutElementTransitions(appearing, disappearing, disappearingOptions, set, additionalAnimations) } + } else { + popWithoutElementTransitions(appearing, disappearing, disappearingOptions, set, additionalAnimations) } } @@ -220,7 +221,7 @@ open class StackAnimator @JvmOverloads constructor( disappearing: ViewController<*>, options: Options, set: AnimatorSet - ) = GlobalScope.launch(Dispatchers.Main.immediate) { + ) = appearing.coroutineScope.launchAnimation(set, { runningPushAnimations.remove(appearing, set) }) { appearing.setWaitForRender(Bool(true)) appearing.view.alpha = 0f appearing.awaitRender() @@ -242,7 +243,8 @@ open class StackAnimator @JvmOverloads constructor( val push = resolvedOptions.animations.push if (push.waitForRender.isTrue) { appearing.view.alpha = 0f - appearing.addOnAppearedListener { + appearing.coroutineScope.launchAnimation(set, { runningPushAnimations.remove(appearing, set) }) { + appearing.awaitRender() appearing.view.alpha = 1f animatePushWithoutElementTransitions(set, push, appearing, disappearing, additionalAnimations) } diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java index 7fcbb0689b7..d710d517c13 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java @@ -41,6 +41,7 @@ public abstract class ViewController implements ViewTreeObs BehaviourAdapter { private final List onAppearedListeners = new ArrayList<>(); + @Nullable private ViewControllerScope coroutineScope; private boolean appearEventPosted; private boolean isFirstLayout = true; private Bool waitForRender = new NullBool(); @@ -83,6 +84,14 @@ public boolean isDestroyed() { return isDestroyed; } + public ViewControllerScope getCoroutineScope() { + if (coroutineScope == null) { + coroutineScope = new ViewControllerScope(); + if (isDestroyed) coroutineScope.cancel(); + } + return coroutineScope; + } + public ViewController(Activity activity, String id, YellowBoxDelegate yellowBoxDelegate, Options initialOptions, ViewControllerOverlay overlay) { this.activity = activity; this.id = id; @@ -101,6 +110,7 @@ public ScrollEventListener getScrollEventListener() { } public void addOnAppearedListener(Runnable onAppearedListener) { + if (isDestroyed) return; if (isShown) { onAppearedListener.run(); } else { @@ -283,6 +293,7 @@ public boolean containsComponent(Component component) { @CallSuper public void onViewWillAppear() { + if (isDestroyed) return; isShown = true; applyOptions(options); performOnParentController(parentController -> { @@ -292,8 +303,12 @@ public void onViewWillAppear() { if (!onAppearedListeners.isEmpty() && !appearEventPosted) { appearEventPosted = true; UiThread.post(() -> { - forEach(onAppearedListeners, Runnable::run); + List listeners = new ArrayList<>(onAppearedListeners); onAppearedListeners.clear(); + for (Runnable listener : listeners) { + if (isDestroyed) break; + listener.run(); + } }); } } @@ -313,6 +328,9 @@ public void onViewDisappear() { @CallSuper public void destroy() { + isDestroyed = true; + if (coroutineScope != null) coroutineScope.cancel(); + onAppearedListeners.clear(); if (isShown) { isShown = false; onViewDisappear(); @@ -329,7 +347,6 @@ public void destroy() { } setParentController(null); view = null; - isDestroyed = true; } } diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerScope.kt b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerScope.kt new file mode 100644 index 00000000000..821944a5c11 --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerScope.kt @@ -0,0 +1,42 @@ +package com.reactnativenavigation.viewcontrollers.viewcontroller + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** Lazily created by a controller and cancelled before its view is destroyed. */ +class ViewControllerScope : CoroutineScope { + override val coroutineContext = SupervisorJob() + Dispatchers.Main.immediate + + fun cancel() = coroutineContext.cancel() + + fun launchAnimation(set: AnimatorSet, onCancelled: () -> Unit, block: suspend CoroutineScope.() -> Unit) { + val job = launch(start = CoroutineStart.LAZY, block = block) + val listener = object : AnimatorListenerAdapter() { + override fun onAnimationCancel(animation: Animator) { job.cancel() } + } + set.addListener(listener) + job.invokeOnCompletion { cause -> + set.removeListener(listener) + if (cause != null) onCancelled() + } + job.start() + } +} + +/** AnimatorSet.cancel() does not notify listeners before start(), while preparation can suspend. */ +fun AnimatorSet.cancelPendingOrRunning() { + if (isStarted) { + cancel() + } else { + val callbacks = listeners?.toList().orEmpty() + callbacks.forEach { it.onAnimationCancel(this) } + callbacks.forEach { it.onAnimationEnd(this) } + } +} diff --git a/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabs.java b/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabs.java index 3016ee4ea4e..d400b73ba39 100644 --- a/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabs.java +++ b/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabs.java @@ -17,6 +17,7 @@ import com.aurelhubert.ahbottomnavigation.AHBottomNavigation; import com.aurelhubert.ahbottomnavigation.AHBottomNavigationItem; import com.reactnativenavigation.R; +import com.reactnativenavigation.customrow.BottomTabsCustomRowAttacher; import com.reactnativenavigation.options.LayoutDirection; import java.util.ArrayList; @@ -154,12 +155,23 @@ public void setLayoutDirection(LayoutDirection direction) { */ public void setCustomItemViews(List customViews) { clearCustomItemViews(); - if (customViews == null || customViews.isEmpty()) return; - - customItemViews.addAll(customViews); + if (customViews != null) customItemViews.addAll(customViews); + BottomTabsCustomRowAttacher.onCustomItemsChanged(this); attachCustomItemViews(); } + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + BottomTabsCustomRowAttacher.attach(this); + } + + @Override + protected void onDetachedFromWindow() { + BottomTabsCustomRowAttacher.detach(this); + super.onDetachedFromWindow(); + } + public void onCustomItemViewSelectionChanged(int selectedIndex) { for (int i = 0; i < customItemViews.size(); i++) { customItemViews.get(i).setItemSelected(i == selectedIndex); diff --git a/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabsContainer.kt b/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabsContainer.kt index 3a937ac6f0b..480801b6f22 100644 --- a/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabsContainer.kt +++ b/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabsContainer.kt @@ -10,7 +10,7 @@ import android.view.ViewOutlineProvider import android.widget.FrameLayout.LayoutParams.MATCH_PARENT import android.widget.FrameLayout.LayoutParams.WRAP_CONTENT import android.widget.LinearLayout -import androidx.annotation.RestrictTo +import androidx.annotation.VisibleForTesting import androidx.core.graphics.ColorUtils import androidx.core.view.updatePadding import com.reactnativenavigation.options.params.Fraction @@ -43,8 +43,8 @@ class BottomTabsContainer(context: Context, val bottomTabs: BottomTabs) : Shadow private var blurColor: Int? = null var topOutLineView = TopOutlineView(context) - @RestrictTo(RestrictTo.Scope.TESTS, RestrictTo.Scope.SUBCLASSES) get - @RestrictTo(RestrictTo.Scope.TESTS) set(value) { + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) get + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) set(value) { this.removeView(field) addView(value, LayoutParams(MATCH_PARENT, WRAP_CONTENT)) field = value diff --git a/android/src/main/java/com/reactnativenavigation/views/element/TransitionAnimatorCreator.kt b/android/src/main/java/com/reactnativenavigation/views/element/TransitionAnimatorCreator.kt index f8fb69419fb..950c3e9af29 100644 --- a/android/src/main/java/com/reactnativenavigation/views/element/TransitionAnimatorCreator.kt +++ b/android/src/main/java/com/reactnativenavigation/views/element/TransitionAnimatorCreator.kt @@ -10,7 +10,6 @@ import android.widget.FrameLayout import androidx.core.animation.doOnCancel import androidx.core.animation.doOnEnd import androidx.core.animation.doOnStart -import com.facebook.react.uimanager.ViewGroupManager import com.reactnativenavigation.R import com.reactnativenavigation.options.AnimationOptions import com.reactnativenavigation.options.LayoutAnimation @@ -61,7 +60,9 @@ open class TransitionAnimatorCreator @JvmOverloads constructor(private val trans private fun reparentViews(transitions: TransitionSet) { transitions.transitions - .sortedBy { getZIndex(it.view) } + // Sort before detaching any views: Fabric's native hierarchy is the + // source of truth for z-order, including transitions in nested parents. + .sortedWith { first, second -> ViewHierarchyComparator.compare(first.view, second.view) } .forEach { reparent(it) } transitions.validSharedElementTransitions .forEach { it.view.visibility = View.INVISIBLE } @@ -99,7 +100,6 @@ open class TransitionAnimatorCreator @JvmOverloads constructor(private val trans mutableListOf().apply { addAll(transitions.validSharedElementTransitions) addAll(transitions.validElementTransitions) - sortBy { getZIndex(it.view) } sortBy { it.view.getTag(R.id.original_index_in_parent) as Int } forEach { removeFromOverlay(it.viewController, it.view) @@ -124,7 +124,6 @@ open class TransitionAnimatorCreator @JvmOverloads constructor(private val trans view.setTag(R.id.original_left, view.left) view.setTag(R.id.original_pivot_x, view.pivotX) view.setTag(R.id.original_pivot_y, view.pivotY) - view.setTag(R.id.original_z_index, getZIndex(view)) biologicalParent.removeView(view) @@ -152,10 +151,6 @@ open class TransitionAnimatorCreator @JvmOverloads constructor(private val trans parent.addView(element, index, lp) } - private fun getZIndex(view: View) = ViewGroupManager.getViewZIndex(view) - ?: ViewTags.get(view, R.id.original_z_index) - ?: 0 - private fun addToOverlay(vc: ViewController<*>, element: View, lp: FrameLayout.LayoutParams) { val viewController = vc.parentController ?: vc viewController.addOverlay(element, lp) @@ -165,4 +160,4 @@ open class TransitionAnimatorCreator @JvmOverloads constructor(private val trans val viewController = vc.parentController ?: vc viewController.removeOverlay(element) } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/reactnativenavigation/views/element/ViewHierarchyComparator.kt b/android/src/main/java/com/reactnativenavigation/views/element/ViewHierarchyComparator.kt new file mode 100644 index 00000000000..c3b5da1abf3 --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/views/element/ViewHierarchyComparator.kt @@ -0,0 +1,31 @@ +package com.reactnativenavigation.views.element + +import android.view.View +import android.view.ViewGroup + +/** Fabric applies React zIndex by ordering native children, not by setting View.z. */ +internal object ViewHierarchyComparator : Comparator { + override fun compare(first: View, second: View): Int { + val firstPath = drawingPath(first) + val secondPath = drawingPath(second) + for (index in 0 until minOf(firstPath.size, secondPath.size)) { + val elevationOrder = firstPath[index].first.compareTo(secondPath[index].first) + if (elevationOrder != 0) return elevationOrder + val siblingOrder = firstPath[index].second.compareTo(secondPath[index].second) + if (siblingOrder != 0) return siblingOrder + } + return firstPath.size.compareTo(secondPath.size) + } + + private fun drawingPath(view: View): List> { + val path = mutableListOf>() + var child = view + var parent = child.parent as? ViewGroup + while (parent != null) { + path.add(child.z to parent.indexOfChild(child)) + child = parent + parent = child.parent as? ViewGroup + } + return path.asReversed() + } +} diff --git a/android/src/main/java/com/reactnativenavigation/views/element/animators/ReactImageMatrixAnimator.kt b/android/src/main/java/com/reactnativenavigation/views/element/animators/ReactImageMatrixAnimator.kt index 1d8f81ce235..ede390ae04c 100644 --- a/android/src/main/java/com/reactnativenavigation/views/element/animators/ReactImageMatrixAnimator.kt +++ b/android/src/main/java/com/reactnativenavigation/views/element/animators/ReactImageMatrixAnimator.kt @@ -27,14 +27,14 @@ class ReactImageMatrixAnimator(from: View, to: View) : PropertyAnimatorCreator, nativeId: String): View? { + if (root.isDestroyed) return null + val view = ReactFindViewUtil.findView(root.view, nativeId) + return if (view is ImageView) awaitImage(view) else view + } - override suspend fun find(root: ViewController<*>, nativeId: String) = suspendCoroutine { cont -> - when (val view = ReactFindViewUtil.findView(root.view, nativeId)) { - null -> cont.resume(null) - is ImageView -> { - if (hasMeasuredDrawable(view)) { - resume(view, cont) - } else { - resumeOnImageLoad(view, cont) - } + internal suspend fun awaitImage(view: ImageView): View? = withTimeoutOrNull(IMAGE_LOAD_TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + val observer = view.viewTreeObserver + lateinit var preDraw: ViewTreeObserver.OnPreDrawListener + lateinit var attachment: View.OnAttachStateChangeListener + var pendingResume: Runnable? = null + + fun cleanup() { + if (observer.isAlive) observer.removeOnPreDrawListener(preDraw) + view.removeOnAttachStateChangeListener(attachment) + pendingResume?.let(view::removeCallbacks) } - else -> cont.resume(view) - } - } - private fun resume(view: ImageView, cont: Continuation) { - if (view.drawable is RootDrawable) { - view.post { cont.resume(view) } - } else { - cont.resume(view) - } - } + fun finish(result: View?) { + cleanup() + if (cont.isActive) cont.resume(result) + } - private fun resumeOnImageLoad(view: ImageView, cont: Continuation) { - view.doOnPreDraw { - if (hasMeasuredDrawable(view)) { - view.post { - cont.resume(view) + fun checkImage() { + if (!hasMeasuredDrawable(view) || pendingResume != null) return + // Fresco updates drawable bounds during drawing; preserve the deferred resume. + if (view.drawable is RootDrawable) { + pendingResume = Runnable { finish(view) }.also { view.post(it) } + } else { + finish(view) } - } else { - resumeOnImageLoad(view, cont) } - } - } - private fun hasMeasuredDrawable(view: ImageView) = when (view.drawable) { - is RootDrawable -> true - else -> checkIfFastImageIsMeasured(view) + preDraw = ViewTreeObserver.OnPreDrawListener { checkImage(); true } + attachment = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) {} + override fun onViewDetachedFromWindow(v: View) { finish(null) } + } + observer.addOnPreDrawListener(preDraw) + view.addOnAttachStateChangeListener(attachment) + cont.invokeOnCancellation { cleanup() } + if (cont.isActive) checkImage() + } } - private fun checkIfFastImageIsMeasured(view: ImageView) = with(view.drawable) { - this != null && intrinsicWidth != -1 && intrinsicHeight != -1 && isImageScaledToFit(view) + private fun hasMeasuredDrawable(view: ImageView): Boolean { + val drawable = view.drawable ?: return false + return drawable is RootDrawable || + (view.width > 0 && view.height > 0 && drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) } - private fun Drawable.isImageScaledToFit(view: ImageView): Boolean { - val scaleX = view.width / intrinsicWidth.toFloat() - val scaleY = view.height / intrinsicHeight.toFloat() - return min(scaleX, scaleY) >= 1f + companion object { + internal const val IMAGE_LOAD_TIMEOUT_MS = 1000L } } diff --git a/android/src/main/java/com/reactnativenavigation/views/stack/topbar/titlebar/ButtonBar.kt b/android/src/main/java/com/reactnativenavigation/views/stack/topbar/titlebar/ButtonBar.kt index 798ce497d3a..1b4e1fbfc36 100644 --- a/android/src/main/java/com/reactnativenavigation/views/stack/topbar/titlebar/ButtonBar.kt +++ b/android/src/main/java/com/reactnativenavigation/views/stack/topbar/titlebar/ButtonBar.kt @@ -38,8 +38,9 @@ open class ButtonBar internal constructor(context: Context) : Toolbar(context) { get() = menu.size() fun addButton(menuItem: Int, intId: Int, order: Int, styledText: SpannableString): MenuItem? { - if(shouldAnimate) - TransitionManager.beginDelayedTransition(this,AutoTransition()) + if (shouldAnimate) { + TransitionManager.beginDelayedTransition(this, AutoTransition()) + } return this.menu?.add(menuItem, intId, order, @@ -47,14 +48,16 @@ open class ButtonBar internal constructor(context: Context) : Toolbar(context) { } fun removeButton(buttonId: Int) { - if(shouldAnimate) - TransitionManager.beginDelayedTransition(this,AutoTransition()) + if (shouldAnimate) { + TransitionManager.beginDelayedTransition(this, AutoTransition()) + } menu.removeItem(buttonId) } open fun clearButtons() { - if(shouldAnimate) - TransitionManager.beginDelayedTransition(this,AutoTransition()) + if (shouldAnimate) { + TransitionManager.beginDelayedTransition(this, AutoTransition()) + } clearBackButton() if (menu.size() > 0) menu.clear() } @@ -84,4 +87,4 @@ open class ButtonBar internal constructor(context: Context) : Toolbar(context) { } } } -} \ No newline at end of file +} diff --git a/android/src/main/res/values/ids.xml b/android/src/main/res/values/ids.xml index f43a89d7454..a5e3aa8ed06 100644 --- a/android/src/main/res/values/ids.xml +++ b/android/src/main/res/values/ids.xml @@ -15,5 +15,4 @@ - - \ No newline at end of file + diff --git a/android/src/test/java/com/reactnativenavigation/TestApplication.kt b/android/src/test/java/com/reactnativenavigation/TestApplication.kt index 81e15dd3fd1..3e2de020159 100644 --- a/android/src/test/java/com/reactnativenavigation/TestApplication.kt +++ b/android/src/test/java/com/reactnativenavigation/TestApplication.kt @@ -6,10 +6,11 @@ import com.facebook.react.ReactApplication import com.facebook.react.ReactHost import com.facebook.react.ReactNativeHost import com.facebook.react.ReactPackage +import com.facebook.react.devsupport.interfaces.DevSupportManager import org.mockito.Mockito class TestApplication : Application(), ReactApplication { - override val reactNativeHost: ReactNativeHost = object : ReactNativeHost(this) { + override val reactNativeHost: ReactNativeHost = object : ReactNativeHost(this@TestApplication) { override fun getUseDeveloperSupport(): Boolean { return true } @@ -24,6 +25,9 @@ class TestApplication : Application(), ReactApplication { setTheme(R.style.Theme_AppCompat) } - override val reactHost: ReactHost - get() = Mockito.mock(ReactHost::class.java) + override val reactHost: ReactHost = Mockito.mock(ReactHost::class.java).apply { + val manager = Mockito.mock(DevSupportManager::class.java) + Mockito.`when`(manager.devSupportEnabled).thenReturn(true) + Mockito.`when`(devSupportManager).thenReturn(manager) + } } diff --git a/android/src/test/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacherTest.kt b/android/src/test/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacherTest.kt new file mode 100644 index 00000000000..4848099abc2 --- /dev/null +++ b/android/src/test/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacherTest.kt @@ -0,0 +1,36 @@ +package com.reactnativenavigation.customrow + +import android.view.ViewGroup +import android.widget.FrameLayout +import com.reactnativenavigation.BaseTest +import com.reactnativenavigation.views.bottomtabs.BottomTabs +import com.reactnativenavigation.views.bottomtabs.CustomBottomTabItemView +import org.assertj.core.api.Java6Assertions.assertThat +import org.junit.Test + +class BottomTabsCustomRowAttacherTest : BaseTest() { + @Test + fun attachesOnlyForCustomItemsAndCleansUpOnDetachAndReattach() { + val activity = newActivity() + val root = FrameLayout(activity) + val tabs = BottomTabs(activity) + root.addView(tabs, FrameLayout.LayoutParams(400, 60)) + activity.setContentView(root) + val host = activity.findViewById(android.R.id.content) + fun rows() = (0 until host.childCount).map { host.getChildAt(it) }.filterIsInstance() + assertThat(rows()).isEmpty() + tabs.setCustomItemViews(listOf(CustomBottomTabItemView(activity, "tab", "Tab", 0, true, null))) + assertThat(rows()).hasSize(1) + BottomTabsCustomRowAttacher.attach(tabs) + assertThat(rows()).hasSize(1) + root.removeView(tabs) + assertThat(rows()).isEmpty() + assertThat(tabs.alpha).isEqualTo(1f) + root.addView(tabs) + assertThat(rows()).hasSize(1) + tabs.setCustomItemViews(emptyList()) + assertThat(rows()).isEmpty() + assertThat(tabs.alpha).isEqualTo(1f) + root.removeView(tabs) + } +} diff --git a/android/src/test/java/com/reactnativenavigation/react/JsDevReloadHandlerTest.java b/android/src/test/java/com/reactnativenavigation/react/JsDevReloadHandlerTest.java new file mode 100644 index 00000000000..242cfd7525d --- /dev/null +++ b/android/src/test/java/com/reactnativenavigation/react/JsDevReloadHandlerTest.java @@ -0,0 +1,65 @@ +package com.reactnativenavigation.react; + +import android.app.Activity; +import android.content.Intent; +import com.facebook.react.devsupport.interfaces.DevSupportManager; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowLooper; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {28, 33, 34}, manifest = Config.NONE) +public class JsDevReloadHandlerTest { + private Activity activity; + private DevSupportManager devSupportManager; + private JsDevReloadHandler handler; + + @Before + public void setUp() { + activity = Robolectric.buildActivity(Activity.class).setup().get(); + devSupportManager = mock(DevSupportManager.class); + handler = new JsDevReloadHandler(devSupportManager); + } + + @Test + public void resumedActivityReceivesDevelopmentReloadBroadcast() { + handler.onActivityResumed(activity); + activity.sendBroadcast(new Intent("com.reactnativenavigation.broadcast.RELOAD")); + ShadowLooper.idleMainLooper(); + + verify(devSupportManager).handleReloadJS(); + handler.onActivityPaused(activity); + } + + @Test + public void pausedActivityNoLongerReceivesReloadBroadcast() { + handler.onActivityResumed(activity); + handler.onActivityPaused(activity); + activity.sendBroadcast(new Intent("com.reactnativenavigation.broadcast.RELOAD")); + ShadowLooper.idleMainLooper(); + + verify(devSupportManager, never()).handleReloadJS(); + } + + @Test + public void removingListenerDoesNotCrashPendingReloadCallbacks() { + JsDevReloadHandler.ReloadListener listener = mock(JsDevReloadHandler.ReloadListener.class); + handler.setReloadListener(listener); + handler.removeReloadListener(listener); + handler.onSuccess(); + handler.onActivityResumed(activity); + activity.sendBroadcast(new Intent("com.reactnativenavigation.broadcast.RELOAD")); + ShadowLooper.idleMainLooper(); + + verify(listener, never()).onReload(); + verify(devSupportManager).handleReloadJS(); + handler.onActivityPaused(activity); + } +} diff --git a/android/src/test/java/com/reactnativenavigation/react/modal/ModalViewManagerTest.kt b/android/src/test/java/com/reactnativenavigation/react/modal/ModalViewManagerTest.kt new file mode 100644 index 00000000000..5cedfe14968 --- /dev/null +++ b/android/src/test/java/com/reactnativenavigation/react/modal/ModalViewManagerTest.kt @@ -0,0 +1,30 @@ +package com.reactnativenavigation.react.modal + +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.reactnativenavigation.BaseTest +import com.reactnativenavigation.NavigationActivity +import com.reactnativenavigation.viewcontrollers.navigator.Navigator +import org.junit.Test +import org.mockito.kotlin.* + +class ModalViewManagerTest : BaseTest() { + @Test + fun updatingModalPropsDoesNotPresentTheSameControllerTwice() { + val navigator = mock() + val activity = mock() + whenever(activity.navigator).thenReturn(navigator) + val context = mock() + whenever(context.currentActivity).thenReturn(activity) + val modal = mock() + val controller = mock() + whenever(modal.viewController).thenReturn(controller) + whenever(modal.isPresented).thenCallRealMethod() + doCallRealMethod().whenever(modal).isPresented = true + val manager = ModalViewManager(context) + manager.updateProperties(modal, ReactStylesDiffMap(JavaOnlyMap())) + manager.updateProperties(modal, ReactStylesDiffMap(JavaOnlyMap())) + verify(navigator, times(1)).showModal(eq(controller), any()) + } +} diff --git a/android/src/test/java/com/reactnativenavigation/utils/ContextTest.kt b/android/src/test/java/com/reactnativenavigation/utils/ContextTest.kt new file mode 100644 index 00000000000..d13e59982c1 --- /dev/null +++ b/android/src/test/java/com/reactnativenavigation/utils/ContextTest.kt @@ -0,0 +1,40 @@ +package com.reactnativenavigation.utils + +import android.app.Application +import android.content.Context +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeHost +import com.facebook.react.devsupport.interfaces.DevSupportManager +import org.assertj.core.api.Java6Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ContextTest { + @Test + fun readsDeveloperSupportFromReactHostWithoutAccessingLegacyHost() { + val manager = mock() + val host = mock() + whenever(host.devSupportManager).thenReturn(manager) + val application = object : Application(), ReactApplication { + override val reactHost = host + override val reactNativeHost: ReactNativeHost + get() = error("ReactHost-only applications have no legacy host") + } + val context = mock() + whenever(context.applicationContext).thenReturn(application) + + whenever(manager.devSupportEnabled).thenReturn(true) + assertThat(context.isDebug()).isTrue() + whenever(manager.devSupportEnabled).thenReturn(false) + assertThat(context.isDebug()).isFalse() + whenever(host.devSupportManager).thenReturn(null) + assertThat(context.isDebug()).isFalse() + } +} diff --git a/android/src/test/java/com/reactnativenavigation/viewcontrollers/modal/ModalPresenterTest.java b/android/src/test/java/com/reactnativenavigation/viewcontrollers/modal/ModalPresenterTest.java index 200cdca6e26..a1bc86496ad 100644 --- a/android/src/test/java/com/reactnativenavigation/viewcontrollers/modal/ModalPresenterTest.java +++ b/android/src/test/java/com/reactnativenavigation/viewcontrollers/modal/ModalPresenterTest.java @@ -1,7 +1,6 @@ package com.reactnativenavigation.viewcontrollers.modal; import android.app.Activity; -import android.os.Looper; import android.widget.FrameLayout; import com.reactnativenavigation.BaseTest; @@ -23,7 +22,6 @@ import org.junit.Test; import org.mockito.InOrder; import org.mockito.Mockito; -import org.robolectric.Shadows; import androidx.coordinatorlayout.widget.CoordinatorLayout; @@ -88,7 +86,7 @@ public void showModal_noAnimation() { CommandListener listener = spy(new CommandListenerAdapter() { @Override public void onSuccess(String childId) { - Shadows.shadowOf(Looper.getMainLooper()).idle(); + idleMainLooper(); assertThat(modal1.getView().getParent()).isEqualTo(modalsLayout); verify(modal1).onViewWillAppear(); } @@ -223,7 +221,7 @@ public void dismissModal_noAnimation() { disableModalAnimations(modal1); uut.showModal(modal1, root, new CommandListenerAdapter()); - Shadows.shadowOf(Looper.getMainLooper()).idle(); + idleMainLooper(); uut.dismissModal(modal1, root, root, new CommandListenerAdapter()); verify(modal1).onViewDisappear(); verify(modal1).destroy(); @@ -243,7 +241,7 @@ public void dismissModal_previousModalIsAddedBackToHierarchy() { idleMainLooper(); assertThat(modal1.getView().getParent()).isNull(); - Shadows.shadowOf(Looper.getMainLooper()).idle(); + idleMainLooper(); uut.dismissModal(modal2, modal1, root, new CommandListenerAdapter()); assertThat(modal1.getView().getParent()).isNotNull(); idleMainLooper(); diff --git a/android/src/test/java/com/reactnativenavigation/viewcontrollers/stack/StackAnimatorTest.kt b/android/src/test/java/com/reactnativenavigation/viewcontrollers/stack/StackAnimatorTest.kt index 581fbe44006..78153eacd99 100644 --- a/android/src/test/java/com/reactnativenavigation/viewcontrollers/stack/StackAnimatorTest.kt +++ b/android/src/test/java/com/reactnativenavigation/viewcontrollers/stack/StackAnimatorTest.kt @@ -96,6 +96,21 @@ class StackAnimatorTest : BaseTest() { assertThat(child2.view.alpha).isOne() } + @Test + fun popCancelsAPushThatHasNotRenderedYet() { + child2.options.animations.push.waitForRender = Bool(true) + val pushed = mock() + val popped = mock() + uut.push(child2, child1, child2.options, emptyList(), pushed) + uut.pop(child1, child2, child2.options, emptyList(), popped) + child2.onViewWillAppear() + idleMainLooper() + assertThat(commandAnimator.isStarted).isFalse() + assertThat(uut.runningPushAnimations).isEmpty() + verify(pushed).run() + verify(popped).run() + } + @Test fun pop_onlyExitAnimationIsPlayedByDefault() { val onAnimationEnd = mock() @@ -240,4 +255,4 @@ class StackAnimatorTest : BaseTest() { vc.mergeOptions(this) } } -} \ No newline at end of file +} diff --git a/android/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerScopeTest.kt b/android/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerScopeTest.kt new file mode 100644 index 00000000000..fdc902643db --- /dev/null +++ b/android/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerScopeTest.kt @@ -0,0 +1,47 @@ +package com.reactnativenavigation.viewcontrollers.viewcontroller + +import android.animation.AnimatorSet +import com.reactnativenavigation.BaseTest +import com.reactnativenavigation.mocks.SimpleViewController +import com.reactnativenavigation.options.Options +import com.reactnativenavigation.utils.awaitRender +import kotlinx.coroutines.launch +import org.assertj.core.api.Java6Assertions.assertThat +import org.junit.Test +import org.mockito.kotlin.mock + +class ViewControllerScopeTest : BaseTest() { + @Test + fun destroyCancelsRenderWaitsEvenBeforeTheViewWasCreated() { + val controller = SimpleViewController(newActivity(), mock(), "screen", Options()) + var resumed = false + val job = controller.coroutineScope.launch { + controller.awaitRender() + resumed = true + } + controller.destroy() + controller.onViewWillAppear() + idleMainLooper() + assertThat(job.isCancelled).isTrue() + assertThat(resumed).isFalse() + assertThat(controller.isDestroyed).isTrue() + } + + @Test + fun cancellingAPendingAnimationRemovesItsRenderWait() { + val controller = SimpleViewController(newActivity(), mock(), "screen", Options()) + val animator = AnimatorSet() + var prepared = false + var cancelled = 0 + controller.coroutineScope.launchAnimation(animator, { cancelled++ }) { + controller.awaitRender() + prepared = true + } + animator.cancelPendingOrRunning() + controller.onViewWillAppear() + idleMainLooper() + assertThat(prepared).isFalse() + assertThat(cancelled).isEqualTo(1) + controller.destroy() + } +} diff --git a/android/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerTest.java b/android/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerTest.java index a4b48366a16..387a7872f36 100644 --- a/android/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerTest.java +++ b/android/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerTest.java @@ -28,7 +28,8 @@ import org.junit.Ignore; import org.junit.Test; import org.mockito.Mockito; -import org.robolectric.Shadows; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowView; import java.lang.reflect.Field; @@ -188,7 +189,7 @@ public void onAppear_WhenShown() { Assertions.assertThat(spy.getView()).isNotShown(); verify(spy, times(0)).onViewWillAppear(); - Shadows.shadowOf(spy.getView()).setMyParent(mock(ViewParent.class)); + Shadow.extract(spy.getView()).setMyParent(mock(ViewParent.class)); spy.getView().getViewTreeObserver().dispatchOnGlobalLayout(); Assertions.assertThat(spy.getView()).isShown(); @@ -198,7 +199,7 @@ public void onAppear_WhenShown() { @Test public void onAppear_CalledAtMostOnce() { ViewController spy = spy(uut); - Shadows.shadowOf(spy.getView()).setMyParent(mock(ViewParent.class)); + Shadow.extract(spy.getView()).setMyParent(mock(ViewParent.class)); Assertions.assertThat(spy.getView()).isShown(); spy.getView().getViewTreeObserver().dispatchOnGlobalLayout(); spy.getView().getViewTreeObserver().dispatchOnGlobalLayout(); @@ -216,7 +217,7 @@ public void isViewShown_doesNotCreateView() { @Test public void onDisappear_WhenNotShown_AfterOnAppearWasCalled() { ViewController spy = spy(uut); - Shadows.shadowOf(spy.getView()).setMyParent(mock(ViewParent.class)); + Shadow.extract(spy.getView()).setMyParent(mock(ViewParent.class)); Assertions.assertThat(spy.getView()).isShown(); spy.getView().getViewTreeObserver().dispatchOnGlobalLayout(); verify(spy, times(1)).onViewWillAppear(); @@ -231,7 +232,7 @@ public void onDisappear_WhenNotShown_AfterOnAppearWasCalled() { @Test public void onDisappear_CalledAtMostOnce() { ViewController spy = spy(uut); - Shadows.shadowOf(spy.getView()).setMyParent(mock(ViewParent.class)); + Shadow.extract(spy.getView()).setMyParent(mock(ViewParent.class)); Assertions.assertThat(spy.getView()).isShown(); spy.getView().getViewTreeObserver().dispatchOnGlobalLayout(); spy.getView().setVisibility(View.GONE); @@ -247,7 +248,7 @@ public void onDestroy_RemovesGlobalLayoutListener() throws Exception { ViewController spy = spy(uut); View view = spy.getView(); - Shadows.shadowOf(view).setMyParent(mock(ViewParent.class)); + Shadow.extract(view).setMyParent(mock(ViewParent.class)); spy.destroy(); @@ -264,7 +265,7 @@ public void onDestroy_RemovesGlobalLayoutListener() throws Exception { @Test public void onDestroy_CallsOnDisappearIfNeeded() { ViewController spy = spy(uut); - Shadows.shadowOf(spy.getView()).setMyParent(mock(ViewParent.class)); + Shadow.extract(spy.getView()).setMyParent(mock(ViewParent.class)); Assertions.assertThat(spy.getView()).isShown(); spy.getView().getViewTreeObserver().dispatchOnGlobalLayout(); verify(spy, times(1)).onViewWillAppear(); @@ -352,4 +353,3 @@ public void onMeasureChild() { verify(spy).applyTopInset(); } } - diff --git a/android/src/test/java/com/reactnativenavigation/views/element/TransitionAnimatorCreatorTest.kt b/android/src/test/java/com/reactnativenavigation/views/element/TransitionAnimatorCreatorTest.kt new file mode 100644 index 00000000000..525f32ec610 --- /dev/null +++ b/android/src/test/java/com/reactnativenavigation/views/element/TransitionAnimatorCreatorTest.kt @@ -0,0 +1,89 @@ +package com.reactnativenavigation.views.element + +import android.animation.AnimatorSet +import android.animation.ValueAnimator +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import com.reactnativenavigation.options.AnimationOptions +import com.reactnativenavigation.options.LayoutAnimation +import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Java6Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class TransitionAnimatorCreatorTest { + private val context = RuntimeEnvironment.getApplication() + private val parent = FrameLayout(context) + private val overlay = FrameLayout(context) + private val back = View(context) + private val stationary = View(context) + private val front = View(context) + + @Test + fun reparentsInNativeDrawingOrderAndRestoresSiblingIndicesOnEnd() { + val animator = createAnimator() + assertThat(overlay.getChildAt(0)).isSameAs(back) + assertThat(overlay.getChildAt(1)).isSameAs(front) + + animator.start() + animator.end() + assertRestored() + } + + @Test + fun restoresSiblingIndicesOnCancel() { + val animator = createAnimator() + animator.start() + animator.cancel() + assertRestored() + } + + private fun createAnimator(): AnimatorSet = runBlocking { + parent.addView(back) + parent.addView(stationary) + parent.addView(front) + val controller = mock>() + doAnswer { + overlay.addView(it.getArgument(0), it.getArgument(1)) + }.whenever(controller).addOverlay(any(), any()) + doAnswer { + overlay.removeView(it.getArgument(0)) + }.whenever(controller).removeOverlay(any()) + + fun transition(element: View) = mock().apply { + whenever(view).thenReturn(element) + whenever(viewController).thenReturn(controller) + whenever(createAnimators()).thenReturn(ValueAnimator.ofFloat(0f, 1f)) + } + + val transitions = TransitionSet().apply { + add(transition(front)) + add(transition(back)) + } + val layoutAnimation = mock() + val creator = mock() + whenever(creator.create(layoutAnimation, controller, controller)).thenReturn(transitions) + TransitionAnimatorCreator(creator).create( + layoutAnimation, AnimationOptions(), controller, controller + ) + } + + private fun assertRestored() { + assertThat(overlay.childCount).isZero() + assertThat(parent.childCount).isEqualTo(3) + assertThat(parent.getChildAt(0)).isSameAs(back) + assertThat(parent.getChildAt(1)).isSameAs(stationary) + assertThat(parent.getChildAt(2)).isSameAs(front) + } +} diff --git a/android/src/test/java/com/reactnativenavigation/views/element/ViewHierarchyComparatorTest.kt b/android/src/test/java/com/reactnativenavigation/views/element/ViewHierarchyComparatorTest.kt new file mode 100644 index 00000000000..27980597c16 --- /dev/null +++ b/android/src/test/java/com/reactnativenavigation/views/element/ViewHierarchyComparatorTest.kt @@ -0,0 +1,79 @@ +package com.reactnativenavigation.views.element + +import android.view.View +import android.widget.FrameLayout +import org.assertj.core.api.Java6Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ViewHierarchyComparatorTest { + private val context = RuntimeEnvironment.getApplication() + + @Test + fun preservesFabricSiblingOrderRegardlessOfTransitionDeclarationOrder() { + val parent = FrameLayout(context) + val back = View(context) + val front = View(context) + parent.addView(back) + parent.addView(front) + + assertThat(listOf(front, back).sortedWith(ViewHierarchyComparator)) + .containsExactly(back, front) + } + + @Test + fun comparesNestedViewsUsingTheirAncestorOrder() { + val root = FrameLayout(context) + val backParent = FrameLayout(context) + val frontParent = FrameLayout(context) + root.addView(backParent) + root.addView(frontParent) + backParent.addView(View(context)) + val back = View(context) + val front = View(context) + backParent.addView(back) + frontParent.addView(front) + + assertThat(listOf(front, back).sortedWith(ViewHierarchyComparator)) + .containsExactly(back, front) + } + + @Test + fun respectsAndroidElevationBeforeSiblingIndex() { + val parent = FrameLayout(context) + val elevated = View(context).apply { elevation = 4f } + val back = View(context) + parent.addView(elevated) + parent.addView(back) + + assertThat(listOf(elevated, back).sortedWith(ViewHierarchyComparator)) + .containsExactly(back, elevated) + } + + @Test + fun ordersAnAncestorBeforeItsDescendant() { + val root = FrameLayout(context) + val parent = FrameLayout(context) + val child = View(context) + root.addView(parent) + parent.addView(child) + + assertThat(listOf(child, parent).sortedWith(ViewHierarchyComparator)) + .containsExactly(parent, child) + assertThat(ViewHierarchyComparator.compare(child, child)).isZero() + } + + @Test + fun detachedViewsRetainDeclarationOrder() { + val first = View(context) + val second = View(context) + + assertThat(listOf(first, second).sortedWith(ViewHierarchyComparator)) + .containsExactly(first, second) + } +} diff --git a/android/src/test/java/com/reactnativenavigation/views/element/finder/ExistingViewFinderTest.kt b/android/src/test/java/com/reactnativenavigation/views/element/finder/ExistingViewFinderTest.kt new file mode 100644 index 00000000000..6d1cac54307 --- /dev/null +++ b/android/src/test/java/com/reactnativenavigation/views/element/finder/ExistingViewFinderTest.kt @@ -0,0 +1,66 @@ +package com.reactnativenavigation.views.element.finder + +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.os.Looper +import android.widget.FrameLayout +import android.widget.ImageView +import com.reactnativenavigation.BaseTest +import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewControllerScope +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Java6Assertions.assertThat +import org.junit.Test +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.TimeUnit + +class ExistingViewFinderTest : BaseTest() { + @Test + fun acceptsLoadedImagesLargerThanTheirView(): Unit = runBlocking { + val image = ImageView(newActivity()) + image.layout(0, 0, 100, 100) + image.setImageDrawable(BitmapDrawable(image.resources, Bitmap.createBitmap(1000, 1000, Bitmap.Config.ARGB_8888))) + assertThat(ExistingViewFinder().awaitImage(image)).isSameAs(image) + } + + @Test + fun fallsBackWhenTheImageNeverLoads(): Unit = runBlocking { + val scope = ViewControllerScope() + try { + val image = ImageView(newActivity()) + val result = scope.async { ExistingViewFinder().awaitImage(image) } + shadowOf(Looper.getMainLooper()).idleFor(ExistingViewFinder.IMAGE_LOAD_TIMEOUT_MS + 1, TimeUnit.MILLISECONDS) + assertThat(result.isCompleted).isTrue() + assertThat(result.await()).isNull() + image.viewTreeObserver.dispatchOnPreDraw() + } finally { scope.cancel() } + } + + @Test + fun detachingTheImageCompletesTheWait(): Unit = runBlocking { + val activity = newActivity() + val root = FrameLayout(activity) + val image = ImageView(activity) + root.addView(image) + activity.setContentView(root) + val scope = ViewControllerScope() + try { + val result = scope.async { ExistingViewFinder().awaitImage(image) } + root.removeView(image) + assertThat(result.isCompleted).isTrue() + assertThat(result.await()).isNull() + } finally { scope.cancel() } + } + + @Test + fun cancellationDoesNotResumeOnALaterDraw() { + val scope = ViewControllerScope() + val image = ImageView(newActivity()) + val result = scope.async { ExistingViewFinder().awaitImage(image) } + scope.cancel() + image.layout(0, 0, 100, 100) + image.setImageDrawable(BitmapDrawable(image.resources, Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888))) + image.viewTreeObserver.dispatchOnPreDraw() + assertThat(result.isCancelled).isTrue() + } +} diff --git a/autolink/fixtures/rn87/MainApplication.kt.template b/autolink/fixtures/rn87/MainApplication.kt.template new file mode 100644 index 00000000000..24d17ceb4fe --- /dev/null +++ b/autolink/fixtures/rn87/MainApplication.kt.template @@ -0,0 +1,27 @@ +package com.helloworld + +import android.app.Application +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + }, + ) + } + + override fun onCreate() { + super.onCreate() + loadReactNative(this) + } +} diff --git a/autolink/postlink/__snapshots__/applicationLinker.test.js.snap b/autolink/postlink/__snapshots__/applicationLinker.test.js.snap index 282f1e5fc7c..331966856b4 100644 --- a/autolink/postlink/__snapshots__/applicationLinker.test.js.snap +++ b/autolink/postlink/__snapshots__/applicationLinker.test.js.snap @@ -19,7 +19,7 @@ import com.facebook.soloader.SoLoader class MainApplication : NavigationApplication() { override val reactNativeHost: ReactNativeHost = - object : NavigationReactNativeHost(this) { + object : NavigationReactNativeHost(this@MainApplication) { override fun getPackages(): List = PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: diff --git a/autolink/postlink/applicationLinker.js b/autolink/postlink/applicationLinker.js index b77d1496487..bf07e582a5b 100644 --- a/autolink/postlink/applicationLinker.js +++ b/autolink/postlink/applicationLinker.js @@ -106,15 +106,21 @@ class ApplicationLinker { } _extendNavigationHost(applicationContent) { + // Modern templates configure ReactHost directly, without a legacy ReactNativeHost. + if (/override\s+val\s+reactHost\s*:\s*ReactHost\s+by\s+lazy/.test(applicationContent) && + !/override\s+val\s+reactNativeHost\s*:/.test(applicationContent)) { + return applicationContent; + } + if (this._hasAlreadyLinkedNavigationHost(applicationContent)) { warnn(' NavigationReactNativeHost is already used, skipping.'); - return applicationContent; + return applicationContent.replace('NavigationReactNativeHost(this)', 'NavigationReactNativeHost(this@MainApplication)'); } if (this._doesExtendDefaultReactNativeHost(applicationContent)) { debugn(' Changing host implementation to NavigationReactNativeHost'); return applicationContent - .replace('DefaultReactNativeHost(this)', 'NavigationReactNativeHost(this)') + .replace(/DefaultReactNativeHost\(this(?:@MainApplication)?\)/, 'NavigationReactNativeHost(this@MainApplication)') .replace( 'import com.facebook.react.defaults.DefaultReactNativeHost', 'import com.facebook.react.defaults.DefaultReactNativeHost\nimport com.reactnativenavigation.react.NavigationReactNativeHost' @@ -122,7 +128,7 @@ class ApplicationLinker { } else if (this._doesExtendReactNativeHost(applicationContent)) { debugn(' Changing host implementation to NavigationReactNativeHost'); return applicationContent - .replace('ReactNativeHost(this)', 'NavigationReactNativeHost(this)') + .replace(/ReactNativeHost\(this(?:@MainApplication)?\)/, 'NavigationReactNativeHost(this@MainApplication)') .replace( 'import com.facebook.react.ReactNativeHost', 'import com.facebook.react.ReactNativeHost\nimport com.reactnativenavigation.react.NavigationReactNativeHost' @@ -133,15 +139,15 @@ class ApplicationLinker { } _doesExtendReactNativeHost(applicationContent) { - return /\s*ReactNativeHost\(this\)\s*/.test(applicationContent); + return /\s*ReactNativeHost\(this(?:@MainApplication)?\)\s*/.test(applicationContent); } _doesExtendDefaultReactNativeHost(applicationContent) { - return /\s*DefaultReactNativeHost\(this\)\s*/.test(applicationContent); + return /\s*DefaultReactNativeHost\(this(?:@MainApplication)?\)\s*/.test(applicationContent); } _hasAlreadyLinkedNavigationHost(applicationContent) { - return /\s*NavigationReactNativeHost\(this\)\s*/.test(applicationContent); + return /\s*NavigationReactNativeHost\(this(?:@MainApplication)?\)\s*/.test(applicationContent); } _removeSOLoaderInit(applicationContent) { @@ -163,6 +169,13 @@ class ApplicationLinker { } _removeNewArchEntryPointLoad(applicationContent) { + // NavigationApplication.onCreate already initializes React Native. + if (/\bloadReactNative\(this\)/.test(applicationContent)) { + return applicationContent + .replace(/^import com\.facebook\.react\.ReactNativeApplicationEntryPoint\.loadReactNative\r?\n/m, '') + .replace(/^\s*loadReactNative\(this\)\s*;?\s*$/m, ''); + } + if (this._isNewArchEntryPointLoadCalled(applicationContent)) { debugn(' Removing New Architecture entry point load block'); return applicationContent.replace( diff --git a/autolink/postlink/applicationLinker.test.js b/autolink/postlink/applicationLinker.test.js index bf219d25dc4..260a33d4600 100644 --- a/autolink/postlink/applicationLinker.test.js +++ b/autolink/postlink/applicationLinker.test.js @@ -4,6 +4,27 @@ import * as mockHelpers from './__helpers__/fixtures'; jest.mock('./log'); describe('applicationLinker', () => { + it('links the RN 0.87 ReactHost template without introducing a legacy host', () => { + jest.mock('./path', () => ({})); + const ApplicationLinker = require('./applicationLinker'); + const linker = new ApplicationLinker(); + linker.applicationPath = mockHelpers.prepareFixtureDuplicate({ + rnVersion: '87', + userFixtureFileName: 'MainApplication.kt.template', + patchedFixtureFileName: 'rnn-tests_MainApplication87.kt', + }); + linker.link(); + const content = fs.readFileSync(linker.applicationPath, 'utf8'); + expect(content).toContain('class MainApplication : NavigationApplication()'); + expect(content).toContain('override val reactHost: ReactHost by lazy'); + expect(content).toContain('PackageList(this).packages.apply'); + expect(content).not.toContain('loadReactNative'); + expect(content).not.toContain('reactNativeHost'); + expect(linker.navigationHostSuccess).toBe(true); + linker.link(); + expect(fs.readFileSync(linker.applicationPath, 'utf8')).toBe(content); + }); + it('should work for RN 0.77', () => { jest.mock('./path', () => { const mainApplicationPath = mockHelpers.prepareFixtureDuplicate77({ diff --git a/ios/DisplayLinkAnimator.mm b/ios/DisplayLinkAnimator.mm index cf1762bd132..ff019903387 100644 --- a/ios/DisplayLinkAnimator.mm +++ b/ios/DisplayLinkAnimator.mm @@ -1,10 +1,11 @@ #import "DisplayLinkAnimator.h" +#import @implementation DisplayLinkAnimator { NSArray> *_animators; NSMutableArray> *_activeAnimators; CADisplayLink *_displayLink; - NSDate *_startDate; + CFTimeInterval _startTime; CGFloat _duration; } @@ -25,7 +26,7 @@ - (instancetype)initWithDisplayLinkAnimator:(id)dis } - (void)start { - _startDate = NSDate.date; + _startTime = CACurrentMediaTime(); _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(_displayLinkDidTick:)]; [_displayLink addToRunLoop:NSRunLoop.mainRunLoop forMode:NSDefaultRunLoopMode]; @@ -47,7 +48,7 @@ - (void)_displayLinkDidTick:(CADisplayLink *)displayLink { _onStart(); _onStart = nil; } - NSTimeInterval elapsed = [NSDate.date timeIntervalSinceDate:_startDate]; + NSTimeInterval elapsed = MAX(0, displayLink.timestamp - _startTime); if (elapsed > _duration) { [self updateAnimators:_duration]; [self end]; @@ -63,10 +64,11 @@ - (void)_displayLinkDidTick:(CADisplayLink *)displayLink { } - (void)updateAnimators:(NSTimeInterval)elapsed { - for (int i = 0; i < _activeAnimators.count; i++) { + for (NSUInteger i = 0; i < _activeAnimators.count;) { id animator = _activeAnimators[i]; if (elapsed < animator.maxDuration) { [animator updateAnimations:elapsed]; + i++; } else { [self deactivateAnimator:animator]; } diff --git a/ios/RNNAppDelegate.mm b/ios/RNNAppDelegate.mm index da6914a738a..b79bf012952 100644 --- a/ios/RNNAppDelegate.mm +++ b/ios/RNNAppDelegate.mm @@ -2,6 +2,7 @@ #import #import #import +#import #import "RCTAppSetupUtils.h" #if __has_include() @@ -92,9 +93,13 @@ - (BOOL)application:(UIApplication *)application self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self.reactNativeDelegate]; self.reactNativeDelegate.dependencyProvider = [RCTAppDependencyProvider new]; +#if !defined(REACT_NATIVE_VERSION_MINOR) || REACT_NATIVE_VERSION_MINOR < 87 RCTAppSetupPrepareApp(application, YES); +#endif RCTEnableTurboModuleInterop(YES); +#if !defined(REACT_NATIVE_VERSION_MINOR) || REACT_NATIVE_VERSION_MINOR < 87 RCTEnableTurboModuleInteropBridgeProxy(YES); +#endif self.reactNativeFactory.rootViewFactory.reactHost = [self.reactNativeFactory.rootViewFactory createReactHost:launchOptions]; diff --git a/ios/RNNEventEmitter.mm b/ios/RNNEventEmitter.mm index fe03a354ed9..5338e734880 100644 --- a/ios/RNNEventEmitter.mm +++ b/ios/RNNEventEmitter.mm @@ -1,7 +1,7 @@ #import "RNNEventEmitter.h" #import "RNNUtils.h" #import "RNNTurboEventEmitter.h" -#import +#import @implementation RNNEventEmitter { } diff --git a/ios/RNNReactButtonView.h b/ios/RNNReactButtonView.h index 45103d942b3..a3a5ab2210d 100644 --- a/ios/RNNReactButtonView.h +++ b/ios/RNNReactButtonView.h @@ -1,5 +1,5 @@ #import "RNNComponentView.h" -#import +#import #import #import #import diff --git a/ios/RNNReactView.mm b/ios/RNNReactView.mm index 099cb723386..bf52f524db5 100644 --- a/ios/RNNReactView.mm +++ b/ios/RNNReactView.mm @@ -1,9 +1,8 @@ #import "RNNReactView.h" #import "RNNAppDelegate.h" -#import #ifdef RCT_NEW_ARCH_ENABLED -#import +#import #import #import diff --git a/ios/RNNViewLocation.mm b/ios/RNNViewLocation.mm index 2e601239eb5..92391463240 100644 --- a/ios/RNNViewLocation.mm +++ b/ios/RNNViewLocation.mm @@ -1,6 +1,5 @@ #import "RNNViewLocation.h" #import "RNNReactView.h" -#import @implementation RNNViewLocation diff --git a/ios/ReactNativeNavigation.h b/ios/ReactNativeNavigation.h index 0c8f231486f..21ec6b1faa0 100644 --- a/ios/ReactNativeNavigation.h +++ b/ios/ReactNativeNavigation.h @@ -4,7 +4,7 @@ #import #ifdef RCT_NEW_ARCH_ENABLED -#import +#import #import "RNNTurboManager.h" #endif diff --git a/ios/ScreenAnimationController.h b/ios/ScreenAnimationController.h index 4d3c7f96715..46c6f3ff865 100644 --- a/ios/ScreenAnimationController.h +++ b/ios/ScreenAnimationController.h @@ -5,7 +5,7 @@ #import #ifdef RCT_NEW_ARCH_ENABLED -#import +#import #import #endif diff --git a/ios/TurboModules/RNNTurboManager.mm b/ios/TurboModules/RNNTurboManager.mm index 1d74ac0a098..c144f7dc7eb 100644 --- a/ios/TurboModules/RNNTurboManager.mm +++ b/ios/TurboModules/RNNTurboManager.mm @@ -9,7 +9,7 @@ #import "RNNReactComponentRegistry.h" #import "RNNReactRootViewCreator.h" #import "RNNTurboCommandsHandler.h" -#import +#import #import "RNNSplashScreenViewController.h" @interface RNNTurboManager () diff --git a/ios/UITabBar+utils.mm b/ios/UITabBar+utils.mm index 12406b28d55..bc344fc088e 100644 --- a/ios/UITabBar+utils.mm +++ b/ios/UITabBar+utils.mm @@ -4,7 +4,7 @@ #define BADGE_OFFSET 0.2 #define IMAGE_VIEW_TAG 1 -typedef void (*UITabBarButton_layoutSubviews__IMP)(void); +typedef void (*UITabBarButton_layoutSubviews__IMP)(id, SEL); static UITabBarButton_layoutSubviews__IMP original_UITabBarButton_layoutSubviews; @implementation UITabBar (utils) @@ -33,23 +33,27 @@ - (void)swizzleUITabBarButton { + (void)swizzleUITabBarButtonLayoutSubviews { Class UITabBarButtonClass = NSClassFromString(@"UITabBarButton"); + if (!UITabBarButtonClass) return; SEL layoutSubviewsSEL = @selector(layoutSubviews); Method layoutSubviewsMethod = class_getInstanceMethod(UITabBarButtonClass, layoutSubviewsSEL); - IMP layoutSubviewsIMP = method_getImplementation(layoutSubviewsMethod); - - original_UITabBarButton_layoutSubviews = layoutSubviewsIMP; SEL swizzleUITabBarButton_layoutSubviewsSEL = @selector(swizzleUITabBarButton_layoutSubviews); Method swizzleUITabBarButton_layoutSubviewsMethod = class_getInstanceMethod(self, swizzleUITabBarButton_layoutSubviewsSEL); + if (!layoutSubviewsMethod || !swizzleUITabBarButton_layoutSubviewsMethod) return; + + original_UITabBarButton_layoutSubviews = + (UITabBarButton_layoutSubviews__IMP)method_getImplementation(layoutSubviewsMethod); - method_exchangeImplementations(layoutSubviewsMethod, - swizzleUITabBarButton_layoutSubviewsMethod); + // Replace only the button's method, even if its implementation is inherited. + class_replaceMethod(UITabBarButtonClass, layoutSubviewsSEL, + method_getImplementation(swizzleUITabBarButton_layoutSubviewsMethod), + method_getTypeEncoding(layoutSubviewsMethod)); } - (void)swizzleUITabBarButton_layoutSubviews { - original_UITabBarButton_layoutSubviews(); + original_UITabBarButton_layoutSubviews(self, @selector(layoutSubviews)); for (UIView *subView in self.subviews) { if ([subView isKindOfClass:NSClassFromString(@"UITabBarSwappableImageView")]) { subView.center = CGPointMake(subView.center.x, subView.superview.frame.size.height / 2); diff --git a/jest-setup.js b/jest-setup.js index 14630df1d2a..068818a1d1b 100644 --- a/jest-setup.js +++ b/jest-setup.js @@ -17,7 +17,7 @@ const { mockDetox } = require('detox-testing-library-rnn-adapter'); jest.mock('react-native-gesture-handler', () => { return { - gestureHandlerRootHOC: jest.fn(), + GestureHandlerRootView: require('react-native').View, }; }); diff --git a/package.json b/package.json index 5624cb564ca..0f60fa8d895 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "uninstall-android": "cd playground/android && ./gradlew uninstallAll", "clean": "node ./scripts/clean", "prepare": "bob build", + "prepack": "bob build", "start": "node ./scripts/start", "pod-install": "yarn workspace react-native-navigation-playground pod-install", "test-js": "node ./scripts/test-js", @@ -84,7 +85,6 @@ "hoist-non-react-statics": "3.3.2", "lodash": "4.18.1", "prop-types": "15.x.x", - "react-lifecycles-compat": "^3.0.4", "tslib": "1.9.3" }, "devDependencies": { @@ -183,4 +183,4 @@ ] ] } -} \ No newline at end of file +} diff --git a/playground/android/app/build.gradle b/playground/android/app/build.gradle index bbb88c2963b..8c7ed1082de 100644 --- a/playground/android/app/build.gradle +++ b/playground/android/app/build.gradle @@ -11,6 +11,7 @@ def jscFlavor = 'org.webkit:android-jsc:+' android { compileSdkVersion rootProject.ext.get("compileSdkVersion") + ndkVersion rootProject.ext.get("ndkVersion") namespace "com.reactnativenavigation.playground" diff --git a/playground/android/app/src/main/java/com/reactnativenavigation/playground/MainApplication.kt b/playground/android/app/src/main/java/com/reactnativenavigation/playground/MainApplication.kt index 73e9ed4a17a..28598c6ab99 100644 --- a/playground/android/app/src/main/java/com/reactnativenavigation/playground/MainApplication.kt +++ b/playground/android/app/src/main/java/com/reactnativenavigation/playground/MainApplication.kt @@ -15,7 +15,7 @@ class MainApplication : NavigationApplication(mapOf( RNNToggles.TOP_BAR_COLOR_ANIMATION__TABS to true, RNNToggles.TAB_BAR_TRANSLUCENCE to true, )) { - override val reactNativeHost: ReactNativeHost = object : NavigationReactNativeHost(this) { + override val reactNativeHost: ReactNativeHost = object : NavigationReactNativeHost(this@MainApplication) { override fun getJSMainModuleName(): String { return "index" } diff --git a/playground/android/build.gradle b/playground/android/build.gradle index a26e123a221..9af6c430f19 100644 --- a/playground/android/build.gradle +++ b/playground/android/build.gradle @@ -4,12 +4,11 @@ buildscript { apply from: './rninfo.gradle' ext { - // RN 0.79+ uses Kotlin 2.1.x, older versions use 2.0.21 - kotlinVersion = ext.rnInfo.isRN79OrHigher ? '2.1.20' : '2.0.21' + kotlinVersion = ext.rnInfo.minorVersion >= 87 ? '2.2.0' : (ext.rnInfo.isRN79OrHigher ? '2.1.20' : '2.0.21') RNNKotlinVersion = kotlinVersion detoxKotlinVersion = kotlinVersion - compileSdkVersion = 36 - buildToolsVersion = "36.0.0" + compileSdkVersion = ext.rnInfo.minorVersion >= 87 ? 37 : 36 + buildToolsVersion = ext.rnInfo.minorVersion >= 87 ? "37.0.0" : "36.0.0" minSdkVersion = 24 targetSdkVersion = 36 ndkVersion = "27.1.12297006" diff --git a/playground/android/gradle.properties b/playground/android/gradle.properties index 24f4326f621..e93ac3a8709 100644 --- a/playground/android/gradle.properties +++ b/playground/android/gradle.properties @@ -23,6 +23,9 @@ android.enableJetifier=true android.jetifier.ignorelist=bcprov hermesEnabled=true newArchEnabled=true +# Match the RN 0.87 template while third-party libraries migrate to AGP 9. +android.builtInKotlin=false +android.newDsl=false FLIPPER_VERSION=0.191.0 android.experimental.androidTest.numManagedDeviceShards=3 diff --git a/playground/e2e/CustomBottomTabComponent.test.js b/playground/e2e/CustomBottomTabComponent.test.js index 03d055024ae..9d6e21f01e1 100644 --- a/playground/e2e/CustomBottomTabComponent.test.js +++ b/playground/e2e/CustomBottomTabComponent.test.js @@ -1,3 +1,4 @@ +import { device, waitFor } from 'detox'; import Utils from './Utils'; import TestIDs from '../src/testIDs'; @@ -28,6 +29,18 @@ describe.e2e('Custom BottomTab Component', () => { await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_2)).toBeVisible(); }); + it(':android: removes the row with its modal and recreates it on the next presentation', async () => { + await device.pressBack(); + await waitFor(elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_0)) + .not.toExist() + .withTimeout(5000); + await openCustomBottomTabsModal(); + await elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_1).tap(); + await waitFor(elementById(TestIDs.CUSTOM_BOTTOM_TAB_SELECTED_LABEL)) + .toHaveText('Search content') + .withTimeout(3000); + }); + it('mounts the first tab content by default', async () => { await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_SELECTED_LABEL)).toHaveText('Home content'); }); diff --git a/playground/e2e/DeepLinking.test.js b/playground/e2e/DeepLinking.test.js index 242760c3111..34b4f8aa221 100644 --- a/playground/e2e/DeepLinking.test.js +++ b/playground/e2e/DeepLinking.test.js @@ -1,3 +1,4 @@ +/* global device, waitFor */ import Utils from './Utils'; import TestIDs from '../src/testIDs'; @@ -28,7 +29,8 @@ describe.e2e('Deep linking', () => { it('deep-link modal can be dismissed via the close button', async () => { await device.openURL({ url: 'rnnplayground://pushed/42' }); - await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible(); + // OS URL delivery completes asynchronously after Detox's openURL command. + await waitFor(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible().withTimeout(5000); await elementById(TestIDs.DEEP_LINK_CLOSE_BTN).tap(); await expect(elementById(TestIDs.NAVIGATION_SCREEN)).toBeVisible(); }); @@ -37,7 +39,7 @@ describe.e2e('Deep linking', () => { await elementById(TestIDs.SIMULATE_NESTED_DEEP_LINK_BTN).tap(); // The top-of-stack header proves the second Pushed segment mounted; // the nested-route -> multi-segment expansion is what produced it. - await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible(); + await waitFor(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible().withTimeout(5000); }); it('unmatched URL does not present a modal and does not crash', async () => { @@ -49,12 +51,12 @@ describe.e2e('Deep linking', () => { it('OS-delivered URL while running opens the modal', async () => { await device.openURL({ url: 'rnnplayground://pushed/77' }); - await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible(); + await waitFor(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible().withTimeout(5000); }); it('OS-delivered URL with query params opens the modal (reserved keys filtered)', async () => { await device.openURL({ url: 'rnnplayground://pushed/77?ref=test&source=push' }); - await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible(); + await waitFor(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible().withTimeout(5000); }); it('cold-start deep link presents the modal after root mounts', async () => { @@ -62,7 +64,7 @@ describe.e2e('Deep linking', () => { newInstance: true, url: 'rnnplayground://pushed/55', }); - await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible(); + await waitFor(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible().withTimeout(5000); }); it('tapping a notification with a url payload opens the deep link modal', async () => { @@ -70,7 +72,7 @@ describe.e2e('Deep linking', () => { return; } await device.sendUserNotification(NOTIFICATION_PAYLOAD); - await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible(); + await waitFor(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible().withTimeout(5000); }); it('tapping a notification with a nested url payload builds a multi-screen modal', async () => { @@ -78,7 +80,7 @@ describe.e2e('Deep linking', () => { return; } await device.sendUserNotification(NESTED_NOTIFICATION_PAYLOAD); - await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible(); + await waitFor(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible().withTimeout(5000); }); it('cold-start notification tap presents the modal after root mounts', async () => { @@ -89,6 +91,6 @@ describe.e2e('Deep linking', () => { newInstance: true, userNotification: NOTIFICATION_PAYLOAD, }); - await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible(); + await waitFor(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible().withTimeout(5000); }); }); diff --git a/playground/e2e/Orientation.test.js b/playground/e2e/Orientation.test.js index d0154ea2142..49989813980 100644 --- a/playground/e2e/Orientation.test.js +++ b/playground/e2e/Orientation.test.js @@ -1,3 +1,4 @@ +/* global device, element, by */ import Utils from './Utils'; import TestIDs from '../src/testIDs'; @@ -6,24 +7,18 @@ const { elementById } = Utils; describe.e2e(':ios: orientation', () => { beforeEach(async () => { await device.launchApp({ newInstance: true }); - waitForDeviceToSettleAfterOrientationChangeAndroid = (ms) => - new Promise((res) => setTimeout(res, device.getPlatform() === 'ios' ? 0 : 400)); await elementById(TestIDs.NAVIGATION_TAB).tap(); await elementById(TestIDs.SHOW_ORIENTATION_SCREEN).tap(); }); - afterEach(() => { - device.setOrientation('portrait'); - }); + afterEach(() => device.setOrientation('portrait')); it('landscape and portrait array', async () => { await elementById(TestIDs.LANDSCAPE_PORTRAIT_ORIENTATION_BTN).tap(); await expect(element(by.id(TestIDs.PORTRAIT_ELEMENT))).toBeVisible(); await device.setOrientation('landscape'); - waitForDeviceToSettleAfterOrientationChangeAndroid(); await expect(element(by.id(TestIDs.LANDSCAPE_ELEMENT))).toBeVisible(); await device.setOrientation('portrait'); - waitForDeviceToSettleAfterOrientationChangeAndroid(); await expect(element(by.id(TestIDs.PORTRAIT_ELEMENT))).toBeVisible(); await elementById(TestIDs.DISMISS_BTN).tap(); }); diff --git a/playground/e2e/SharedElements.test.js b/playground/e2e/SharedElements.test.js new file mode 100644 index 00000000000..2464c1b4a58 --- /dev/null +++ b/playground/e2e/SharedElements.test.js @@ -0,0 +1,26 @@ +/* global device, waitFor, by */ +import Utils from './Utils'; +import TestIDs from '../src/testIDs'; + +const { elementById, elementByLabel } = Utils; + +describe.e2e('Shared elements', () => { + it(':android: restores nested shared elements after repeated push and pop', async () => { + await device.launchApp({ newInstance: true }); + await elementById(TestIDs.NAVIGATION_TAB).tap(); + await waitFor(elementByLabel('Shared Element (Cocktails)')) + .toBeVisible() + .whileElement(by.id(`${TestIDs.NAVIGATION_SCREEN}_LIST`)) + .scroll(200, 'down'); + await elementByLabel('Shared Element (Cocktails)').tap(); + + // The cocktail image, title and backdrop have different parents and zIndex + // values. A second transition verifies that native child order was restored. + for (let iteration = 0; iteration < 2; iteration++) { + await elementById('1').tap(); + await expect(elementById(TestIDs.COCKTAILS_DETAILS_HEADER)).toBeVisible(); + await device.pressBack(); + await expect(elementById('1')).toBeVisible(); + } + }); +}); diff --git a/playground/e2e/detox.config.js b/playground/e2e/detox.config.js index 3850d41f992..714d029b027 100644 --- a/playground/e2e/detox.config.js +++ b/playground/e2e/detox.config.js @@ -55,13 +55,13 @@ const config = { 'ios.release': { type: 'ios.app', binaryPath: 'ios/DerivedData/playground/Build/Products/Release-iphonesimulator/playground.app', - build: 'RCT_NO_LAUNCH_PACKAGER=true xcodebuild build -scheme playground_release -workspace ios/playground.xcworkspace -sdk iphonesimulator -configuration Release -derivedDataPath ios/DerivedData/playground ONLY_ACTIVE_ARCH=YES -quiet -UseModernBuildSystem=YES', + build: 'RCT_NO_LAUNCH_PACKAGER=true xcodebuild build -scheme playground -workspace ios/playground.xcworkspace -sdk iphonesimulator -configuration Release -derivedDataPath ios/DerivedData/playground ONLY_ACTIVE_ARCH=YES -quiet -UseModernBuildSystem=YES', }, 'android.debug': { type: 'android.apk', binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', start: 'react-native start', - build: 'cd android && ./gradlew app:assembleDebug app:assembleAndroidTest -DtestBuildType=debug', + build: 'cd android && ./gradlew generateCodegenArtifactsFromSchema app:assembleDebug app:assembleAndroidTest -DtestBuildType=debug', reversePorts: [ 8081, ], @@ -69,7 +69,7 @@ const config = { 'android.release': { type: 'android.apk', binaryPath: 'android/app/build/outputs/apk/release/app-release.apk', - build: 'cd android && ./gradlew app:assembleRelease app:assembleAndroidTest -DtestBuildType=release', + build: 'cd android && ./gradlew generateCodegenArtifactsFromSchema app:assembleRelease app:assembleAndroidTest -DtestBuildType=release', }, }, configurations: { @@ -121,4 +121,3 @@ const config = { }; module.exports = config; - diff --git a/playground/ios/NavigationTests/DisplayLinkAnimatorTest.mm b/playground/ios/NavigationTests/DisplayLinkAnimatorTest.mm new file mode 100644 index 00000000000..582920741fd --- /dev/null +++ b/playground/ios/NavigationTests/DisplayLinkAnimatorTest.mm @@ -0,0 +1,49 @@ +#import "DisplayLinkAnimator.h" +#import + +@interface DisplayLinkAnimator (Testing) +- (void)updateAnimators:(NSTimeInterval)elapsed; +@end + +@interface RNNTestDisplayLinkDelegate : NSObject +@property(nonatomic) NSTimeInterval maxDuration; +@property(nonatomic) NSUInteger updates; +@property(nonatomic) NSUInteger endings; +@end + +@implementation RNNTestDisplayLinkDelegate +- (void)updateAnimations:(NSTimeInterval)elapsed { self.updates++; } +- (void)end { self.endings++; } +@end + +@interface DisplayLinkAnimatorTest : XCTestCase +@end + +@implementation DisplayLinkAnimatorTest +- (void)testFinishingAnimatorDoesNotSkipTheNextAnimator { + RNNTestDisplayLinkDelegate *first = [RNNTestDisplayLinkDelegate new]; + first.maxDuration = 1; + RNNTestDisplayLinkDelegate *second = [RNNTestDisplayLinkDelegate new]; + second.maxDuration = 2; + DisplayLinkAnimator *animator = [[DisplayLinkAnimator alloc] + initWithDisplayLinkAnimators:@[ first, second ] duration:2]; + [animator updateAnimators:1]; + XCTAssertEqual(first.endings, 1u); + XCTAssertEqual(second.updates, 1u); + [animator updateAnimators:2]; + XCTAssertEqual(first.endings, 1u); + XCTAssertEqual(second.endings, 1u); +} + +- (void)testAdjacentCompletedAnimatorsEndInTheSameTick { + RNNTestDisplayLinkDelegate *first = [RNNTestDisplayLinkDelegate new]; + RNNTestDisplayLinkDelegate *second = [RNNTestDisplayLinkDelegate new]; + first.maxDuration = second.maxDuration = 1; + DisplayLinkAnimator *animator = [[DisplayLinkAnimator alloc] + initWithDisplayLinkAnimators:@[ first, second ] duration:1]; + [animator updateAnimators:1]; + [animator updateAnimators:2]; + XCTAssertEqual(first.endings, 1u); + XCTAssertEqual(second.endings, 1u); +} +@end diff --git a/playground/ios/NavigationTests/RNNCommandsHandlerTest.mm b/playground/ios/NavigationTests/RNNCommandsHandlerTest.mm index 446e292c3b8..9a76b21ccd1 100644 --- a/playground/ios/NavigationTests/RNNCommandsHandlerTest.mm +++ b/playground/ios/NavigationTests/RNNCommandsHandlerTest.mm @@ -807,8 +807,8 @@ - (void)testDismissModal_shouldResolveTopMostComponentId { RNNLayoutInfo *stackLayoutInfo = [RNNLayoutInfo new]; stackLayoutInfo.componentId = @"stack"; - RNNComponentViewController *child = [OCMockObject - partialMockForObject:[RNNComponentViewController createWithComponentId:@"child"]]; + RNNComponentViewController *child = [RNNComponentViewController createWithComponentId:@"child"]; + id childMock = OCMPartialMock(child); __unused RNNStackController *stack = [[RNNStackController alloc] initWithLayoutInfo:stackLayoutInfo creator:nil @@ -819,7 +819,7 @@ - (void)testDismissModal_shouldResolveTopMostComponentId { childViewControllers:@[ child ]]; OCMStub([self.modalManager dismissModal:OCMArg.any animated:NO completion:OCMArg.invokeBlock]); - OCMStub(child.isModal).andReturn(YES); + OCMStub([childMock isModal]).andReturn(YES); OCMStub([self.layoutManager findComponentForId:@"child"]).andReturn(child); [self.uut dismissModal:@"child" @@ -838,8 +838,8 @@ - (void)testDismissModal_shouldMergeOptions { RNNLayoutInfo *stackLayoutInfo = [RNNLayoutInfo new]; stackLayoutInfo.componentId = @"stack"; - RNNComponentViewController *child = [OCMockObject - partialMockForObject:[RNNComponentViewController createWithComponentId:@"child"]]; + RNNComponentViewController *child = [RNNComponentViewController createWithComponentId:@"child"]; + id childMock = OCMPartialMock(child); __unused RNNStackController *stack = [[RNNStackController alloc] initWithLayoutInfo:stackLayoutInfo creator:nil @@ -849,7 +849,7 @@ - (void)testDismissModal_shouldMergeOptions { eventEmitter:nil childViewControllers:@[ child ]]; - OCMStub(child.isModal).andReturn(YES); + OCMStub([childMock isModal]).andReturn(YES); OCMStub([self.layoutManager findComponentForId:@"child"]).andReturn(child); [[self.modalManager expect] diff --git a/playground/ios/NavigationTests/RNNModalManagerEventHandlerTest.mm b/playground/ios/NavigationTests/RNNModalManagerEventHandlerTest.mm index 5fb694ab7d2..b0966c0d37c 100644 --- a/playground/ios/NavigationTests/RNNModalManagerEventHandlerTest.mm +++ b/playground/ios/NavigationTests/RNNModalManagerEventHandlerTest.mm @@ -21,8 +21,7 @@ - (void)setUp { - (void)testDismissedModal_shouldEmitEventWithTopMostComponentId { RNNLayoutInfo *stackLayoutInfo = [RNNLayoutInfo new]; stackLayoutInfo.componentId = @"stack"; - RNNComponentViewController *child = [OCMockObject - partialMockForObject:[RNNComponentViewController createWithComponentId:@"child"]]; + RNNComponentViewController *child = [RNNComponentViewController createWithComponentId:@"child"]; __unused RNNStackController *stack = [[RNNStackController alloc] initWithLayoutInfo:stackLayoutInfo creator:nil @@ -40,8 +39,7 @@ - (void)testDismissedModal_shouldEmitEventWithTopMostComponentId { - (void)testAttemptToDismissModal_shouldEmitEventWithTopMostComponentId { RNNLayoutInfo *stackLayoutInfo = [RNNLayoutInfo new]; stackLayoutInfo.componentId = @"stack"; - RNNComponentViewController *child = [OCMockObject - partialMockForObject:[RNNComponentViewController createWithComponentId:@"child"]]; + RNNComponentViewController *child = [RNNComponentViewController createWithComponentId:@"child"]; __unused RNNStackController *stack = [[RNNStackController alloc] initWithLayoutInfo:stackLayoutInfo creator:nil @@ -59,8 +57,7 @@ - (void)testAttemptToDismissModal_shouldEmitEventWithTopMostComponentId { - (void)testDismissedMultipleModals_shouldEmitEventWithTopMostComponentId { RNNLayoutInfo *stackLayoutInfo = [RNNLayoutInfo new]; stackLayoutInfo.componentId = @"stack"; - RNNComponentViewController *child = [OCMockObject - partialMockForObject:[RNNComponentViewController createWithComponentId:@"child"]]; + RNNComponentViewController *child = [RNNComponentViewController createWithComponentId:@"child"]; __unused RNNStackController *stack = [[RNNStackController alloc] initWithLayoutInfo:stackLayoutInfo creator:nil diff --git a/playground/ios/NavigationTests/RNNRootViewControllerTest.mm b/playground/ios/NavigationTests/RNNRootViewControllerTest.mm index 195677a01ba..f9c601225ac 100644 --- a/playground/ios/NavigationTests/RNNRootViewControllerTest.mm +++ b/playground/ios/NavigationTests/RNNRootViewControllerTest.mm @@ -635,9 +635,11 @@ - (void)testTopBarNoBorderOff { self.options.topBar.noBorder = [[Bool alloc] initWithValue:topBarNoBorderInput]; __unused RNNStackController *nav = [self createNavigationController]; [self.uut viewWillAppear:false]; - XCTAssertTrue(CGColorEqualToColor( - self.uut.navigationController.navigationBar.standardAppearance.shadowColor.CGColor, - [UINavigationBarAppearance new].shadowColor.CGColor)); + // RNN applies the border to the screen's appearance, as in testTopBarNoBorderOn. + // UIKit can represent its default shadow with nil. + XCTAssertEqualObjects( + self.uut.navigationItem.standardAppearance.shadowColor, + [UINavigationBarAppearance new].shadowColor); } - (void)testStatusBarBlurOn { diff --git a/playground/ios/NavigationTests/UITabBarController+RNNOptionsTest.mm b/playground/ios/NavigationTests/UITabBarController+RNNOptionsTest.mm index 9d47153b4d7..5c777628eb4 100644 --- a/playground/ios/NavigationTests/UITabBarController+RNNOptionsTest.mm +++ b/playground/ios/NavigationTests/UITabBarController+RNNOptionsTest.mm @@ -23,6 +23,15 @@ - (void)test_centerTabItems { [(id)self.uut.tabBar verify]; } +- (void)test_centerTabItemsLaysOutRealTabBarButtons { + UITabBar *tabBar = [[UITabBar alloc] initWithFrame:CGRectMake(0, 0, 390, 83)]; + tabBar.items = @[ [[UITabBarItem alloc] initWithTitle:@"Tab" image:[UIImage systemImageNamed:@"star"] tag:0] ]; + [tabBar centerTabItems]; + [tabBar setNeedsLayout]; + XCTAssertNoThrow([tabBar layoutIfNeeded]); + XCTAssertNil(tabBar.items.firstObject.title); +} + - (void)test_tabBarTranslucent_true { [self.uut setTabBarTranslucent:YES]; XCTAssertTrue(self.uut.tabBar.translucent); diff --git a/playground/ios/Podfile b/playground/ios/Podfile index 1d62af0704f..cb8a2204ac1 100644 --- a/playground/ios/Podfile +++ b/playground/ios/Podfile @@ -41,6 +41,15 @@ target 'playground' do pod 'HMSegmentedControl' + # The host app already links React Native and the native modules. Test bundles + # only need their headers; linking them again loads duplicate Objective-C classes. + ['NavigationTests', 'NavigationIOS12Tests', 'SnapshotTests'].each do |test_target| + target test_target do + inherit! :search_paths + pod 'OCMock' + end + end + post_install do |installer| # https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202 react_native_post_install( @@ -63,36 +72,3 @@ target 'playground' do end end end - -target 'NavigationTests' do - config = use_native_modules! - - use_react_native!( - :path => config[:reactNativePath], - :app_path => "#{Pod::Config.instance.installation_root}/.." - ) - - pod 'OCMock' -end - -target 'NavigationIOS12Tests' do - config = use_native_modules! - - use_react_native!( - :path => config[:reactNativePath], - :app_path => "#{Pod::Config.instance.installation_root}/.." - ) - - pod 'OCMock' -end - -target 'SnapshotTests' do - config = use_native_modules! - - use_react_native!( - :path => config[:reactNativePath], - :app_path => "#{Pod::Config.instance.installation_root}/.." - ) - - pod 'OCMock' -end diff --git a/playground/ios/playground.xcodeproj/project.pbxproj b/playground/ios/playground.xcodeproj/project.pbxproj index 4e76a4d4b3f..51ab8f0acb3 100644 --- a/playground/ios/playground.xcodeproj/project.pbxproj +++ b/playground/ios/playground.xcodeproj/project.pbxproj @@ -69,6 +69,7 @@ E58D26532385888C003F36BA /* RNNSideMenuPresenterTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = E58D26362385888B003F36BA /* RNNSideMenuPresenterTest.mm */; }; E58D26542385888C003F36BA /* RNNSideMenuControllerTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = E58D26372385888B003F36BA /* RNNSideMenuControllerTest.mm */; }; E58D26552385888C003F36BA /* RNNTransitionStateHolderTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = E58D26382385888B003F36BA /* RNNTransitionStateHolderTest.mm */; }; + C08727010000000000000001 /* DisplayLinkAnimatorTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = C08727010000000000000002 /* DisplayLinkAnimatorTest.mm */; }; E58D26582385888C003F36BA /* UITabBarController+RNNOptionsTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = E58D263B2385888C003F36BA /* UITabBarController+RNNOptionsTest.mm */; }; E58D26592385888C003F36BA /* RNNNavigationStackManagerTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = E58D263C2385888C003F36BA /* RNNNavigationStackManagerTest.mm */; }; E58D265A2385888C003F36BA /* RNNTestRootViewCreator.mm in Sources */ = {isa = PBXBuildFile; fileRef = E58D263D2385888C003F36BA /* RNNTestRootViewCreator.mm */; }; @@ -197,6 +198,7 @@ E58D26362385888B003F36BA /* RNNSideMenuPresenterTest.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNNSideMenuPresenterTest.mm; sourceTree = ""; }; E58D26372385888B003F36BA /* RNNSideMenuControllerTest.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNNSideMenuControllerTest.mm; sourceTree = ""; }; E58D26382385888B003F36BA /* RNNTransitionStateHolderTest.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNNTransitionStateHolderTest.mm; sourceTree = ""; }; + C08727010000000000000002 /* DisplayLinkAnimatorTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = DisplayLinkAnimatorTest.mm; sourceTree = ""; }; E58D263A2385888C003F36BA /* RNNBottomTabsPresenterTest.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNNBottomTabsPresenterTest.mm; sourceTree = ""; }; E58D263B2385888C003F36BA /* UITabBarController+RNNOptionsTest.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = "UITabBarController+RNNOptionsTest.mm"; sourceTree = ""; }; E58D263C2385888C003F36BA /* RNNNavigationStackManagerTest.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNNNavigationStackManagerTest.mm; sourceTree = ""; }; @@ -430,6 +432,7 @@ E58D26342385888B003F36BA /* RNNTestRootViewCreator.h */, E58D263D2385888C003F36BA /* RNNTestRootViewCreator.mm */, E58D26382385888B003F36BA /* RNNTransitionStateHolderTest.mm */, + C08727010000000000000002 /* DisplayLinkAnimatorTest.mm */, E58D263B2385888C003F36BA /* UITabBarController+RNNOptionsTest.mm */, E58D26252385888B003F36BA /* UIViewController+LayoutProtocolTest.mm */, E58D263E2385888C003F36BA /* UIViewController+RNNOptionsTest.mm */, @@ -494,8 +497,6 @@ 507C80D62429111F00F765F7 /* Sources */, 507C80D72429111F00F765F7 /* Frameworks */, 507C80D82429111F00F765F7 /* Resources */, - AB86EBB5F0AF15B43F3472FD /* [CP] Copy Pods Resources */, - D85A6267FC9FE5A55766B261 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -515,8 +516,6 @@ 50996C5923AA46DD00008F89 /* Sources */, 50996C5A23AA46DD00008F89 /* Frameworks */, 50996C5B23AA46DD00008F89 /* Resources */, - EB65541BD6775B84E7C8AEDD /* [CP] Copy Pods Resources */, - 72BA3B0BE92EC7CD48134759 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -536,8 +535,6 @@ E58D2617238587F4003F36BA /* Sources */, E58D2618238587F4003F36BA /* Frameworks */, E58D2619238587F4003F36BA /* Resources */, - 8A0391DE1BA0B3DE9D40DE96 /* [CP] Copy Pods Resources */, - AB7C03026562E29128D8F500 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -760,90 +757,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 72BA3B0BE92EC7CD48134759 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-NavigationIOS12Tests/Pods-NavigationIOS12Tests-frameworks.sh", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermesvm.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NavigationIOS12Tests/Pods-NavigationIOS12Tests-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 8A0391DE1BA0B3DE9D40DE96 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-NavigationTests/Pods-NavigationTests-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NavigationTests/Pods-NavigationTests-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - AB7C03026562E29128D8F500 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-NavigationTests/Pods-NavigationTests-frameworks.sh", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermesvm.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NavigationTests/Pods-NavigationTests-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - AB86EBB5F0AF15B43F3472FD /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SnapshotTests/Pods-SnapshotTests-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SnapshotTests/Pods-SnapshotTests-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; D80DD80E880A67F5575078C4 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -866,48 +779,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - D85A6267FC9FE5A55766B261 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SnapshotTests/Pods-SnapshotTests-frameworks.sh", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermesvm.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SnapshotTests/Pods-SnapshotTests-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - EB65541BD6775B84E7C8AEDD /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-NavigationIOS12Tests/Pods-NavigationIOS12Tests-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NavigationIOS12Tests/Pods-NavigationIOS12Tests-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -980,6 +851,7 @@ E58D265A2385888C003F36BA /* RNNTestRootViewCreator.mm in Sources */, E58D26492385888C003F36BA /* RNNFontAttributesCreatorTest.mm in Sources */, E58D26552385888C003F36BA /* RNNTransitionStateHolderTest.mm in Sources */, + C08727010000000000000001 /* DisplayLinkAnimatorTest.mm in Sources */, E58D26462385888C003F36BA /* UIViewController+LayoutProtocolTest.mm in Sources */, E58D26512385888C003F36BA /* RNNExternalComponentStoreTest.mm in Sources */, E58D265E2385888C003F36BA /* RNNCommandsHandlerTest.mm in Sources */, @@ -1244,6 +1116,7 @@ "$(inherited)", "$(SRCROOT)/../../ios/**", "$(SRCROOT)/NavigationTests", + "${PODS_ROOT}/Headers/Private", ); INFOPLIST_FILE = SnapshotTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -1276,6 +1149,7 @@ "$(inherited)", "$(SRCROOT)/../../ios/**", "$(SRCROOT)/NavigationTests", + "${PODS_ROOT}/Headers/Private", ); INFOPLIST_FILE = SnapshotTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; diff --git a/playground/package.json b/playground/package.json index a4dac6abbad..a17b984f48c 100644 --- a/playground/package.json +++ b/playground/package.json @@ -19,7 +19,6 @@ "hoist-non-react-statics": "^3.3.2", "lodash": "4.18.1", "prop-types": "15.x.x", - "react-lifecycles-compat": "^3.0.4", "react-native-redash": "^12.6.1", "react-native-webview": "^13.12.5", "reanimated-color-picker": "^3.0.6", diff --git a/playground/src/app.ts b/playground/src/app.ts index e8a077eac4e..3791b54c836 100644 --- a/playground/src/app.ts +++ b/playground/src/app.ts @@ -4,19 +4,6 @@ import { registerScreens } from './screens'; import addProcessors from './commons/Processors'; import { setDefaultOptions } from './commons/options/Options'; import testIDs from './testIDs'; -import Screens from './screens/Screens'; - -// @ts-ignore -alert = (title, message) => - Navigation.showOverlay({ - component: { - name: Screens.Alert, - passProps: { - title, - message, - }, - }, - }); function start() { registerScreens(); diff --git a/playground/src/components/withGestureHandlerRoot.test.tsx b/playground/src/components/withGestureHandlerRoot.test.tsx new file mode 100644 index 00000000000..de1c2c9a665 --- /dev/null +++ b/playground/src/components/withGestureHandlerRoot.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; +import withGestureHandlerRoot from './withGestureHandlerRoot'; + +jest.mock('react-native-gesture-handler', () => ({ + GestureHandlerRootView: require('react-native').View, +})); + +it('forwards screen props and preserves static navigation options', () => { + function Screen({ label }: { label: string }) { + return {label}; + } + Screen.options = { topBar: { visible: false } }; + const Wrapped = withGestureHandlerRoot(Screen); + + expect(Wrapped).toHaveProperty('options', Screen.options); + let renderer!: ReactTestRenderer; + act(() => { + renderer = create(); + }); + expect(renderer.root.findByType(Text).props.children).toBe('Gesture screen'); + act(() => renderer.unmount()); +}); diff --git a/playground/src/components/withGestureHandlerRoot.tsx b/playground/src/components/withGestureHandlerRoot.tsx new file mode 100644 index 00000000000..daf735e1590 --- /dev/null +++ b/playground/src/components/withGestureHandlerRoot.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import hoistNonReactStatics from 'hoist-non-react-statics'; + +const rootStyle = { flex: 1 }; + +// Gesture Handler 3 removes gestureHandlerRootHOC. Keep the same root layout +// and screen statics (including RNN options) on both Gesture Handler 2 and 3. +export default function withGestureHandlerRoot

(Screen: React.ComponentType

) { + function GestureRoot(props: P) { + return ( + + + + ); + } + return hoistNonReactStatics(GestureRoot, Screen); +} diff --git a/playground/src/screens/BottomTabsStylingScreen.tsx b/playground/src/screens/BottomTabsStylingScreen.tsx index cd06fd33d97..5d553a2968a 100644 --- a/playground/src/screens/BottomTabsStylingScreen.tsx +++ b/playground/src/screens/BottomTabsStylingScreen.tsx @@ -2,11 +2,8 @@ import React, { Component } from 'react'; import { NavigationProps, Options } from 'react-native-navigation'; import { Text, StyleSheet, View, ScrollView, Platform } from 'react-native'; -import Root from '../components/Root'; import Button from '../components/Button'; import Navigation from '../services/Navigation'; -import Screens from './Screens'; -import { stack } from '../commons/Layouts'; const isIOS26 = Platform.OS === 'ios' && parseInt(Platform.Version as string, 10) >= 26; diff --git a/playground/src/screens/ButtonsScreen.tsx b/playground/src/screens/ButtonsScreen.tsx index d00d8434dd0..edc6bf72512 100644 --- a/playground/src/screens/ButtonsScreen.tsx +++ b/playground/src/screens/ButtonsScreen.tsx @@ -1,4 +1,3 @@ -/* eslint-disable prettier/prettier */ import React from 'react'; import { Platform } from 'react-native'; import { NavigationComponent, Options, OptionsTopBarButton } from 'react-native-navigation'; diff --git a/playground/src/screens/FirstBottomTabScreen.tsx b/playground/src/screens/FirstBottomTabScreen.tsx index c8d1164c543..0be5fda401d 100644 --- a/playground/src/screens/FirstBottomTabScreen.tsx +++ b/playground/src/screens/FirstBottomTabScreen.tsx @@ -1,3 +1,4 @@ +import showAlert from '../services/Alert'; import React, { Component } from 'react'; import { EmitterSubscription, Platform, Text } from 'react-native'; import { NavigationProps, Options } from 'react-native-navigation'; @@ -80,7 +81,7 @@ export default class FirstBottomTabScreen extends Component { return Navigation.events().registerBottomTabPressedListener((event) => { if (event.tabIndex == 2) { - alert('BottomTabPressed'); + showAlert('BottomTabPressed'); } }); }; diff --git a/playground/src/screens/FlatListScreen.tsx b/playground/src/screens/FlatListScreen.tsx index 06ee41802cb..d78ab148449 100644 --- a/playground/src/screens/FlatListScreen.tsx +++ b/playground/src/screens/FlatListScreen.tsx @@ -1,3 +1,4 @@ +import showAlert from '../services/Alert'; import React from 'react'; import { FlatList, View, Text } from 'react-native'; import { @@ -92,7 +93,7 @@ export default class FlatListScreen extends NavigationComponent { } componentDidDisappear() { - this.showUnmountAndDisappearAlerts && alert('didDisappear'); // eslint-disable-line no-alert + this.showUnmountAndDisappearAlerts && showAlert('didDisappear'); } componentWillUnmount() { setTimeout(() => { - this.showUnmountAndDisappearAlerts && alert('componentWillUnmount'); // eslint-disable-line no-alert + this.showUnmountAndDisappearAlerts && showAlert('componentWillUnmount'); }, 100); } navigationButtonPressed(id: NavigationButtonPressedEvent) { - alert(`navigationButtonPressed: ${id}`); // eslint-disable-line no-alert + showAlert(`navigationButtonPressed: ${id}`); } render() { @@ -87,7 +88,7 @@ export default class LifecycleScreen extends React.Component { this.showUnmountAndDisappearAlerts = false; const promise = new Promise((resolve) => { const unregister = Navigation.events().registerScreenPoppedListener(() => { - alert('Screen popped event'); + showAlert('Screen popped event'); unregister.remove(); resolve(); }); diff --git a/playground/src/screens/OverlayScreen.tsx b/playground/src/screens/OverlayScreen.tsx index fb8d6f28752..4905cbf759a 100644 --- a/playground/src/screens/OverlayScreen.tsx +++ b/playground/src/screens/OverlayScreen.tsx @@ -1,3 +1,4 @@ +import showAlert from '../services/Alert'; import React from 'react'; import { NavigationProps } from 'react-native-navigation'; import Root from '../components/Root'; @@ -64,7 +65,7 @@ export default class OverlayScreen extends React.Component { {this.state?.overlayDismissedCount || ''}