Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .buildkite/jobs/pipeline.android_rn_87.yml
Original file line number Diff line number Diff line change
@@ -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/**/*"
11 changes: 11 additions & 0 deletions .buildkite/jobs/pipeline.ios_rn_87.yml
Original file line number Diff line number Diff line change
@@ -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/**/*"
2 changes: 2 additions & 0 deletions .buildkite/pipeline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,7 +79,7 @@ android {
minifyEnabled false
}
}
lintOptions {
lint {
abortOnError false
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -34,4 +34,4 @@ class TestApplication : NavigationApplication() {
override val reactHost: ReactHost
get() = getDefaultReactHost(this, reactNativeHost)

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -47,7 +46,6 @@ public void onCreate() {

reactGateway = createReactGateway();

BottomTabsCustomRowAttacher.INSTANCE.registerOnce(this, null);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -59,4 +54,3 @@ class NavigationPackage() : BaseReactPackage() {
return mutableListOf(ModalViewManager(reactContext))
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<View>(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<View>(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(
Expand Down Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down
Loading