diff --git a/android/src/main/java/com/reactnativenavigation/NavigationApplication.java b/android/src/main/java/com/reactnativenavigation/NavigationApplication.java index ae41ecdf239..a49628613c6 100644 --- a/android/src/main/java/com/reactnativenavigation/NavigationApplication.java +++ b/android/src/main/java/com/reactnativenavigation/NavigationApplication.java @@ -7,6 +7,7 @@ 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; @@ -45,6 +46,8 @@ public void onCreate() { DefaultNewArchitectureEntryPoint.load(); 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 5391b87c121..c4026a19f38 100644 --- a/android/src/main/java/com/reactnativenavigation/NavigationPackage.kt +++ b/android/src/main/java/com/reactnativenavigation/NavigationPackage.kt @@ -7,6 +7,9 @@ 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 import com.reactnativenavigation.react.modal.ModalViewManager @@ -15,10 +18,16 @@ 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)) } + BottomTabsCustomRowModule.NAME -> { + BottomTabsCustomRowModule(context) + } else -> { null } @@ -26,14 +35,24 @@ class NavigationPackage() : BaseReactPackage() { } override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { - mapOf(NavigationTurboModule.NAME to ReactModuleInfo( - name = NavigationTurboModule.NAME, - className = NavigationTurboModule.NAME, - canOverrideExistingModule = false, - needsEagerInit = false, - isCxxModule = false, - isTurboModule = true - )) + mapOf( + NavigationTurboModule.NAME to ReactModuleInfo( + name = NavigationTurboModule.NAME, + className = NavigationTurboModule.NAME, + canOverrideExistingModule = false, + needsEagerInit = false, + isCxxModule = false, + isTurboModule = true + ), + BottomTabsCustomRowModule.NAME to ReactModuleInfo( + name = BottomTabsCustomRowModule.NAME, + className = BottomTabsCustomRowModule.NAME, + canOverrideExistingModule = false, + needsEagerInit = true, + isCxxModule = false, + isTurboModule = false + ) + ) } override fun createViewManagers(reactContext: ReactApplicationContext): List> { diff --git a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRow.kt b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRow.kt new file mode 100644 index 00000000000..8764ad4a1a5 --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRow.kt @@ -0,0 +1,262 @@ +package com.reactnativenavigation.customrow + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.Outline +import android.graphics.RenderEffect +import android.graphics.Shader +import android.os.Build +import android.util.Log +import android.util.TypedValue +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.FrameLayout +import com.reactnativenavigation.views.bottomtabs.BottomTabs +import com.reactnativenavigation.views.bottomtabs.CustomBottomTabItemView + +/** + * Floating row that hosts the React-rendered `CustomBottomTabItemView` + * cells produced by RNN's existing custom-tab path. Mimics the iOS + * `RNNBottomTabsCustomRow` (Approach B): the underlying `BottomTabs` view + * is kept for state but its visuals are hidden; this view is the only + * thing the user sees. + * + * Strict zero-touch on the existing tabs implementation: only public APIs + * of `BottomTabs` (`hasCustomItemViews`, `getCustomItemView`, + * `getItemsCount`, `setCurrentItem`) are used, plus standard `View` + * methods. + */ +@SuppressLint("ViewConstructor") +class BottomTabsCustomRow( + context: Context, + private val bottomTabs: BottomTabs, +) : FrameLayout(context) { + + private val cells = mutableListOf() + private var currentOptions: BottomTabsCustomRowOptions = BottomTabsCustomRowConfigStore.get() + private var selectedIndex: Int = bottomTabs.currentItem + private var safeBottomInsetPx: Int = 0 + + /** + * Visible chrome (background colour, rounded corners, shadow) that also + * hosts the cell views as children. Lives as a dedicated child of the + * row so it can be inset from the row's bottom by + * `safeBottom + bottomMargin` and only paint over the content area — + * mirrors iOS's `backgroundColorView` / `backgroundEffectView`. Hosting + * the cells inside it ensures cells render *above* the chrome regardless + * of elevation. + */ + private val backgroundView: FrameLayout = FrameLayout(context).apply { + clipToOutline = true + clipChildren = true + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + val r = effectiveCornerRadiusPx() + outline.setRoundRect(0, 0, view.width, view.height, r) + } + } + } + + private val configListener: (BottomTabsCustomRowOptions) -> Unit = { opts -> + post { applyOptions(opts) } + } + + init { + setWillNotDraw(true) + clipChildren = false + clipToPadding = false + addView(backgroundView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + BottomTabsCustomRowConfigStore.addListener(configListener) + applyOptions(currentOptions) + rebuildCells() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + BottomTabsCustomRowConfigStore.removeListener(configListener) + } + + fun rebuildCells() { + for (cell in cells) { + (cell.parent as? ViewGroup)?.removeView(cell) + } + cells.clear() + if (!bottomTabs.hasCustomItemViews()) return + + val count = bottomTabs.itemsCount + for (i in 0 until count) { + val itemView = bottomTabs.getCustomItemView(i) ?: continue + (itemView.parent as? ViewGroup)?.removeView(itemView) + val cell = Cell(context, i, itemView).also { + it.setOnClickListener { _ -> + bottomTabs.setCurrentItem(i, true) + setSelectedIndex(i) + } + } + backgroundView.addView( + cell, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + cells.add(cell) + } + setSelectedIndex(selectedIndex) + requestLayout() + } + + fun setSelectedIndex(index: Int) { + selectedIndex = index + for (cell in cells) cell.itemView.setItemSelected(cell.index == index) + } + + fun applyOptions(options: BottomTabsCustomRowOptions) { + currentOptions = options + + val solidColor = options.backgroundColor + val effect = options.backgroundEffect + val backgroundColorToUse = solidColor + ?: if (effect == BottomTabsCustomRowOptions.BackgroundEffect.None) + Color.TRANSPARENT + else + materialChromeColor() + + backgroundView.setBackgroundColor(backgroundColorToUse) + + // NOTE: Android does not expose a true blur-behind API for arbitrary + // views (the `RenderEffect.createBlurEffect` API blurs the view's own + // rendered content, which would smear the cells we host). For + // `glass` / `blur` we therefore render an opaque-ish chrome material + // colour; a future enhancement can swap this for `eightbitlab/ + // BlurView` to get true blur-behind on Android. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + backgroundView.setRenderEffect(null) + } + + // iOS gets a soft visual lift from `UIGlassEffect`'s built-in ring + + // highlight. Android has no equivalent, so we emulate it with a + // low-elevation shadow tinted at low alpha. Hard `elevation = 8` (the + // Material default) looks much heavier than the iOS reference, so + // we stay subtle: ~3dp elevation, ~30% / 12% shadow alpha. + backgroundView.elevation = dp(3f) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + backgroundView.outlineSpotShadowColor = Color.argb(0x4C, 0, 0, 0) + backgroundView.outlineAmbientShadowColor = Color.argb(0x1E, 0, 0, 0) + } + backgroundView.invalidateOutline() + requestLayout() + } + + private fun materialChromeColor(): Int { + val typed = TypedValue() + val resolved = context.theme.resolveAttribute( + android.R.attr.colorBackground, typed, true + ) + val base = if (resolved && typed.type >= TypedValue.TYPE_FIRST_COLOR_INT && + typed.type <= TypedValue.TYPE_LAST_COLOR_INT + ) typed.data else Color.WHITE + return alphaOver(base, 0xF0) + } + + private fun alphaOver(color: Int, alpha: Int): Int { + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) + } + + private fun effectiveCornerRadiusPx(): Float { + val dp = currentOptions.cornerRadius ?: 28f + return dp(dp) + } + + /** + * Content-area height (where cells live), excluding the bottom safe-area + * inset and bottomMargin. + */ + fun effectiveContentHeightPx(nativeBarHeightPx: Int): Int { + val heightOption = currentOptions.height + if (heightOption != null) { + val configured = dp(heightOption).toInt() + // JS height targets iOS floating chrome; keep Android shell tight so + // the pill sits on the tab bar without a tall empty box above nav. + return minOf(configured, nativeBarHeightPx.coerceAtLeast(dp(52f).toInt())) + } + // Default: a touch taller than the native bar's content area to give + // icon + label + pill enough room — mirrors iOS's +18pt default. + val nativeContent = (nativeBarHeightPx - safeBottomInsetPx).coerceAtLeast(0) + return (nativeContent + dp(18f)).toInt() + } + + /** + * Total row height including the bottom safe-area inset and bottomMargin + * — mirrors iOS's `desiredRowHeightForNativeTabBarHeight:safeBottom:`. + */ + fun effectiveTotalHeightPx(nativeBarHeightPx: Int): Int = + effectiveContentHeightPx(nativeBarHeightPx) + safeBottomInsetPx + effectiveBottomMarginPx() + + fun effectiveHorizontalMarginPx(): Int = dp(currentOptions.horizontalMargin ?: 16f).toInt() + fun effectiveBottomMarginPx(): Int = dp(currentOptions.bottomMargin ?: 0f).toInt() + + fun setSafeBottomInsetPx(insetPx: Int) { + if (safeBottomInsetPx == insetPx) return + safeBottomInsetPx = insetPx + requestLayout() + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + val w = width + // Visible chrome only covers the content area — never the safe-area + // strip below (matches iOS where `backgroundEffectView.frame = + // content` excludes `safe.bottom + bottomMargin`). + val contentBottom = (height - safeBottomInsetPx - effectiveBottomMarginPx()).coerceAtLeast(0) + val bgSpec = MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY) + val bgHeightSpec = MeasureSpec.makeMeasureSpec(contentBottom, MeasureSpec.EXACTLY) + backgroundView.measure(bgSpec, bgHeightSpec) + backgroundView.layout(0, 0, w, contentBottom) + backgroundView.invalidateOutline() + layoutCells(w, contentBottom) + } + + private fun layoutCells(parentWidth: Int, parentHeight: Int) { + if (cells.isEmpty() || parentWidth <= 0 || parentHeight <= 0) return + val per = parentWidth.toFloat() / cells.size.toFloat() + for (i in cells.indices) { + val cell = cells[i] + val l = (i * per).toInt() + val r = ((i + 1) * per).toInt() + val widthSpec = MeasureSpec.makeMeasureSpec(r - l, MeasureSpec.EXACTLY) + val heightSpec = MeasureSpec.makeMeasureSpec(parentHeight, MeasureSpec.EXACTLY) + cell.measure(widthSpec, heightSpec) + // Cells are children of `backgroundView`, laid out in its local + // coordinate space (which is already 0..contentBottom). + cell.layout(l, 0, r, parentHeight) + } + } + + private fun dp(value: Float): Float = + value * resources.displayMetrics.density + + @SuppressLint("ViewConstructor") + private class Cell( + context: Context, + val index: Int, + val itemView: CustomBottomTabItemView, + ) : FrameLayout(context) { + init { + isClickable = true + isFocusable = true + // Host the React item view inside this cell so it inherits taps + // we don't consume. + addView(itemView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + // Bypass the React view's pass-through and let this Cell receive + // the click event directly. + return onTouchEvent(ev) || super.dispatchTouchEvent(ev) + } + } +} diff --git a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacher.kt b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacher.kt new file mode 100644 index 00000000000..064b854cf6e --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacher.kt @@ -0,0 +1,205 @@ +package com.reactnativenavigation.customrow + +import android.app.Activity +import android.app.Application +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.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 + + private data class LastPlacement( + val left: Int, + val top: Int, + val width: Int, + val height: Int, + 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) + } + } + + 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) { + 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) + } + } + ) + } + + private fun tryAttach(activity: Activity) { + val scanRoot = activity.window?.decorView as? ViewGroup + ?: activity.findViewById(android.R.id.content) as? ViewGroup + ?: return + val overlayHost = activity.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) + } + } + + 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 positionRow( + row: BottomTabsCustomRow, + bottomTabs: BottomTabs, + overlayHost: ViewGroup, + activity: Activity, + ) { + val navBarInsetPx = systemBottomInsetPx(overlayHost, bottomTabs) + val placement = BottomTabsCustomRowLayout.resolvePlacement( + activity, + row, + bottomTabs, + overlayHost, + navBarInsetPx, + ) ?: return + + val next = LastPlacement( + placement.left, + placement.top, + placement.width, + placement.height, + placement.rowSafeBottomInsetPx, + ) + if (row.getTag(TAG_LAST_PLACEMENT) == next) { + return + } + row.setTag(TAG_LAST_PLACEMENT, next) + row.setSafeBottomInsetPx(placement.rowSafeBottomInsetPx) + + val lp = (row.layoutParams as? FrameLayout.LayoutParams) + ?: FrameLayout.LayoutParams(placement.width, placement.height) + lp.width = placement.width + lp.height = placement.height + lp.leftMargin = placement.left + lp.topMargin = placement.top + row.layoutParams = lp + row.bringToFront() + } + + private fun systemBottomInsetPx(overlayHost: View, bottomTabs: View): Int { + for (source in listOf(bottomTabs, overlayHost)) { + val insets = source.rootWindowInsets ?: continue + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val navBars = insets.getInsets(WindowInsets.Type.navigationBars()).bottom + if (navBars > 0) return navBars + } else { + @Suppress("DEPRECATION") + val legacy = insets.systemWindowInsetBottom + if (legacy > 0) return legacy + } + } + 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_LAST_PLACEMENT = "rnnCustomRowLastPlacement".hashCode() +} diff --git a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowConfigStore.kt b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowConfigStore.kt new file mode 100644 index 00000000000..14efdf5b6d3 --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowConfigStore.kt @@ -0,0 +1,32 @@ +package com.reactnativenavigation.customrow + +/** + * Process-wide singleton holding the latest custom-row configuration + * pushed from JS via the `RNNBottomTabsCustomRowModule` native module. + * + * The attacher reads from here when it needs to apply chrome to a freshly + * detected `BottomTabs` instance. + */ +object BottomTabsCustomRowConfigStore { + @Volatile + private var current: BottomTabsCustomRowOptions = BottomTabsCustomRowOptions() + + private val listeners = mutableSetOf<(BottomTabsCustomRowOptions) -> Unit>() + + fun update(options: BottomTabsCustomRowOptions) { + current = options + synchronized(listeners) { + listeners.toList().forEach { it.invoke(options) } + } + } + + fun get(): BottomTabsCustomRowOptions = current + + fun addListener(listener: (BottomTabsCustomRowOptions) -> Unit) { + synchronized(listeners) { listeners.add(listener) } + } + + fun removeListener(listener: (BottomTabsCustomRowOptions) -> Unit) { + synchronized(listeners) { listeners.remove(listener) } + } +} diff --git a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowLayout.kt b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowLayout.kt new file mode 100644 index 00000000000..a0c1e2bcec5 --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowLayout.kt @@ -0,0 +1,139 @@ +package com.reactnativenavigation.customrow + +import android.app.Activity +import android.os.Build +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import com.reactnativenavigation.views.bottomtabs.BottomTabs + +/** + * Resolves how the floating custom row should anchor and whether the bottom + * system-bar inset belongs inside the row or was already applied by RNN's + * [com.reactnativenavigation.viewcontrollers.bottomtabs.BottomTabsController]. + * + * On most devices (including Pixel with gesture/3-button nav) RNN pads the + * bottom-tabs controller by `systemBars().bottom`, so the native bar already + * sits above the nav area — adding the same inset again creates a visible gap. + * + * With edge-to-edge (API 35+ theme opt-in) content can extend behind the nav + * bar; the row must pin to the overlay host bottom and reserve inset inside. + */ +internal object BottomTabsCustomRowLayout { + + enum class AnchorMode { + /** Native bar bottom is already above system bars (RNN bottom padding). */ + NATIVE_BAR_ABOVE_SYSTEM_BARS, + /** Row extends to the host bottom; inset is applied inside the row. */ + EDGE_TO_EDGE, + } + + data class Placement( + val anchorMode: AnchorMode, + /** Inset applied inside the row for cell/chrome layout (0 when native bar already cleared it). */ + val rowSafeBottomInsetPx: Int, + val left: Int, + val top: Int, + val width: Int, + val height: Int, + ) + + fun resolvePlacement( + activity: Activity, + row: BottomTabsCustomRow, + bottomTabs: BottomTabs, + overlayHost: ViewGroup, + navBarInsetPx: Int, + ): Placement? { + val nativeHeight = bottomTabs.height + if (nativeHeight <= 0) return null + + val horizontalMargin = row.effectiveHorizontalMarginPx() + val bottomMargin = row.effectiveBottomMarginPx() + + val tabLeftInHost = tabLeftRelativeToHost(bottomTabs, overlayHost) + val tabRightInHost = tabLeftInHost + bottomTabs.width + + // Row is hosted on `android.R.id.content`; anchor to `BottomTabs` bottom + // (above RNN's nav-bar padding). Never use decor.height — that draws over + // the system navigation buttons. + val anchorMode = resolveAnchorMode(activity, bottomTabs, overlayHost, navBarInsetPx) + val rowSafeBottom = 0 + + val contentHeight = row.effectiveContentHeightPx(nativeHeight) + val totalHeight = contentHeight + bottomMargin + + val left = tabLeftInHost + horizontalMargin + val width = (tabRightInHost - horizontalMargin) - left + + // RNN already lays out `BottomTabs` above its bottom padding — match that + // edge. Do not subtract `navBarInsetPx` again (that was lifting the bar). + val bottom = tabBottomRelativeToHost(bottomTabs, overlayHost) - bottomMargin + val top = bottom - totalHeight + + return Placement(anchorMode, rowSafeBottom, left, top, width, totalHeight) + } + + fun resolveAnchorMode( + activity: Activity, + bottomTabs: BottomTabs, + overlayHost: ViewGroup, + @Suppress("UNUSED_PARAMETER") navBarInsetPx: Int, + ): AnchorMode { + // RNN's bottom-tabs host ends at `android.R.id.content` bottom while the + // system nav bar sits below that — never treat "flush with content" as + // edge-to-edge or we reserve a phantom inset and leave a white gap. + if (!isEdgeToEdgeEnabled(activity)) { + return AnchorMode.NATIVE_BAR_ABOVE_SYSTEM_BARS + } + val decor = activity.window?.decorView ?: return AnchorMode.NATIVE_BAR_ABOVE_SYSTEM_BARS + val tolerancePx = dpToPx(activity, 4f) + val tabBottomOnScreen = screenBottom(bottomTabs) + val decorBottomOnScreen = screenBottom(decor) + return if (kotlin.math.abs(tabBottomOnScreen - decorBottomOnScreen) <= tolerancePx) { + AnchorMode.EDGE_TO_EDGE + } else { + AnchorMode.NATIVE_BAR_ABOVE_SYSTEM_BARS + } + } + + private fun screenBottom(view: android.view.View): Int { + val loc = IntArray(2).also(view::getLocationOnScreen) + return loc[1] + view.height + } + + fun isEdgeToEdgeEnabled(activity: Activity): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + return false + } + val typedValue = TypedValue() + val resolved = activity.theme.resolveAttribute( + android.R.attr.windowOptOutEdgeToEdgeEnforcement, + typedValue, + true + ) + return resolved && + typedValue.type == TypedValue.TYPE_INT_BOOLEAN && + typedValue.data == 0 + } + + private fun tabLeftRelativeToHost(bottomTabs: BottomTabs, overlayHost: ViewGroup): Int { + val tabLoc = IntArray(2).also(bottomTabs::getLocationOnScreen) + val hostLoc = IntArray(2).also(overlayHost::getLocationOnScreen) + return tabLoc[0] - hostLoc[0] + } + + private fun tabBottomRelativeToHost(bottomTabs: BottomTabs, overlayHost: ViewGroup): Int { + val tabLoc = IntArray(2).also(bottomTabs::getLocationOnScreen) + val hostLoc = IntArray(2).also(overlayHost::getLocationOnScreen) + val tabTopInHost = tabLoc[1] - hostLoc[1] + return tabTopInHost + bottomTabs.height + } + + private fun dpToPx(activity: Activity, dp: Float): Int = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp, + activity.resources.displayMetrics + ).toInt() +} diff --git a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowModule.kt b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowModule.kt new file mode 100644 index 00000000000..d120908391b --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowModule.kt @@ -0,0 +1,37 @@ +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 +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.module.annotations.ReactModule + +/** + * RN bridge module that lets JS push the latest `bottomTabs.customRow` + * configuration to native. The JS-side `AndroidCustomRowForwarder` + * scans `Navigation.setRoot` / `setDefaultOptions` / `mergeOptions` + * payloads and calls [configure] whenever it finds a `customRow` block. + */ +@ReactModule(name = BottomTabsCustomRowModule.NAME) +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 { + const val NAME = "RNNBottomTabsCustomRowModule" + } +} diff --git a/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowOptions.kt b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowOptions.kt new file mode 100644 index 00000000000..305f0b37246 --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowOptions.kt @@ -0,0 +1,68 @@ +package com.reactnativenavigation.customrow + +import android.graphics.Color +import com.facebook.react.bridge.ReadableMap + +/** + * Mirrors the iOS-side `RNNBottomTabsCustomRowOptions` data shape. All + * fields are optional. Defaults are chosen to give an Android equivalent + * of the iOS 26 floating glass pill on Android 12+ (RenderEffect blur), + * and an opaque material chrome on older versions. + */ +data class BottomTabsCustomRowOptions( + val height: Float? = null, + val backgroundColor: Int? = null, + val backgroundEffect: BackgroundEffect? = null, + val cornerRadius: Float? = null, + val horizontalMargin: Float? = null, + val bottomMargin: Float? = null, +) { + enum class BackgroundEffect { Glass, Blur, None } + + companion object { + fun fromMap(map: ReadableMap?): BottomTabsCustomRowOptions { + if (map == null) return BottomTabsCustomRowOptions() + return BottomTabsCustomRowOptions( + height = map.optFloat("height"), + backgroundColor = map.optColor("backgroundColor"), + backgroundEffect = map.optEffect("backgroundEffect"), + cornerRadius = map.optFloat("cornerRadius"), + horizontalMargin = map.optFloat("horizontalMargin"), + bottomMargin = map.optFloat("bottomMargin"), + ) + } + + private fun ReadableMap.optFloat(key: String): Float? { + if (!hasKey(key) || isNull(key)) return null + return getDouble(key).toFloat() + } + + private fun ReadableMap.optColor(key: String): Int? { + if (!hasKey(key) || isNull(key)) return null + // Color may arrive as a number (Android-style int) or a wrapped + // theme-color object. Support both shallowly. + return when (getType(key)) { + com.facebook.react.bridge.ReadableType.Number -> getInt(key) + com.facebook.react.bridge.ReadableType.Map -> { + val sub = getMap(key) + val light = sub?.let { if (it.hasKey("light")) it.getInt("light") else null } + light ?: sub?.let { if (it.hasKey("color")) it.getInt("color") else null } + } + com.facebook.react.bridge.ReadableType.String -> { + runCatching { Color.parseColor(getString(key)) }.getOrNull() + } + else -> null + } + } + + private fun ReadableMap.optEffect(key: String): BackgroundEffect? { + if (!hasKey(key) || isNull(key)) return null + return when (getString(key)?.lowercase()) { + "glass" -> BackgroundEffect.Glass + "blur" -> BackgroundEffect.Blur + "none" -> BackgroundEffect.None + else -> null + } + } + } +} diff --git a/android/src/main/java/com/reactnativenavigation/options/BottomTabOptions.java b/android/src/main/java/com/reactnativenavigation/options/BottomTabOptions.java index 24e22b0a994..d38d4a452dd 100644 --- a/android/src/main/java/com/reactnativenavigation/options/BottomTabOptions.java +++ b/android/src/main/java/com/reactnativenavigation/options/BottomTabOptions.java @@ -44,6 +44,7 @@ public static BottomTabOptions parse(Context context, TypefaceLoader typefaceMan options.dotIndicator = DotIndicatorOptions.parse(context, json.optJSONObject("dotIndicator")); options.selectTabOnPress = BoolParser.parse(json, "selectTabOnPress"); options.popToRoot = BoolParser.parse(json, "popToRoot"); + options.component = ComponentOptions.parse(json.optJSONObject("component")); return options; } @@ -67,6 +68,7 @@ public static BottomTabOptions parse(Context context, TypefaceLoader typefaceMan public Bool selectTabOnPress = new NullBool(); public Bool popToRoot = new NullBool(); public FontOptions font = new FontOptions(); + public ComponentOptions component = new ComponentOptions(); void mergeWith(final BottomTabOptions other) { @@ -90,6 +92,7 @@ void mergeWith(final BottomTabOptions other) { if (other.dotIndicator.hasValue()) dotIndicator = other.dotIndicator; if (other.selectTabOnPress.hasValue()) selectTabOnPress = other.selectTabOnPress; if (other.popToRoot.hasValue()) popToRoot = other.popToRoot; + if (other.component.hasValue()) component = other.component; } void mergeWithDefault(final BottomTabOptions defaultOptions) { @@ -113,7 +116,7 @@ void mergeWithDefault(final BottomTabOptions defaultOptions) { if (!dotIndicator.hasValue()) dotIndicator = defaultOptions.dotIndicator; if (!selectTabOnPress.hasValue()) selectTabOnPress = defaultOptions.selectTabOnPress; if (!popToRoot.hasValue()) popToRoot = defaultOptions.popToRoot; - + if (!component.hasValue()) component = defaultOptions.component; } } diff --git a/android/src/main/java/com/reactnativenavigation/react/ReactView.java b/android/src/main/java/com/reactnativenavigation/react/ReactView.java index 31e6dea1f60..67f908829e2 100644 --- a/android/src/main/java/com/reactnativenavigation/react/ReactView.java +++ b/android/src/main/java/com/reactnativenavigation/react/ReactView.java @@ -16,6 +16,7 @@ import com.facebook.react.ReactHost; import com.facebook.react.bridge.ReactContext; import com.facebook.react.interfaces.fabric.ReactSurface; +import com.facebook.react.runtime.ReactSurfaceImpl; import com.facebook.react.uimanager.UIManagerHelper; import com.facebook.react.uimanager.common.UIManagerType; import com.facebook.react.uimanager.events.EventDispatcher; @@ -71,6 +72,18 @@ public void destroy() { reactSurface.stop(); } + /** + * Replace the surface's initial props. Useful for components that need to + * receive runtime updates from native (e.g. bottom tab item components). + * No-op when the underlying surface implementation does not support + * runtime prop updates. + */ + public void setProps(Bundle props) { + if (reactSurface instanceof ReactSurfaceImpl) { + ((ReactSurfaceImpl) reactSurface).updateInitProps(props); + } + } + public void sendComponentWillStart(ComponentType type) { this.post(() -> { ReactContext currentReactContext = getReactContext(); diff --git a/android/src/main/java/com/reactnativenavigation/react/events/ComponentType.java b/android/src/main/java/com/reactnativenavigation/react/events/ComponentType.java index 505a97e7c65..8a75fe4a53e 100644 --- a/android/src/main/java/com/reactnativenavigation/react/events/ComponentType.java +++ b/android/src/main/java/com/reactnativenavigation/react/events/ComponentType.java @@ -4,7 +4,8 @@ public enum ComponentType { Component("Component"), Button("TopBarButton"), Title("TopBarTitle"), - Background("TopBarBackground"); + Background("TopBarBackground"), + BottomTabItem("BottomTabItem"); private String name; diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabPresenter.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabPresenter.java index b69caa509b7..fce7e68e15c 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabPresenter.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabPresenter.java @@ -20,7 +20,9 @@ import com.reactnativenavigation.utils.LateInit; import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController; import com.reactnativenavigation.views.bottomtabs.BottomTabs; +import com.reactnativenavigation.views.bottomtabs.CustomBottomTabItemView; +import java.util.ArrayList; import java.util.List; public class BottomTabPresenter { @@ -33,6 +35,7 @@ public class BottomTabPresenter { private final LateInit bottomTabs = new LateInit<>(); private final List> tabs; private final int defaultDotIndicatorSize; + private boolean useCustomItemViews; public BottomTabPresenter(Context context, List> tabs, ImageLoader imageLoader, TypefaceLoader typefaceLoader, Options defaultOptions) { this.tabs = tabs; @@ -53,10 +56,27 @@ public void bindView(BottomTabs bottomTabs) { this.bottomTabs.set(bottomTabs); } + /** + * When `true`, tabs whose options declare `bottomTab.component` are + * skipped during native icon/text application. The accompanying + * `CustomBottomTabItemView` overlay is responsible for visual rendering. + */ + public void setUseCustomItemViews(boolean useCustomItemViews) { + this.useCustomItemViews = useCustomItemViews; + } + public void applyOptions() { bottomTabs.perform(bottomTabs -> { for (int i = 0; i < tabs.size(); i++) { BottomTabOptions tab = tabs.get(i).resolveCurrentOptions(defaultOptions).bottomTabOptions; + if (useCustomItemViews && tab.component.hasValue()) { + if (tab.testId.hasValue()) bottomTabs.setTag(i, tab.testId.get()); + if (tab.badge.hasValue()) { + CustomBottomTabItemView v = bottomTabs.getCustomItemView(i); + if (v != null) v.setBadge(tab.badge.get("")); + } + continue; + } bottomTabs.setIconWidth(i, tab.iconWidth.get(null)); bottomTabs.setIconHeight(i, tab.iconHeight.get(null)); bottomTabs.setTitleTypeface(i, tab.font.getTypeface(typefaceLoader, defaultTypeface)); @@ -86,6 +106,14 @@ public void mergeChildOptions(Options options, ViewController child) { int index = bottomTabFinder.findByControllerId(child.getId()); if (index >= 0) { BottomTabOptions tab = options.bottomTabOptions; + if (useCustomItemViews && bottomTabs.getCustomItemView(index) != null) { + if (tab.badge.hasValue()) { + CustomBottomTabItemView v = bottomTabs.getCustomItemView(index); + if (v != null) v.setBadge(tab.badge.get("")); + } + if (tab.testId.hasValue()) bottomTabs.setTag(index, tab.testId.get()); + return; + } if (tab.iconWidth.hasValue()) bottomTabs.setIconWidth(index, tab.iconWidth.get(null)); if (tab.iconHeight.hasValue()) bottomTabs.setIconHeight(index, tab.iconHeight.get(null)); if (tab.font.hasValue()) bottomTabs.setTitleTypeface(index, tab.font.getTypeface(typefaceLoader, defaultTypeface)); diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java index 49324fa2b6d..57fa97690fe 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java @@ -16,6 +16,8 @@ import androidx.core.graphics.Insets; import androidx.core.view.WindowInsetsCompat; +import android.util.Log; + import com.aurelhubert.ahbottomnavigation.AHBottomNavigation; import com.aurelhubert.ahbottomnavigation.AHBottomNavigationItem; import com.reactnativenavigation.options.BottomTabOptions; @@ -34,7 +36,9 @@ import com.reactnativenavigation.views.bottomtabs.BottomTabs; import com.reactnativenavigation.views.bottomtabs.BottomTabsContainer; import com.reactnativenavigation.views.bottomtabs.BottomTabsLayout; +import com.reactnativenavigation.views.bottomtabs.CustomBottomTabItemView; +import java.util.ArrayList; import java.util.Collection; import java.util.Deque; import java.util.LinkedList; @@ -42,6 +46,8 @@ public class BottomTabsController extends ParentController implements AHBottomNavigation.OnTabSelectedListener, TabSelector { + private static final String LOG_TAG = "BottomTabsController"; + private BottomTabsContainer bottomTabsContainer; private BottomTabs bottomTabs; private final Deque selectionStack; @@ -51,6 +57,7 @@ public class BottomTabsController extends ParentController imp private final BottomTabsAttacher tabsAttacher; private final BottomTabsPresenter presenter; private final BottomTabPresenter tabPresenter; + private boolean useCustomItemViews; public BottomTabsAnimator getAnimator() { return presenter.getAnimator(); @@ -105,13 +112,59 @@ public BottomTabsLayout createView() { bottomTabs.setOnTabSelectedListener(this); root.addBottomTabsContainer(bottomTabsContainer); + useCustomItemViews = resolveUseCustomItemViews(); + tabPresenter.setUseCustomItemViews(useCustomItemViews); + bottomTabs.addItems(createTabs()); + + if (useCustomItemViews) { + attachCustomItemViewsToCells(); + } + setInitialTab(resolveCurrentOptions); tabsAttacher.attach(); return root; } + private boolean resolveUseCustomItemViews() { + if (tabs.isEmpty()) return false; + int withComponent = 0; + for (ViewController tab : tabs) { + BottomTabOptions options = tab.resolveCurrentOptions(initialOptions).bottomTabOptions; + if (options.component.hasValue()) withComponent++; + } + if (withComponent == 0) return false; + if (withComponent != tabs.size()) { + Log.w(LOG_TAG, + "Mixed bottomTab.component usage detected (" + withComponent + " of " + + tabs.size() + " tabs). All tabs must declare a component or none — " + + "falling back to native rendering for all tabs."); + return false; + } + return true; + } + + private void attachCustomItemViewsToCells() { + List overlays = new ArrayList<>(); + int initialIndex = bottomTabs.getCurrentItem(); + for (int i = 0; i < tabs.size(); i++) { + BottomTabOptions options = tabs.get(i).resolveCurrentOptions(initialOptions).bottomTabOptions; + String componentId = options.component.componentId.get(tabs.get(i).getId() + "_tab_" + i); + String componentName = options.component.name.get(); + String badge = options.badge.hasValue() ? options.badge.get() : null; + CustomBottomTabItemView itemView = new CustomBottomTabItemView( + getActivity(), + componentId, + componentName, + i, + i == initialIndex, + badge); + overlays.add(itemView); + } + bottomTabs.setCustomItemViews(overlays); + } + private void setInitialTab(Options resolveCurrentOptions) { int initialTabIndex = 0; if (resolveCurrentOptions.bottomTabsOptions.currentTabId.hasValue()) @@ -120,6 +173,9 @@ else if (resolveCurrentOptions.bottomTabsOptions.currentTabIndex.hasValue()) { initialTabIndex = resolveCurrentOptions.bottomTabsOptions.currentTabIndex.get(); } bottomTabs.setCurrentItem(initialTabIndex, false); + if (useCustomItemViews) { + bottomTabs.onCustomItemViewSelectionChanged(initialTabIndex); + } } @NonNull @@ -291,6 +347,9 @@ private void selectTab(int newIndex, boolean enableSelectionHistory) { ViewController previouslyVisible = getCurrentChild(); bottomTabs.setCurrentItem(newIndex, false); getCurrentChild().onSelected(previouslyVisible); + if (useCustomItemViews) { + bottomTabs.onCustomItemViewSelectionChanged(newIndex); + } } private void saveTabSelection(int newIndex, boolean enableSelectionHistory) { 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 c4e530cd981..3016ee4ea4e 100644 --- a/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabs.java +++ b/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabs.java @@ -8,6 +8,8 @@ import android.graphics.Color; import android.graphics.drawable.Drawable; import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; import android.widget.LinearLayout; import androidx.annotation.IntRange; @@ -25,6 +27,19 @@ public class BottomTabs extends AHBottomNavigation { private boolean itemsCreationEnabled = true; private boolean shouldCreateItems = true; private List onItemCreationEnabled = new ArrayList<>(); + private final List customItemViews = new ArrayList<>(); + private boolean externalCustomItemViewHost = false; + + /** + * When enabled, this view stops re-parenting custom React tab item views + * into its native cells on every layout pass — the caller assumes full + * ownership of where those item views live in the view tree (used by + * the customRow floating-row implementation). Existing behavior is + * unchanged when this remains {@code false} (the default). + */ + public void setExternalCustomItemViewHost(boolean enabled) { + this.externalCustomItemViewHost = enabled; + } public BottomTabs(Context context) { super(context); @@ -131,6 +146,67 @@ public void setLayoutDirection(LayoutDirection direction) { if (tabsContainer != null) tabsContainer.setLayoutDirection(direction.get()); } + /** + * Replace the visual content of every tab cell with the provided custom + * views. The custom view is attached as a child of the AHBottomNavigation + * cell view so taps continue to be handled by the native cell. Pass an + * empty list to remove all overlays. + */ + public void setCustomItemViews(List customViews) { + clearCustomItemViews(); + if (customViews == null || customViews.isEmpty()) return; + + customItemViews.addAll(customViews); + attachCustomItemViews(); + } + + public void onCustomItemViewSelectionChanged(int selectedIndex) { + for (int i = 0; i < customItemViews.size(); i++) { + customItemViews.get(i).setItemSelected(i == selectedIndex); + } + } + + public CustomBottomTabItemView getCustomItemView(int index) { + if (index < 0 || index >= customItemViews.size()) return null; + return customItemViews.get(index); + } + + public boolean hasCustomItemViews() { + return !customItemViews.isEmpty(); + } + + private void clearCustomItemViews() { + for (CustomBottomTabItemView view : customItemViews) { + ViewGroup parent = (ViewGroup) view.getParent(); + if (parent != null) parent.removeView(view); + } + customItemViews.clear(); + } + + private void attachCustomItemViews() { + if (externalCustomItemViewHost) return; + for (int i = 0; i < customItemViews.size(); i++) { + View cell = getViewAtPosition(i); + if (!(cell instanceof ViewGroup)) continue; + CustomBottomTabItemView itemView = customItemViews.get(i); + ViewGroup parent = (ViewGroup) itemView.getParent(); + if (parent != null && parent != cell) parent.removeView(itemView); + if (itemView.getParent() == null) { + ((ViewGroup) cell).addView(itemView, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT)); + } + } + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + if (changed && !customItemViews.isEmpty()) { + attachCustomItemViews(); + } + } + private boolean hasItemsAndIsMeasured(int w, int h, int oldw, int oldh) { return w != 0 && h != 0 && (w != oldw || h != oldh) && getItemsCount() > 0; } diff --git a/android/src/main/java/com/reactnativenavigation/views/bottomtabs/CustomBottomTabItemView.kt b/android/src/main/java/com/reactnativenavigation/views/bottomtabs/CustomBottomTabItemView.kt new file mode 100644 index 00000000000..c6ae3b95a79 --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/views/bottomtabs/CustomBottomTabItemView.kt @@ -0,0 +1,73 @@ +package com.reactnativenavigation.views.bottomtabs + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.MotionEvent +import android.widget.FrameLayout +import com.reactnativenavigation.react.ReactView + +/** + * Hosts a [ReactView] that renders a user-supplied React component as a + * bottom tab item. The view sits on top of the native AHBottomNavigation tab + * cell and forwards touches through to the underlying cell so native + * selection, ripple and `selectTabOnPress: false` keep working. + * + * The hosted component receives the following props at creation: + * `componentId`, `tabIndex`, `selected`, `badge`. Selection updates are + * pushed via [setSelected]; badge updates via [setBadge]. + */ +@SuppressLint("ViewConstructor") +class CustomBottomTabItemView( + context: Context, + val componentId: String, + val componentName: String, + val tabIndex: Int, + initialSelected: Boolean, + initialBadge: String? +) : FrameLayout(context) { + + val reactView: ReactView = ReactView(context, componentId, componentName) + private var isCurrentlySelected: Boolean = initialSelected + private var badge: String? = initialBadge + + init { + addView(reactView) + reactView.isClickable = false + reactView.isFocusable = false + isClickable = false + isFocusable = false + pushProps() + } + + /** + * Touches must always reach the underlying AHBottomNavigation cell so + * that native selection, ripple, accessibility focus and + * `selectTabOnPress: false` keep working. Returning false here makes + * this view completely transparent to touch input and prevents any + * `Touchable*` rendered inside the React tree from swallowing taps. + */ + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean = false + + fun setItemSelected(selected: Boolean) { + if (this.isCurrentlySelected == selected) return + this.isCurrentlySelected = selected + pushProps() + } + + fun setBadge(badge: String?) { + if (this.badge == badge) return + this.badge = badge + pushProps() + } + + private fun pushProps() { + val bundle = Bundle().apply { + putString("componentId", componentId) + putInt("tabIndex", tabIndex) + putBoolean("selected", isCurrentlySelected) + if (badge != null) putString("badge", badge) + } + reactView.setProps(bundle) + } +} diff --git a/ios/BottomTabPresenter.h b/ios/BottomTabPresenter.h index 5ba0cdd15c8..bf441135ac1 100644 --- a/ios/BottomTabPresenter.h +++ b/ios/BottomTabPresenter.h @@ -4,6 +4,13 @@ @property(nonatomic, strong, readonly) RNNTabBarItemCreator *tabCreator; +/** + * When YES, tabs whose options declare `bottomTab.component` skip native + * icon/text/sfSymbol/role application. The accompanying + * `RNNCustomTabBarItemView` is responsible for visual rendering of the tab. + */ +@property(nonatomic, assign) BOOL useCustomItemViews; + - (instancetype)initWithDefaultOptions:(RNNNavigationOptions *)defaultOptions tabCreator:(RNNTabBarItemCreator *)tabCreator; diff --git a/ios/BottomTabPresenter.mm b/ios/BottomTabPresenter.mm index ebb1a679361..ccbd7ee48a0 100644 --- a/ios/BottomTabPresenter.mm +++ b/ios/BottomTabPresenter.mm @@ -36,10 +36,37 @@ - (void)mergeOptions:(RNNNavigationOptions *)mergeOptions - (void)createTabBarItem:(UIViewController *)child bottomTabOptions:(RNNBottomTabOptions *)bottomTabOptions { + if (_useCustomItemViews && bottomTabOptions.component.name.hasValue) { + UITabBarItem *blankItem = [self createBlankTabBarItem:child + bottomTabOptions:bottomTabOptions]; + if (blankItem != child.tabBarItem) { + child.tabBarItem = blankItem; + } + return; + } + UITabBarItem *updatedItem = [_tabCreator createTabBarItem:bottomTabOptions mergeItem:child.tabBarItem]; if (updatedItem != child.tabBarItem) { child.tabBarItem = updatedItem; } } +// Builds a truly blank `UITabBarItem` (nil image, nil title). When custom +// item views are active, `RNNBottomTabsController` hides the native tab bar +// visuals and renders the custom row on top. The bar item still needs to +// exist so that `UITabBarController` reserves the right number of slots and +// the bottom safe-area inset. +- (UITabBarItem *)createBlankTabBarItem:(UIViewController *)child + bottomTabOptions:(RNNBottomTabOptions *)bottomTabOptions { + UITabBarItem *item = child.tabBarItem ?: [UITabBarItem new]; + item.image = nil; + item.selectedImage = nil; + item.title = nil; + item.tag = bottomTabOptions.tag; + item.accessibilityIdentifier = [bottomTabOptions.testID withDefault:nil]; + item.accessibilityLabel = [bottomTabOptions.accessibilityLabel withDefault:nil]; + item.imageInsets = UIEdgeInsetsZero; + return item; +} + @end diff --git a/ios/RNNBottomTabOptions.h b/ios/RNNBottomTabOptions.h index 809482f3f30..a425ac9d08e 100644 --- a/ios/RNNBottomTabOptions.h +++ b/ios/RNNBottomTabOptions.h @@ -1,3 +1,4 @@ +#import "RNNComponentOptions.h" #import "RNNOptions.h" @class DotIndicatorOptions; @@ -5,6 +6,7 @@ @interface RNNBottomTabOptions : RNNOptions @property(nonatomic) NSUInteger tag; +@property(nonatomic, strong) RNNComponentOptions *component; @property(nonatomic, strong) Text *text; @property(nonatomic, strong) Text *badge; @property(nonatomic, strong) Color *badgeColor; diff --git a/ios/RNNBottomTabOptions.mm b/ios/RNNBottomTabOptions.mm index 2a959c99d83..28c84d5f129 100644 --- a/ios/RNNBottomTabOptions.mm +++ b/ios/RNNBottomTabOptions.mm @@ -8,6 +8,9 @@ - (instancetype)initWithDict:(NSDictionary *)dict { self = [super initWithDict:dict]; self.tag = arc4random(); + self.component = + [[RNNComponentOptions alloc] initWithDict:[dict objectForKey:@"component"]]; + self.text = [TextParser parse:dict key:@"text"]; self.badge = [TextParser parse:dict key:@"badge"]; self.fontFamily = [TextParser parse:dict key:@"fontFamily"]; @@ -38,6 +41,7 @@ - (instancetype)initWithDict:(NSDictionary *)dict { - (void)mergeOptions:(RNNBottomTabOptions *)options { [self.dotIndicator mergeOptions:options.dotIndicator]; + [self.component mergeOptions:options.component]; if (options.text.hasValue) self.text = options.text; @@ -88,7 +92,7 @@ - (BOOL)hasValue { self.iconColor.hasValue || self.selectedIconColor.hasValue || self.selectedTextColor.hasValue || self.iconInsets.hasValue || self.textColor.hasValue || self.visible.hasValue || self.selectTabOnPress.hasValue || self.sfSymbol.hasValue || - self.sfSelectedSymbol.hasValue || self.role.hasValue; + self.sfSelectedSymbol.hasValue || self.role.hasValue || self.component.hasValue; } @end diff --git a/ios/RNNBottomTabsController.h b/ios/RNNBottomTabsController.h index dfde621bf52..6efe764b269 100644 --- a/ios/RNNBottomTabsController.h +++ b/ios/RNNBottomTabsController.h @@ -3,6 +3,7 @@ #import "RNNBottomTabsPresenter.h" #import "RNNDotIndicatorPresenter.h" #import "RNNEventEmitter.h" +#import "RNNReactComponentRegistry.h" #import "UIViewController+LayoutProtocol.h" #import @@ -16,6 +17,7 @@ presenter:(RNNBasePresenter *)presenter bottomTabPresenter:(BottomTabPresenter *)bottomTabPresenter dotIndicatorPresenter:(RNNDotIndicatorPresenter *)dotIndicatorPresenter + componentRegistry:(RNNReactComponentRegistry *)componentRegistry eventEmitter:(RNNEventEmitter *)eventEmitter childViewControllers:(NSArray *)childViewControllers bottomTabsAttacher:(BottomTabsBaseAttacher *)bottomTabsAttacher; diff --git a/ios/RNNBottomTabsController.mm b/ios/RNNBottomTabsController.mm index bec586e0633..f48ffd17bc5 100644 --- a/ios/RNNBottomTabsController.mm +++ b/ios/RNNBottomTabsController.mm @@ -1,7 +1,10 @@ #import "RNNBottomTabsController.h" +#import "RNNBottomTabsCustomRow.h" +#import "RNNCustomTabBarItemView.h" #import "RNNTabBarItemCreator.h" #import "UITabBarController+RNNOptions.h" #import "UITabBarController+RNNUtils.h" +#import @interface RNNBottomTabsController () @property(nonatomic, strong) BottomTabPresenter *bottomTabPresenter; @@ -21,6 +24,10 @@ @implementation RNNBottomTabsController { BOOL _didFinishSetup; BOOL _rnnDidApplyInitialTabBarSelectionFix; BOOL _rnnSuppressTabSelectionEvents; + RNNReactComponentRegistry *_componentRegistry; + NSMutableArray *_customTabItemViews; + BOOL _useCustomItemViews; + RNNBottomTabsCustomRow *_customRow; } - (instancetype)initWithLayoutInfo:(RNNLayoutInfo *)layoutInfo @@ -30,14 +37,18 @@ - (instancetype)initWithLayoutInfo:(RNNLayoutInfo *)layoutInfo presenter:(RNNBasePresenter *)presenter bottomTabPresenter:(BottomTabPresenter *)bottomTabPresenter dotIndicatorPresenter:(RNNDotIndicatorPresenter *)dotIndicatorPresenter + componentRegistry:(RNNReactComponentRegistry *)componentRegistry eventEmitter:(RNNEventEmitter *)eventEmitter childViewControllers:(NSArray *)childViewControllers bottomTabsAttacher:(BottomTabsBaseAttacher *)bottomTabsAttacher { _bottomTabsAttacher = bottomTabsAttacher; _bottomTabPresenter = bottomTabPresenter; _dotIndicatorPresenter = dotIndicatorPresenter; + _componentRegistry = componentRegistry; _options = options; _didFinishSetup = NO; + _customTabItemViews = [NSMutableArray new]; + _useCustomItemViews = NO; IntNumber *currentTabIndex = options.bottomTabs.currentTabIndex; if ([currentTabIndex hasValue]) { @@ -88,14 +99,20 @@ - (void)viewWillAppear:(BOOL)animated { [selectedChild pushViewController: [UIViewController new] animated:NO]; [selectedChild popViewControllerAnimated:NO]; } + + if (_useCustomItemViews) { + [self ensureCustomRowAttached]; + [self layoutCustomRow]; + } } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; // iOS 26: first layout can misplace tab item titles; cycling selection (then restoring) forces a // correct layout without user interaction. Defer so all tab children are in the hierarchy. + // Skipped when custom item views are active — the native tab bar visuals are hidden anyway. if (@available(iOS 26.0, *)) { - if (!_rnnDidApplyInitialTabBarSelectionFix) { + if (!_useCustomItemViews && !_rnnDidApplyInitialTabBarSelectionFix) { _rnnDidApplyInitialTabBarSelectionFix = YES; __weak RNNBottomTabsController *weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ @@ -128,13 +145,182 @@ - (void)rnn_cycleAllTabsThenRestoreInitialSelection { - (void)createTabBarItems:(NSArray *)childViewControllers { _bottomTabPresenter.tabCreator.searchRoleUsed = NO; + [self resolveCustomItemViewMode:childViewControllers]; + _bottomTabPresenter.useCustomItemViews = _useCustomItemViews; for (UIViewController *child in childViewControllers) { [_bottomTabPresenter applyOptions:child.resolveOptions child:child]; } + if (_useCustomItemViews) { + [self buildCustomTabItemViews:childViewControllers]; + [self applyCustomItemViewsTabBarConfiguration]; + [self ensureCustomRowAttached]; + } + [self syncTabBarItemTestIDs]; } +- (void)applyCustomItemViewsTabBarConfiguration { + // Hide the native tab bar visuals so our custom row is the only thing + // shown. The bar itself stays in the view hierarchy so that + // `UITabBarController` keeps reserving the bottom safe-area inset for + // the selected child controller and exposes the right frame for the + // row to match. + for (UIView *subview in self.tabBar.subviews) { + subview.hidden = YES; + } + self.tabBar.tintColor = UIColor.clearColor; + self.tabBar.unselectedItemTintColor = UIColor.clearColor; + self.tabBar.backgroundColor = UIColor.clearColor; +} + +- (void)resolveCustomItemViewMode:(NSArray *)childViewControllers { + if (childViewControllers.count == 0) { + _useCustomItemViews = NO; + return; + } + + NSUInteger withComponent = 0; + for (UIViewController *child in childViewControllers) { + RNNNavigationOptions *resolved = child.resolveOptions; + if (resolved.bottomTab.component.name.hasValue) { + withComponent++; + } + } + + if (withComponent == 0) { + _useCustomItemViews = NO; + return; + } + + if (withComponent != childViewControllers.count) { + RCTLogWarn( + @"[RNN] Mixed bottomTab.component usage detected (%lu of %lu tabs). All tabs must " + @"declare a component or none — falling back to native rendering for all tabs.", + (unsigned long)withComponent, (unsigned long)childViewControllers.count); + _useCustomItemViews = NO; + return; + } + + _useCustomItemViews = YES; +} + +- (void)buildCustomTabItemViews:(NSArray *)childViewControllers { + [self destroyCustomTabItemViews]; + + NSString *parentComponentId = self.layoutInfo.componentId; + for (NSUInteger i = 0; i < childViewControllers.count; i++) { + UIViewController *child = childViewControllers[i]; + RNNNavigationOptions *resolved = child.resolveOptions; + RNNComponentOptions *componentOptions = resolved.bottomTab.component; + + RNNReactView *reactView = + [_componentRegistry createComponentIfNotExists:componentOptions + parentComponentId:parentComponentId + componentType:RNNComponentTypeBottomTabItem + reactViewReadyBlock:nil]; + + NSString *badge = [resolved.bottomTab.badge withDefault:nil]; + RNNCustomTabBarItemView *itemView = + [[RNNCustomTabBarItemView alloc] initWithReactView:reactView + tabIndex:i + selected:(i == _currentTabIndex) + badge:badge]; + [_customTabItemViews addObject:itemView]; + } +} + +- (void)destroyCustomTabItemViews { + for (RNNCustomTabBarItemView *itemView in _customTabItemViews) { + [itemView removeFromSuperview]; + } + [_customTabItemViews removeAllObjects]; + [_customRow removeFromSuperview]; + _customRow = nil; +} + +- (void)ensureCustomRowAttached { + if (!_useCustomItemViews) { + return; + } + if (!_customRow) { + _customRow = [[RNNBottomTabsCustomRow alloc] initWithFrame:CGRectZero]; + __weak RNNBottomTabsController *weakSelf = self; + _customRow.onTapAtIndex = ^(NSUInteger index) { + [weakSelf handleCustomRowTapAtIndex:index]; + }; + } + [_customRow setItemViews:_customTabItemViews]; + [_customRow applyOptions:_options.bottomTabs.customRow]; + if (_customRow.superview != self.view) { + [self.view addSubview:_customRow]; + } else { + [self.view bringSubviewToFront:_customRow]; + } +} + +- (void)layoutCustomRow { + if (!_useCustomItemViews || !_customRow) { + return; + } + CGRect tabBarFrame = self.tabBar.frame; + if (CGRectIsEmpty(tabBarFrame)) { + return; + } + CGRect tabBarInView = [self.view convertRect:tabBarFrame fromView:self.tabBar.superview]; + + CGFloat desiredHeight = [_customRow + desiredRowHeightForNativeTabBarHeight:tabBarInView.size.height + safeBottom:0]; + + CGFloat bottomMargin = [_customRow effectiveBottomMargin]; + CGFloat rowBottom; + UIWindow *window = self.view.window; + if (window) { + // Map the window's safe-area bottom into this controller's view — reliable + // for modals where `self.view.safeAreaInsets` is often zero. + CGFloat yInWindow = CGRectGetHeight(window.bounds) - window.safeAreaInsets.bottom; + CGPoint pInView = [self.view convertPoint:CGPointMake(0, yInWindow) fromView:window]; + rowBottom = pInView.y - bottomMargin; + } else { + rowBottom = CGRectGetMaxY(self.view.safeAreaLayoutGuide.layoutFrame) - bottomMargin; + } + + CGRect rowFrame = CGRectMake(tabBarInView.origin.x, rowBottom - desiredHeight, + tabBarInView.size.width, desiredHeight); + + _customRow.frame = rowFrame; + _customRow.hidden = self.tabBar.hidden; + [_customRow setSelectedIndex:_currentTabIndex]; +} + +- (void)handleCustomRowTapAtIndex:(NSUInteger)index { + if (index >= self.childViewControllers.count) { + return; + } + UIViewController *target = self.childViewControllers[index]; + [self.eventEmitter sendBottomTabPressed:@(index)]; + BOOL select = [[target resolveOptions].bottomTab.selectTabOnPress withDefault:YES]; + if (!select) { + return; + } + NSUInteger previous = _currentTabIndex; + [self setSelectedIndex:index]; + if (!_rnnSuppressTabSelectionEvents) { + [self.eventEmitter sendBottomTabSelected:@(index) unselected:@(previous)]; + } +} + +- (void)updateCustomTabItemSelection { + if (!_useCustomItemViews) { + return; + } + for (NSUInteger i = 0; i < _customTabItemViews.count; i++) { + [_customTabItemViews[i] setSelected:(i == _currentTabIndex)]; + } + [_customRow setSelectedIndex:_currentTabIndex]; +} + - (void)mergeChildOptions:(RNNNavigationOptions *)options child:(UIViewController *)child { [super mergeChildOptions:options child:child]; UIViewController *childViewController = [self findViewController:child]; @@ -145,6 +331,13 @@ - (void)mergeChildOptions:(RNNNavigationOptions *)options child:(UIViewControlle resolvedOptions:childViewController.resolveOptions child:childViewController]; + if (_useCustomItemViews && options.bottomTab.badge.hasValue) { + NSUInteger index = [self.childViewControllers indexOfObject:childViewController]; + if (index != NSNotFound && index < _customTabItemViews.count) { + [_customTabItemViews[index] setBadge:[options.bottomTab.badge withDefault:nil]]; + } + } + [self syncTabBarItemTestIDs]; } @@ -161,6 +354,12 @@ - (void)viewDidLayoutSubviews { [self syncTabBarItemTestIDs]; [self.presenter viewDidLayoutSubviews]; [_dotIndicatorPresenter bottomTabsDidLayoutSubviews:self]; + if (_useCustomItemViews) { + // Re-hide native subviews; UIKit recreates them on bounds changes. + [self applyCustomItemViewsTabBarConfiguration]; + [self ensureCustomRowAttached]; + [self layoutCustomRow]; + } } - (UIViewController *)getCurrentChild { @@ -195,6 +394,7 @@ - (void)setSelectedIndex:(NSUInteger)selectedIndex { } [super setSelectedIndex:_currentTabIndex]; + [self updateCustomTabItemSelection]; } - (UIViewController *)selectedViewController { @@ -205,6 +405,7 @@ - (void)setSelectedViewController:(__kindof UIViewController *)selectedViewContr _previousTabIndex = _currentTabIndex; _currentTabIndex = [self.childViewControllers indexOfObject:selectedViewController]; [super setSelectedViewController:selectedViewController]; + [self updateCustomTabItemSelection]; } - (void)setTabBarVisible:(BOOL)visible animated:(BOOL)animated { @@ -283,4 +484,11 @@ - (BOOL)hidesBottomBarWhenPushed { return [self.presenter hidesBottomBarWhenPushed]; } +- (void)dealloc { + [self destroyCustomTabItemViews]; + if (_componentRegistry && self.layoutInfo.componentId) { + [_componentRegistry clearComponentsForParentId:self.layoutInfo.componentId]; + } +} + @end diff --git a/ios/RNNBottomTabsCustomRow.h b/ios/RNNBottomTabsCustomRow.h new file mode 100644 index 00000000000..845a1ca112f --- /dev/null +++ b/ios/RNNBottomTabsCustomRow.h @@ -0,0 +1,57 @@ +#import "RNNBottomTabsCustomRowOptions.h" +#import "RNNCustomTabBarItemView.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Replaces the visual content of `UITabBar` when `bottomTab.component` is + * declared on every tab. Hosts the React-rendered cell views in equal-width + * slots and dispatches taps back to the bottom-tabs controller. + * + * The native `UITabBar` is kept (with its visuals hidden) so that + * `UITabBarController` keeps managing the bottom safe-area inset for the + * selected child controller — this row is laid out on top of it. + * + * Visual chrome (height, background, corner radius, margins) is configured + * via `RNNBottomTabsCustomRowOptions` and pushed in by the controller. + */ +@interface RNNBottomTabsCustomRow : UIView + +/** + * Block invoked when the user taps a cell. The argument is the 0-based index + * of the tapped cell. + */ +@property(nonatomic, copy, nullable) void (^onTapAtIndex)(NSUInteger index); + +/** + * Replaces the cells displayed by this row. The previously held views are + * removed from the hierarchy. Each cell is rendered at equal width inside + * the content rect (safe area is reserved in the row frame, not inset here). + */ +- (void)setItemViews:(NSArray *)itemViews; + +/** + * Forwards the selected state to each hosted item view. + */ +- (void)setSelectedIndex:(NSUInteger)selectedIndex; + +/** + * Applies user-supplied chrome options (background, corner radius, margins). + * Pass `nil` (or an options instance with no values) to use defaults. + */ +- (void)applyOptions:(nullable RNNBottomTabsCustomRowOptions *)options; + +/** + * Returns the chrome height (plus bottom margin). The controller positions + * the row above the home-indicator safe area. + */ +- (CGFloat)desiredRowHeightForNativeTabBarHeight:(CGFloat)nativeTabBarHeight + safeBottom:(CGFloat)safeBottom; + +/** User `bottomMargin` option, or 0. */ +- (CGFloat)effectiveBottomMargin; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RNNBottomTabsCustomRow.mm b/ios/RNNBottomTabsCustomRow.mm new file mode 100644 index 00000000000..f1011fc6c34 --- /dev/null +++ b/ios/RNNBottomTabsCustomRow.mm @@ -0,0 +1,252 @@ +#import "RNNBottomTabsCustomRow.h" + +@interface RNNBottomTabsCustomRowCell : UIControl +@property(nonatomic, strong, nullable) RNNCustomTabBarItemView *itemView; +@property(nonatomic, assign) NSUInteger index; +@end + +@implementation RNNBottomTabsCustomRowCell + +- (void)setItemView:(RNNCustomTabBarItemView *)itemView { + if (_itemView == itemView) { + return; + } + [_itemView removeFromSuperview]; + _itemView = itemView; + if (itemView) { + itemView.translatesAutoresizingMaskIntoConstraints = YES; + itemView.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + itemView.frame = self.bounds; + [self addSubview:itemView]; + } +} + +- (void)layoutSubviews { + [super layoutSubviews]; + self.itemView.frame = self.bounds; +} + +@end + +@interface RNNBottomTabsCustomRow () +@property(nonatomic, strong) NSMutableArray *cells; +@property(nonatomic, strong) UIVisualEffectView *backgroundEffectView; +@property(nonatomic, strong) UIView *backgroundColorView; +@property(nonatomic, strong) RNNBottomTabsCustomRowOptions *options; +@end + +@implementation RNNBottomTabsCustomRow + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + _cells = [NSMutableArray new]; + self.backgroundColor = UIColor.clearColor; + + // Solid background layer (only made visible if user sets backgroundColor). + _backgroundColorView = [[UIView alloc] init]; + _backgroundColorView.hidden = YES; + _backgroundColorView.clipsToBounds = YES; + if (@available(iOS 13.0, *)) { + _backgroundColorView.layer.cornerCurve = kCACornerCurveContinuous; + } + [self addSubview:_backgroundColorView]; + + // Visual effect (blur / glass) layer. Default depends on iOS version. + UIVisualEffect *effect = [RNNBottomTabsCustomRow defaultBackgroundEffect]; + _backgroundEffectView = [[UIVisualEffectView alloc] initWithEffect:effect]; + _backgroundEffectView.clipsToBounds = YES; + if (@available(iOS 13.0, *)) { + _backgroundEffectView.layer.cornerCurve = kCACornerCurveContinuous; + } + if (@available(iOS 26.0, *)) { + _backgroundEffectView.layer.cornerRadius = 28.0; + } + [self addSubview:_backgroundEffectView]; + } + return self; +} + +// `UIGlassEffect` is a new visual effect introduced in iOS 26. Reference it +// at runtime so this file still compiles against older SDKs and so we get a +// usable fallback on older OS versions. ++ (UIVisualEffect *)defaultBackgroundEffect { + Class glassClass = NSClassFromString(@"UIGlassEffect"); + if (glassClass) { + UIVisualEffect *glass = [[glassClass alloc] init]; + if (glass) { + return glass; + } + } + if (@available(iOS 13.0, *)) { + return [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemChromeMaterial]; + } + return [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight]; +} + ++ (UIVisualEffect *)blurBackgroundEffect { + if (@available(iOS 13.0, *)) { + return [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemChromeMaterial]; + } + return [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight]; +} + +- (void)applyOptions:(RNNBottomTabsCustomRowOptions *)options { + self.options = options; + + UIColor *solidColor = + options.backgroundColor.hasValue ? options.backgroundColor.get : nil; + NSString *effectName = + options.backgroundEffect.hasValue ? options.backgroundEffect.get : nil; + + BOOL useSolidColor = solidColor != nil; + self.backgroundColorView.hidden = !useSolidColor; + if (useSolidColor) { + self.backgroundColorView.backgroundColor = solidColor; + } + + // Effect view stays unless explicitly disabled or overridden by solid color. + BOOL hideEffect = useSolidColor || [effectName isEqualToString:@"none"]; + self.backgroundEffectView.hidden = hideEffect; + if (!hideEffect) { + if ([effectName isEqualToString:@"blur"]) { + self.backgroundEffectView.effect = [RNNBottomTabsCustomRow blurBackgroundEffect]; + } else if ([effectName isEqualToString:@"glass"]) { + self.backgroundEffectView.effect = [RNNBottomTabsCustomRow defaultBackgroundEffect]; + } + } + + [self setNeedsLayout]; +} + +- (CGFloat)effectiveCornerRadius { + if (self.options.cornerRadius.hasValue) { + return [self.options.cornerRadius.get doubleValue]; + } + if (@available(iOS 26.0, *)) { + return 28.0; + } + return 0.0; +} + +- (CGFloat)effectiveHorizontalMargin { + if (self.options.horizontalMargin.hasValue) { + return [self.options.horizontalMargin.get doubleValue]; + } + if (@available(iOS 26.0, *)) { + return 16.0; + } + return 0.0; +} + +- (CGFloat)effectiveBottomMargin { + if (self.options.bottomMargin.hasValue) { + return [self.options.bottomMargin.get doubleValue]; + } + return 0.0; +} + +- (CGFloat)desiredRowHeightForNativeTabBarHeight:(CGFloat)nativeTabBarHeight + safeBottom:(CGFloat)safeBottom { + (void)safeBottom; + CGFloat contentHeight = nativeTabBarHeight; + if (@available(iOS 26.0, *)) { + contentHeight += 18.0; // default extra for iOS 26 floating bar look + } + if (self.options.height.hasValue) { + contentHeight = [self.options.height.get doubleValue]; + } + // Safe area is applied by the controller when positioning the row frame. + return contentHeight + [self effectiveBottomMargin]; +} + +- (void)setItemViews:(NSArray *)itemViews { + for (RNNBottomTabsCustomRowCell *cell in self.cells) { + [cell removeFromSuperview]; + } + [self.cells removeAllObjects]; + + UIView *cellContainer = self.backgroundEffectView.contentView; + for (NSUInteger i = 0; i < itemViews.count; i++) { + RNNBottomTabsCustomRowCell *cell = [[RNNBottomTabsCustomRowCell alloc] init]; + cell.index = i; + cell.itemView = itemViews[i]; + [cell addTarget:self + action:@selector(handleCellTap:) + forControlEvents:UIControlEventTouchUpInside]; + [cellContainer addSubview:cell]; + [self.cells addObject:cell]; + } + [self setNeedsLayout]; +} + +- (void)setSelectedIndex:(NSUInteger)selectedIndex { + for (NSUInteger i = 0; i < self.cells.count; i++) { + RNNCustomTabBarItemView *itemView = self.cells[i].itemView; + [itemView setSelected:(i == selectedIndex)]; + } +} + +- (void)handleCellTap:(RNNBottomTabsCustomRowCell *)cell { + if (self.onTapAtIndex) { + self.onTapAtIndex(cell.index); + } +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + // Bottom safe area is already included in the row frame height via + // `desiredRowHeightForNativeTabBarHeight:safeBottom:` — do not inset it + // again here or the chrome shrinks by ~home-indicator height. + CGFloat bottomInset = [self effectiveBottomMargin]; + CGRect content = + UIEdgeInsetsInsetRect(self.bounds, UIEdgeInsetsMake(0, 0, bottomInset, 0)); + + CGFloat horizontalMargin = [self effectiveHorizontalMargin]; + if (horizontalMargin > 0) { + content = CGRectInset(content, horizontalMargin, 0); + } + + CGFloat cornerRadius = [self effectiveCornerRadius]; + self.backgroundEffectView.layer.cornerRadius = cornerRadius; + self.backgroundColorView.layer.cornerRadius = cornerRadius; + + self.backgroundEffectView.frame = content; + self.backgroundColorView.frame = content; + + if (self.cells.count == 0) { + return; + } + + // Cells are subviews of the effect view's contentView (so the rounded + // mask clips them); their frames are relative to the effect view's + // bounds. The solid color layer sits behind and doesn't host cells. + UIView *cellContainer = self.backgroundColorView.hidden + ? self.backgroundEffectView.contentView + : self.backgroundColorView; + // Make sure cells live in the visible container. + if (self.cells.firstObject.superview != cellContainer) { + for (RNNBottomTabsCustomRowCell *cell in self.cells) { + [cell removeFromSuperview]; + [cellContainer addSubview:cell]; + } + } + + CGFloat totalWidth = content.size.width; + CGFloat totalHeight = content.size.height; + CGFloat width = totalWidth / (CGFloat)self.cells.count; + for (NSUInteger i = 0; i < self.cells.count; i++) { + CGFloat x = floor((CGFloat)i * width); + CGFloat nextX = floor((CGFloat)(i + 1) * width); + self.cells[i].frame = CGRectMake(x, 0, nextX - x, totalHeight); + } +} + +- (void)safeAreaInsetsDidChange { + [super safeAreaInsetsDidChange]; + [self setNeedsLayout]; +} + +@end diff --git a/ios/RNNBottomTabsCustomRowOptions.h b/ios/RNNBottomTabsCustomRowOptions.h new file mode 100644 index 00000000000..4a4a4fcd42b --- /dev/null +++ b/ios/RNNBottomTabsCustomRowOptions.h @@ -0,0 +1,42 @@ +#import "RNNOptions.h" + +/** + * Visual options for the floating row that hosts custom-component bottom tab + * cells. Applies only when every tab declares `bottomTab.component`. All + * fields are optional; if omitted the row uses sensible defaults (iOS 26 + * glass pill on iOS 26+, blur with no inset on older versions). + * + * The same option keys are exposed in JS as `bottomTabs.customRow` on both + * platforms. Android applies them via `RNNBottomTabsCustomRowModule`. + */ +@interface RNNBottomTabsCustomRowOptions : RNNOptions + +/** + * Override the row's content height. The native tab bar (and its safe-area + * inset) is preserved underneath — this only changes how tall the visible + * floating row appears. Defaults to the native tab bar content height (+18pt + * on iOS 26+ to match the larger native floating bar). + */ +@property(nonatomic, strong) Number *height; + +/** Solid background color for the row. When set, overrides `backgroundEffect`. */ +@property(nonatomic, strong) Color *backgroundColor; + +/** + * Visual effect for the row background. Values: `glass` | `blur` | `none`. + * Default: `glass` on iOS 26+, `blur` on older versions. + */ +@property(nonatomic, strong) Text *backgroundEffect; + +/** Corner radius of the row. Default: 28 on iOS 26+, 0 below. */ +@property(nonatomic, strong) Number *cornerRadius; + +/** Horizontal inset of the row from the screen edges. Default: 16 on iOS 26+, 0 below. */ +@property(nonatomic, strong) Number *horizontalMargin; + +/** Distance between the row's bottom edge and the safe-area bottom. Default: 0. */ +@property(nonatomic, strong) Number *bottomMargin; + +- (BOOL)hasValue; + +@end diff --git a/ios/RNNBottomTabsCustomRowOptions.mm b/ios/RNNBottomTabsCustomRowOptions.mm new file mode 100644 index 00000000000..dc9f009522c --- /dev/null +++ b/ios/RNNBottomTabsCustomRowOptions.mm @@ -0,0 +1,37 @@ +#import "RNNBottomTabsCustomRowOptions.h" + +@implementation RNNBottomTabsCustomRowOptions + +- (instancetype)initWithDict:(NSDictionary *)dict { + self = [super initWithDict:dict]; + self.height = [NumberParser parse:dict key:@"height"]; + self.backgroundColor = [ColorParser parse:dict key:@"backgroundColor"]; + self.backgroundEffect = [TextParser parse:dict key:@"backgroundEffect"]; + self.cornerRadius = [NumberParser parse:dict key:@"cornerRadius"]; + self.horizontalMargin = [NumberParser parse:dict key:@"horizontalMargin"]; + self.bottomMargin = [NumberParser parse:dict key:@"bottomMargin"]; + return self; +} + +- (void)mergeOptions:(RNNBottomTabsCustomRowOptions *)options { + if (options.height.hasValue) + self.height = options.height; + if (options.backgroundColor.hasValue) + self.backgroundColor = options.backgroundColor; + if (options.backgroundEffect.hasValue) + self.backgroundEffect = options.backgroundEffect; + if (options.cornerRadius.hasValue) + self.cornerRadius = options.cornerRadius; + if (options.horizontalMargin.hasValue) + self.horizontalMargin = options.horizontalMargin; + if (options.bottomMargin.hasValue) + self.bottomMargin = options.bottomMargin; +} + +- (BOOL)hasValue { + return self.height.hasValue || self.backgroundColor.hasValue || + self.backgroundEffect.hasValue || self.cornerRadius.hasValue || + self.horizontalMargin.hasValue || self.bottomMargin.hasValue; +} + +@end diff --git a/ios/RNNBottomTabsOptions.h b/ios/RNNBottomTabsOptions.h index 36eb252dc1a..bed0c9a86a3 100644 --- a/ios/RNNBottomTabsOptions.h +++ b/ios/RNNBottomTabsOptions.h @@ -1,4 +1,5 @@ #import "BottomTabsAttachMode.h" +#import "RNNBottomTabsCustomRowOptions.h" #import "RNNOptions.h" #import "RNNShadowOptions.h" @@ -22,6 +23,7 @@ @property(nonatomic, strong) Color *borderColor; @property(nonatomic, strong) Number *borderWidth; @property(nonatomic, strong) RNNShadowOptions *shadow; +@property(nonatomic, strong) RNNBottomTabsCustomRowOptions *customRow; @property(nonatomic, strong) BottomTabsAttachMode *tabsAttachMode; - (BOOL)shouldDrawBehind; diff --git a/ios/RNNBottomTabsOptions.mm b/ios/RNNBottomTabsOptions.mm index b3319d95c97..a8cfc850f25 100644 --- a/ios/RNNBottomTabsOptions.mm +++ b/ios/RNNBottomTabsOptions.mm @@ -26,6 +26,7 @@ - (instancetype)initWithDict:(NSDictionary *)dict { self.borderColor = [ColorParser parse:dict key:@"borderColor"]; self.borderWidth = [NumberParser parse:dict key:@"borderWidth"]; self.shadow = [[RNNShadowOptions alloc] initWithDict:dict[@"shadow"]]; + self.customRow = [[RNNBottomTabsCustomRowOptions alloc] initWithDict:dict[@"customRow"]]; return self; } @@ -69,6 +70,7 @@ - (void)mergeOptions:(RNNBottomTabsOptions *)options { self.borderWidth = options.borderWidth; [self.shadow mergeOptions:options.shadow]; + [self.customRow mergeOptions:options.customRow]; } - (BOOL)shouldDrawBehind { diff --git a/ios/RNNComponentViewCreator.h b/ios/RNNComponentViewCreator.h index 2f8a938ea8b..03436bac46f 100644 --- a/ios/RNNComponentViewCreator.h +++ b/ios/RNNComponentViewCreator.h @@ -9,7 +9,8 @@ typedef enum RNNComponentType { RNNComponentTypeComponent, RNNComponentTypeTopBarTitle, RNNComponentTypeTopBarButton, - RNNComponentTypeTopBarBackground + RNNComponentTypeTopBarBackground, + RNNComponentTypeBottomTabItem } RNNComponentType; @protocol RNNComponentViewCreator diff --git a/ios/RNNCustomTabBarItemView.h b/ios/RNNCustomTabBarItemView.h new file mode 100644 index 00000000000..d1b9efac0c4 --- /dev/null +++ b/ios/RNNCustomTabBarItemView.h @@ -0,0 +1,26 @@ +#import "RNNReactView.h" +#import + +/** + * Hosts an `RNNReactView` that renders a user-supplied React component as a + * bottom tab item. The view is laid out on top of the underlying + * `UITabBarButton` and forwards touches to it so native selection, + * accessibility focus and `selectTabOnPress: false` keep working. + * + * The hosted component receives `componentId`, `tabIndex`, `selected` and + * `badge` props. Selected state is updated via `setSelected:`. + */ +@interface RNNCustomTabBarItemView : UIView + +@property(nonatomic, readonly, strong) RNNReactView *reactView; + +- (instancetype)initWithReactView:(RNNReactView *)reactView + tabIndex:(NSUInteger)tabIndex + selected:(BOOL)selected + badge:(NSString *)badge; + +- (void)setSelected:(BOOL)selected; + +- (void)setBadge:(NSString *)badge; + +@end diff --git a/ios/RNNCustomTabBarItemView.mm b/ios/RNNCustomTabBarItemView.mm new file mode 100644 index 00000000000..158ddae64a2 --- /dev/null +++ b/ios/RNNCustomTabBarItemView.mm @@ -0,0 +1,83 @@ +#import "RNNCustomTabBarItemView.h" + +@interface RNNCustomTabBarItemView () + +@property(nonatomic, readwrite, strong) RNNReactView *reactView; +@property(nonatomic, assign) NSUInteger tabIndex; +@property(nonatomic, assign) BOOL isSelected; +@property(nonatomic, copy) NSString *badge; + +@end + +@implementation RNNCustomTabBarItemView + +- (instancetype)initWithReactView:(RNNReactView *)reactView + tabIndex:(NSUInteger)tabIndex + selected:(BOOL)selected + badge:(NSString *)badge { + self = [super initWithFrame:CGRectZero]; + if (self) { + _reactView = reactView; + _tabIndex = tabIndex; + _isSelected = selected; + _badge = [badge copy]; + + self.backgroundColor = [UIColor clearColor]; + self.userInteractionEnabled = NO; + + _reactView.backgroundColor = [UIColor clearColor]; + _reactView.userInteractionEnabled = NO; + [self addSubview:_reactView]; + + [self updateProps]; + } + return self; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + self.reactView.frame = self.bounds; +} + +- (CGSize)sizeThatFits:(CGSize)size { + return size; +} + +- (void)setSelected:(BOOL)selected { + if (self.isSelected == selected) { + return; + } + self.isSelected = selected; + [self updateProps]; +} + +- (void)setBadge:(NSString *)badge { + if (badge == _badge || [badge isEqualToString:_badge]) { + return; + } + _badge = [badge copy]; + [self updateProps]; +} + +- (void)updateProps { + NSMutableDictionary *props = [NSMutableDictionary dictionary]; +#ifdef RCT_NEW_ARCH_ENABLED + // RNNReactView's `properties` setter is a no-op on Fabric (it writes to + // the auto-synthesized ivar instead of the underlying surface). Update + // the React tree by writing directly to the surface so prop changes + // propagate to JS. + [props addEntriesFromDictionary:(self.reactView.surface.properties ?: @{})]; +#else + [props addEntriesFromDictionary:(self.reactView.appProperties ?: @{})]; +#endif + props[@"tabIndex"] = @(self.tabIndex); + props[@"selected"] = @(self.isSelected); + props[@"badge"] = self.badge ?: [NSNull null]; +#ifdef RCT_NEW_ARCH_ENABLED + self.reactView.surface.properties = props; +#else + self.reactView.appProperties = props; +#endif +} + +@end diff --git a/ios/RNNReactRootViewCreator.mm b/ios/RNNReactRootViewCreator.mm index 3528d320e3b..a961d027744 100644 --- a/ios/RNNReactRootViewCreator.mm +++ b/ios/RNNReactRootViewCreator.mm @@ -71,6 +71,7 @@ - (Class)resolveComponentViewClass:(RNNComponentType)componentType { return RNNReactButtonView.class; case RNNComponentTypeTopBarBackground: return RNNReactBackgroundView.class; + case RNNComponentTypeBottomTabItem: case RNNComponentTypeComponent: default: return RNNComponentRootView.class; diff --git a/ios/RNNViewControllerFactory.mm b/ios/RNNViewControllerFactory.mm index a07d6b500a2..f6fe387ddbc 100644 --- a/ios/RNNViewControllerFactory.mm +++ b/ios/RNNViewControllerFactory.mm @@ -234,6 +234,7 @@ - (UIViewController *)createBottomTabs:(RNNLayoutNode *)node { presenter:presenter bottomTabPresenter:bottomTabPresenter dotIndicatorPresenter:dotIndicatorPresenter + componentRegistry:_componentRegistry eventEmitter:_eventEmitter childViewControllers:childViewControllers bottomTabsAttacher:bottomTabsAttacher]; diff --git a/ios/ReactNativeNavigation.xcodeproj/project.pbxproj b/ios/ReactNativeNavigation.xcodeproj/project.pbxproj index c8324568c51..939a6560c07 100644 --- a/ios/ReactNativeNavigation.xcodeproj/project.pbxproj +++ b/ios/ReactNativeNavigation.xcodeproj/project.pbxproj @@ -73,6 +73,12 @@ 5012242B217372B3000F5F98 /* ImageParser.mm in Sources */ = {isa = PBXBuildFile; fileRef = 50122429217372B3000F5F98 /* ImageParser.mm */; }; 5016E8EF20209690009D4F7C /* RNNCustomTitleView.h in Headers */ = {isa = PBXBuildFile; fileRef = 5016E8ED2020968F009D4F7C /* RNNCustomTitleView.h */; }; 5016E8F020209690009D4F7C /* RNNCustomTitleView.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5016E8EE2020968F009D4F7C /* RNNCustomTitleView.mm */; }; + EE4A5C0001000000ABCD0001 /* RNNCustomTabBarItemView.h in Headers */ = {isa = PBXBuildFile; fileRef = EE4A5C0001000000ABCD0003 /* RNNCustomTabBarItemView.h */; }; + EE4A5C0001000000ABCD0002 /* RNNCustomTabBarItemView.mm in Sources */ = {isa = PBXBuildFile; fileRef = EE4A5C0001000000ABCD0004 /* RNNCustomTabBarItemView.mm */; }; + EE4A5C0001000000ABCD0011 /* RNNBottomTabsCustomRow.h in Headers */ = {isa = PBXBuildFile; fileRef = EE4A5C0001000000ABCD0013 /* RNNBottomTabsCustomRow.h */; }; + EE4A5C0001000000ABCD0012 /* RNNBottomTabsCustomRow.mm in Sources */ = {isa = PBXBuildFile; fileRef = EE4A5C0001000000ABCD0014 /* RNNBottomTabsCustomRow.mm */; }; + EE4A5C0001000000ABCD0021 /* RNNBottomTabsCustomRowOptions.h in Headers */ = {isa = PBXBuildFile; fileRef = EE4A5C0001000000ABCD0023 /* RNNBottomTabsCustomRowOptions.h */; }; + EE4A5C0001000000ABCD0022 /* RNNBottomTabsCustomRowOptions.mm in Sources */ = {isa = PBXBuildFile; fileRef = EE4A5C0001000000ABCD0024 /* RNNBottomTabsCustomRowOptions.mm */; }; 50175CD1207A2AA1004FE91B /* RNNComponentOptions.h in Headers */ = {isa = PBXBuildFile; fileRef = 50175CCF207A2AA1004FE91B /* RNNComponentOptions.h */; }; 50175CD2207A2AA1004FE91B /* RNNComponentOptions.mm in Sources */ = {isa = PBXBuildFile; fileRef = 50175CD0207A2AA1004FE91B /* RNNComponentOptions.mm */; }; 5017D9E1239D2C6C00B74047 /* BottomTabsAttachModeFactory.h in Headers */ = {isa = PBXBuildFile; fileRef = 5017D9DF239D2C6C00B74047 /* BottomTabsAttachModeFactory.h */; }; @@ -568,6 +574,12 @@ 50122429217372B3000F5F98 /* ImageParser.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ImageParser.mm; sourceTree = ""; }; 5016E8ED2020968F009D4F7C /* RNNCustomTitleView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RNNCustomTitleView.h; sourceTree = ""; }; 5016E8EE2020968F009D4F7C /* RNNCustomTitleView.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNNCustomTitleView.mm; sourceTree = ""; }; + EE4A5C0001000000ABCD0003 /* RNNCustomTabBarItemView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RNNCustomTabBarItemView.h; sourceTree = ""; }; + EE4A5C0001000000ABCD0004 /* RNNCustomTabBarItemView.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNNCustomTabBarItemView.mm; sourceTree = ""; }; + EE4A5C0001000000ABCD0013 /* RNNBottomTabsCustomRow.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RNNBottomTabsCustomRow.h; sourceTree = ""; }; + EE4A5C0001000000ABCD0014 /* RNNBottomTabsCustomRow.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNNBottomTabsCustomRow.mm; sourceTree = ""; }; + EE4A5C0001000000ABCD0023 /* RNNBottomTabsCustomRowOptions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RNNBottomTabsCustomRowOptions.h; sourceTree = ""; }; + EE4A5C0001000000ABCD0024 /* RNNBottomTabsCustomRowOptions.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNNBottomTabsCustomRowOptions.mm; sourceTree = ""; }; 50175CCF207A2AA1004FE91B /* RNNComponentOptions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNNComponentOptions.h; sourceTree = ""; }; 50175CD0207A2AA1004FE91B /* RNNComponentOptions.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNNComponentOptions.mm; sourceTree = ""; }; 5017D9DF239D2C6C00B74047 /* BottomTabsAttachModeFactory.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BottomTabsAttachModeFactory.h; sourceTree = ""; }; @@ -1663,6 +1675,12 @@ children = ( 5016E8ED2020968F009D4F7C /* RNNCustomTitleView.h */, 5016E8EE2020968F009D4F7C /* RNNCustomTitleView.mm */, + EE4A5C0001000000ABCD0003 /* RNNCustomTabBarItemView.h */, + EE4A5C0001000000ABCD0004 /* RNNCustomTabBarItemView.mm */, + EE4A5C0001000000ABCD0013 /* RNNBottomTabsCustomRow.h */, + EE4A5C0001000000ABCD0014 /* RNNBottomTabsCustomRow.mm */, + EE4A5C0001000000ABCD0023 /* RNNBottomTabsCustomRowOptions.h */, + EE4A5C0001000000ABCD0024 /* RNNBottomTabsCustomRowOptions.mm */, E8A5CD601F49114F00E89D0D /* RNNElement.h */, E8A5CD611F49114F00E89D0D /* RNNElement.mm */, E8AEDB3A1F55A1C2000F5A6A /* RNNElementView.h */, @@ -1737,6 +1755,9 @@ E5F6C3A422DB4D0F0093C2CE /* UIColor+RNNUtils.h in Headers */, 5038A3C1216E1E66009280BC /* RNNFontAttributesCreator.h in Headers */, 5016E8EF20209690009D4F7C /* RNNCustomTitleView.h in Headers */, + EE4A5C0001000000ABCD0001 /* RNNCustomTabBarItemView.h in Headers */, + EE4A5C0001000000ABCD0011 /* RNNBottomTabsCustomRow.h in Headers */, + EE4A5C0001000000ABCD0021 /* RNNBottomTabsCustomRowOptions.h in Headers */, 500623A525B7003A0086AB39 /* RNNShadowOptions.h in Headers */, 50415CBA20553B8E00BB682E /* RNNScreenTransition.h in Headers */, 5039558B2174829400B0A663 /* IntNumberParser.h in Headers */, @@ -2042,6 +2063,9 @@ 50CB3B6A1FDE911400AA153B /* RNNSideMenuOptions.mm in Sources */, 503A8A0623BB850A0094D1C4 /* TimeInterval.mm in Sources */, 5016E8F020209690009D4F7C /* RNNCustomTitleView.mm in Sources */, + EE4A5C0001000000ABCD0002 /* RNNCustomTabBarItemView.mm in Sources */, + EE4A5C0001000000ABCD0012 /* RNNBottomTabsCustomRow.mm in Sources */, + EE4A5C0001000000ABCD0022 /* RNNBottomTabsCustomRowOptions.mm in Sources */, 5061B6C823D48449008B9827 /* VerticalRotationTransition.mm in Sources */, 5022EDB62405224B00852BA6 /* BottomTabPresenter.mm in Sources */, 503955982174864E00B0A663 /* NullDouble.mm in Sources */, diff --git a/package.json b/package.json index 86c44080b2f..119817997e9 100644 --- a/package.json +++ b/package.json @@ -183,4 +183,4 @@ ] ] } -} \ No newline at end of file +} diff --git a/playground/e2e/CustomBottomTabComponent.test.js b/playground/e2e/CustomBottomTabComponent.test.js new file mode 100644 index 00000000000..03d055024ae --- /dev/null +++ b/playground/e2e/CustomBottomTabComponent.test.js @@ -0,0 +1,79 @@ +import Utils from './Utils'; +import TestIDs from '../src/testIDs'; + +const { elementById, sleep } = Utils; + +async function openCustomBottomTabsModal() { + await elementById(TestIDs.BOTTOM_TABS_CUSTOM_COMPONENT_BTN).tap(); + await waitFor(elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_0)) + .toBeVisible() + .withTimeout(5000); + await waitFor(elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_1)) + .toBeVisible() + .withTimeout(5000); + await waitFor(elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_2)) + .toBeVisible() + .withTimeout(5000); +} + +describe.e2e('Custom BottomTab Component', () => { + beforeEach(async () => { + await device.launchApp({ newInstance: true }); + await openCustomBottomTabsModal(); + }); + + it('renders a custom React component for every tab', async () => { + await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_0)).toBeVisible(); + await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_1)).toBeVisible(); + await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_2)).toBeVisible(); + }); + + it('mounts the first tab content by default', async () => { + await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_SELECTED_LABEL)).toHaveText('Home content'); + }); + + it('shows the badge on the Search tab item', async () => { + await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_BADGE)).toBeVisible(); + await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_BADGE)).toHaveText('3'); + }); + + it('switches to the second tab when its custom item is tapped', async () => { + await elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_1).tap(); + await waitFor(elementById(TestIDs.CUSTOM_BOTTOM_TAB_SELECTED_LABEL)) + .toHaveText('Search content') + .withTimeout(3000); + }); + + it('switches to the third tab when its custom item is tapped', async () => { + await elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_2).tap(); + await waitFor(elementById(TestIDs.CUSTOM_BOTTOM_TAB_SELECTED_LABEL)) + .toHaveText('Profile content') + .withTimeout(3000); + }); + + it('switches back to the first tab when its custom item is tapped', async () => { + await elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_2).tap(); + await waitFor(elementById(TestIDs.CUSTOM_BOTTOM_TAB_SELECTED_LABEL)) + .toHaveText('Profile content') + .withTimeout(3000); + await elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_0).tap(); + await waitFor(elementById(TestIDs.CUSTOM_BOTTOM_TAB_SELECTED_LABEL)) + .toHaveText('Home content') + .withTimeout(3000); + }); + + it('cycles through all tabs in order', async () => { + const tabs = [ + { item: TestIDs.CUSTOM_BOTTOM_TAB_ITEM_1, label: 'Search content' }, + { item: TestIDs.CUSTOM_BOTTOM_TAB_ITEM_2, label: 'Profile content' }, + { item: TestIDs.CUSTOM_BOTTOM_TAB_ITEM_0, label: 'Home content' }, + ]; + for (const { item, label } of tabs) { + await elementById(item).tap(); + await sleep(300); + await waitFor(elementById(TestIDs.CUSTOM_BOTTOM_TAB_SELECTED_LABEL)) + .toHaveText(label) + .withTimeout(3000); + } + }); +}); diff --git a/playground/ios/NavigationTests/BottomTabsControllerTest.mm b/playground/ios/NavigationTests/BottomTabsControllerTest.mm index 8d7eb81d7ed..c43d7f36461 100644 --- a/playground/ios/NavigationTests/BottomTabsControllerTest.mm +++ b/playground/ios/NavigationTests/BottomTabsControllerTest.mm @@ -42,6 +42,7 @@ - (void)setUp { presenter:self.mockTabBarPresenter bottomTabPresenter:[BottomTabPresenterCreator createWithDefaultOptions:nil] dotIndicatorPresenter:[[RNNDotIndicatorPresenter alloc] initWithDefaultOptions:nil] + componentRegistry:nil eventEmitter:self.mockEventEmitter childViewControllers:children bottomTabsAttacher:nil]; @@ -77,6 +78,7 @@ - (void)testInitWithLayoutInfo_shouldInitializeDependencies { presenter:presenter bottomTabPresenter:nil dotIndicatorPresenter:nil + componentRegistry:nil eventEmitter:eventEmmiter childViewControllers:childViewControllers bottomTabsAttacher:nil]; @@ -287,6 +289,7 @@ - (void)testOnViewDidLayoutSubviews_ShouldUpdateDotIndicatorForChildren { presenter:nil bottomTabPresenter:nil dotIndicatorPresenter:dotIndicator + componentRegistry:nil eventEmitter:nil childViewControllers:@[ [UIViewController new], vc ] bottomTabsAttacher:nil]; @@ -352,6 +355,7 @@ - (void)testInit_shouldCreateTabBarItems { bottomTabPresenter:[BottomTabPresenterCreator createWithDefaultOptions:RNNNavigationOptions.emptyOptions] dotIndicatorPresenter:dotIndicator + componentRegistry:nil eventEmitter:nil childViewControllers:@[ vc1, stack ] bottomTabsAttacher:nil]; diff --git a/playground/ios/NavigationTests/RNNBottomTabsAppearancePresenterTest.mm b/playground/ios/NavigationTests/RNNBottomTabsAppearancePresenterTest.mm index 46cd89f0e46..d74df9fa587 100644 --- a/playground/ios/NavigationTests/RNNBottomTabsAppearancePresenterTest.mm +++ b/playground/ios/NavigationTests/RNNBottomTabsAppearancePresenterTest.mm @@ -43,6 +43,7 @@ - (void)setUp { bottomTabPresenter:[BottomTabPresenterCreator createWithDefaultOptions:nil] dotIndicatorPresenter:self.dotIndicatorPresenter + componentRegistry:nil eventEmitter:nil childViewControllers:self.children bottomTabsAttacher:nil]]; diff --git a/playground/ios/NavigationTests/RNNBottomTabsController+Helpers.mm b/playground/ios/NavigationTests/RNNBottomTabsController+Helpers.mm index 7106c7eaea0..7b9fe562b49 100644 --- a/playground/ios/NavigationTests/RNNBottomTabsController+Helpers.mm +++ b/playground/ios/NavigationTests/RNNBottomTabsController+Helpers.mm @@ -25,6 +25,7 @@ + (RNNBottomTabsController *)createWithChildren:(NSArray *)children bottomTabPresenter:[BottomTabPresenterCreator createWithDefaultOptions:defaultOptions] dotIndicatorPresenter:[[RNNDotIndicatorPresenter alloc] initWithDefaultOptions:defaultOptions] + componentRegistry:nil eventEmitter:[OCMockObject partialMockForObject:[RNNEventEmitter new]] childViewControllers:children bottomTabsAttacher:nil]; diff --git a/playground/ios/NavigationTests/RNNCommandsHandlerTest.mm b/playground/ios/NavigationTests/RNNCommandsHandlerTest.mm index 3c552258e91..446e292c3b8 100644 --- a/playground/ios/NavigationTests/RNNCommandsHandlerTest.mm +++ b/playground/ios/NavigationTests/RNNCommandsHandlerTest.mm @@ -461,6 +461,7 @@ - (void)testSetRoot_withBottomTabsAttachModeTogether { presenter:[RNNBasePresenter new] bottomTabPresenter:nil dotIndicatorPresenter:nil + componentRegistry:nil eventEmitter:_eventEmmiter childViewControllers:@[ _vc1, _vc2 ] bottomTabsAttacher:attacher]; @@ -494,6 +495,7 @@ - (void)testSetRoot_withBottomTabsAttachModeOnSwitchToTab { presenter:[RNNBasePresenter new] bottomTabPresenter:nil dotIndicatorPresenter:nil + componentRegistry:nil eventEmitter:_eventEmmiter childViewControllers:@[ _vc1, _vc2 ] bottomTabsAttacher:attacher]; @@ -531,6 +533,7 @@ - (void)testSetRoot_withBottomTabsAttachModeOnSwitchToTabWithCustomIndex { presenter:[RNNBasePresenter new] bottomTabPresenter:nil dotIndicatorPresenter:nil + componentRegistry:nil eventEmitter:_eventEmmiter childViewControllers:@[ _vc1, _vc2, _vc3 ] bottomTabsAttacher:attacher]; @@ -575,6 +578,7 @@ - (void)testSetRoot_withBottomTabsAttachModeAfterInitialTab { presenter:[RNNBasePresenter new] bottomTabPresenter:nil dotIndicatorPresenter:nil + componentRegistry:nil eventEmitter:_eventEmmiter childViewControllers:@[ _vc1, _vc2 ] bottomTabsAttacher:attacher]; @@ -674,6 +678,7 @@ - (void)testMergeOptions_shouldMergeWithChildOnly { bottomTabPresenter:[BottomTabPresenterCreator createWithDefaultOptions:[RNNNavigationOptions emptyOptions]] dotIndicatorPresenter:nil + componentRegistry:nil eventEmitter:_eventEmmiter childViewControllers:@[ firstChild, secondChild ] bottomTabsAttacher:nil]; @@ -729,6 +734,7 @@ - (void)testMergeOptions_shouldResolveTreeOptions { bottomTabPresenter:[BottomTabPresenterCreator createWithDefaultOptions:[RNNNavigationOptions emptyOptions]] dotIndicatorPresenter:nil + componentRegistry:nil eventEmitter:_eventEmmiter childViewControllers:@[ stack, secondChild ] bottomTabsAttacher:nil]; diff --git a/playground/ios/NavigationTests/UIViewController+LayoutProtocolTest.mm b/playground/ios/NavigationTests/UIViewController+LayoutProtocolTest.mm index 6b744ac6fe1..66c5f5bad20 100644 --- a/playground/ios/NavigationTests/UIViewController+LayoutProtocolTest.mm +++ b/playground/ios/NavigationTests/UIViewController+LayoutProtocolTest.mm @@ -260,6 +260,7 @@ - (void)testConstants_shouldReturnNavigationBarHeight_visible { presenter:[RNNBasePresenter new] bottomTabPresenter:nil dotIndicatorPresenter:nil + componentRegistry:nil eventEmitter:nil childViewControllers:@[ stack, stack2 ] bottomTabsAttacher:nil]; @@ -299,6 +300,7 @@ - (void)testConstants_shouldReturnNavigationBarHeight_invisible { presenter:[RNNBasePresenter new] bottomTabPresenter:nil dotIndicatorPresenter:nil + componentRegistry:nil eventEmitter:nil childViewControllers:@[ stack, stack2 ] bottomTabsAttacher:nil]; diff --git a/playground/src/screens/CustomBottomTabContentScreen.tsx b/playground/src/screens/CustomBottomTabContentScreen.tsx new file mode 100644 index 00000000000..65dec9d4577 --- /dev/null +++ b/playground/src/screens/CustomBottomTabContentScreen.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { + NavigationComponent, + NavigationProps, + Options, +} from 'react-native-navigation'; +import testIDs from '../testIDs'; + +interface Props extends NavigationProps { + title?: string; +} + +export default class CustomBottomTabContentScreen extends NavigationComponent { + static options(): Options { + return { + topBar: { visible: false }, + }; + } + + render() { + const title = this.props.title ?? 'Tab content'; + return ( + + + {title} + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'white', + }, + text: { + fontSize: 22, + fontWeight: '600', + }, +}); diff --git a/playground/src/screens/CustomBottomTabItem.tsx b/playground/src/screens/CustomBottomTabItem.tsx new file mode 100644 index 00000000000..9f8d7a962cf --- /dev/null +++ b/playground/src/screens/CustomBottomTabItem.tsx @@ -0,0 +1,252 @@ +import React, { useEffect, useRef } from 'react'; +import { Animated, Platform, StyleSheet, Text, View } from 'react-native'; +import testIDs from '../testIDs'; + +interface Props { + componentId: string; + tabIndex: number; + selected?: boolean; + badge?: string | null; + label?: string; +} + +const TAB_TEST_IDS = [ + testIDs.CUSTOM_BOTTOM_TAB_ITEM_0, + testIDs.CUSTOM_BOTTOM_TAB_ITEM_1, + testIDs.CUSTOM_BOTTOM_TAB_ITEM_2, +]; + +const TAB_LABELS = ['Home', 'Search', 'Profile']; + +const UNSELECTED_COLOR = '#9aa0a6'; +const SELECTED_COLOR = '#007aff'; +const SELECTED_PILL_COLOR = 'rgba(0, 122, 255, 0.14)'; +const ICON_BOX = 22; +const STROKE = 1.8; + +/** + * Pure-RN-primitive icons composed from absolutely-positioned Views with + * borderRadius / rotation. They render byte-identically on both platforms + * (no system font, no native dependency). + */ +function HomeIcon({ + color, + size = ICON_BOX, +}: { + color: Animated.AnimatedInterpolation; + size?: number; +}) { + const halfRoof = size / 2; + return ( + + + + + ); +} + +function SearchIcon({ + color, + size = ICON_BOX, +}: { + color: Animated.AnimatedInterpolation; + size?: number; +}) { + const lens = size * 0.65; + return ( + + + + + ); +} + +function ProfileIcon({ + color, + size = ICON_BOX, +}: { + color: Animated.AnimatedInterpolation; + size?: number; +}) { + const headSize = size * 0.42; + const bodyHeight = size * 0.5; + return ( + + + + + ); +} + +function renderIcon(tabIndex: number, color: Animated.AnimatedInterpolation) { + switch (tabIndex) { + case 0: + return ; + case 1: + return ; + case 2: + default: + return ; + } +} + +export default function CustomBottomTabItem(props: Props) { + const tabIndex = props.tabIndex ?? 0; + const label = props.label ?? TAB_LABELS[tabIndex] ?? `Tab ${tabIndex}`; + const testID = TAB_TEST_IDS[tabIndex]; + const selected = !!props.selected; + + const anim = useRef(new Animated.Value(selected ? 1 : 0)).current; + + useEffect(() => { + Animated.spring(anim, { + toValue: selected ? 1 : 0, + friction: 7, + tension: 90, + useNativeDriver: false, + }).start(); + }, [selected, anim]); + + const pillBackground = anim.interpolate({ + inputRange: [0, 1], + outputRange: ['rgba(0, 122, 255, 0)', SELECTED_PILL_COLOR], + }); + const tintColor = anim.interpolate({ + inputRange: [0, 1], + outputRange: [UNSELECTED_COLOR, SELECTED_COLOR], + }); + const scale = anim.interpolate({ + inputRange: [0, 1], + outputRange: [1, 1.04], + }); + + return ( + + + + {renderIcon(tabIndex, tintColor)} + {props.badge ? ( + + + {props.badge} + + + ) : null} + + {label} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: Platform.OS === 'android' ? 'flex-end' : 'center', + paddingHorizontal: 4, + paddingBottom: Platform.OS === 'android' ? 2 : 0, + }, + pill: { + alignSelf: 'stretch', + height: 52, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 26, + }, + iconWrapper: { + alignItems: 'center', + justifyContent: 'center', + }, + label: { + marginTop: 2, + fontSize: 11, + color: UNSELECTED_COLOR, + fontWeight: '600', + }, + badge: { + position: 'absolute', + top: -6, + left: 14, + minWidth: 20, + height: 20, + paddingHorizontal: 5, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#ff3b30', + }, + badgeText: { + color: 'white', + fontSize: 12, + fontWeight: '700', + lineHeight: 14, + }, +}); diff --git a/playground/src/screens/LayoutsScreen.tsx b/playground/src/screens/LayoutsScreen.tsx index 0dc044aee3d..b78704b17c5 100644 --- a/playground/src/screens/LayoutsScreen.tsx +++ b/playground/src/screens/LayoutsScreen.tsx @@ -25,6 +25,7 @@ const { SPLIT_VIEW_BUTTON, BOTTOM_TABS_ROLE_BTN, BOTTOM_TABS_ROLE_SEARCH_TAB, + BOTTOM_TABS_CUSTOM_COMPONENT_BTN, } = testIDs; interface State { @@ -84,6 +85,11 @@ export default class LayoutsScreen extends NavigationComponent +