From b4c4ffc3b677f55341614712bcba9bc06f7a49ab Mon Sep 17 00:00:00 2001 From: Mark de Vocht Date: Thu, 14 May 2026 09:14:35 +0300 Subject: [PATCH 01/13] initial commit --- .../options/BottomTabOptions.java | 5 +- .../react/ReactView.java | 13 + .../react/events/ComponentType.java | 3 +- .../bottomtabs/BottomTabPresenter.java | 28 ++ .../bottomtabs/BottomTabsController.java | 59 +++++ .../views/bottomtabs/BottomTabs.java | 63 +++++ .../bottomtabs/CustomBottomTabItemView.kt | 73 ++++++ ios/BottomTabPresenter.h | 7 + ios/BottomTabPresenter.mm | 44 ++++ ios/RNNBottomTabOptions.h | 2 + ios/RNNBottomTabOptions.mm | 6 +- ios/RNNBottomTabsController.h | 2 + ios/RNNBottomTabsController.mm | 244 +++++++++++++++++- ios/RNNComponentViewCreator.h | 3 +- ios/RNNCustomTabBarItemView.h | 26 ++ ios/RNNCustomTabBarItemView.mm | 83 ++++++ ios/RNNReactRootViewCreator.mm | 1 + ios/RNNViewControllerFactory.mm | 1 + .../project.pbxproj | 8 + .../e2e/CustomBottomTabComponent.test.js | 38 +++ .../BottomTabsControllerTest.mm | 4 + .../RNNBottomTabsController+Helpers.mm | 1 + .../NavigationTests/RNNCommandsHandlerTest.mm | 4 + .../UIViewController+LayoutProtocolTest.mm | 2 + .../screens/CustomBottomTabContentScreen.tsx | 44 ++++ .../src/screens/CustomBottomTabItem.tsx | 74 ++++++ playground/src/screens/LayoutsScreen.tsx | 29 +++ playground/src/screens/Screens.ts | 2 + playground/src/screens/index.tsx | 8 + playground/src/testIDs.ts | 7 + src/interfaces/Options.ts | 39 +++ 31 files changed, 910 insertions(+), 13 deletions(-) create mode 100644 android/src/main/java/com/reactnativenavigation/views/bottomtabs/CustomBottomTabItemView.kt create mode 100644 ios/RNNCustomTabBarItemView.h create mode 100644 ios/RNNCustomTabBarItemView.mm create mode 100644 playground/e2e/CustomBottomTabComponent.test.js create mode 100644 playground/src/screens/CustomBottomTabContentScreen.tsx create mode 100644 playground/src/screens/CustomBottomTabItem.tsx 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..d463be901f7 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,7 @@ public class BottomTabs extends AHBottomNavigation { private boolean itemsCreationEnabled = true; private boolean shouldCreateItems = true; private List onItemCreationEnabled = new ArrayList<>(); + private final List customItemViews = new ArrayList<>(); public BottomTabs(Context context) { super(context); @@ -131,6 +134,66 @@ 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() { + 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..0c36c6155c3 100644 --- a/ios/BottomTabPresenter.mm +++ b/ios/BottomTabPresenter.mm @@ -36,10 +36,54 @@ - (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 `UITabBarItem` that holds a slot in the native bar but renders +// nothing visible. We provide a transparent 24×24 placeholder image (rather +// than `nil`) so that iOS 26's floating pill reserves its normal size — the +// React-rendered `RNNCustomTabBarItemView` is overlaid on the resulting tab +// button by `RNNBottomTabsController`. Title is left empty for the same +// reason: an empty string still lets UIKit size the pill consistently. +- (UITabBarItem *)createBlankTabBarItem:(UIViewController *)child + bottomTabOptions:(RNNBottomTabOptions *)bottomTabOptions { + UITabBarItem *item = child.tabBarItem ?: [UITabBarItem new]; + UIImage *placeholder = [BottomTabPresenter rnn_transparentPlaceholderImage]; + item.image = placeholder; + item.selectedImage = placeholder; + item.title = @""; + item.tag = bottomTabOptions.tag; + item.accessibilityIdentifier = [bottomTabOptions.testID withDefault:nil]; + item.accessibilityLabel = [bottomTabOptions.accessibilityLabel withDefault:nil]; + item.imageInsets = UIEdgeInsetsZero; + return item; +} + ++ (UIImage *)rnn_transparentPlaceholderImage { + static UIImage *image; + static dispatch_once_t once; + dispatch_once(&once, ^{ + CGSize size = CGSizeMake(24, 24); + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size]; + UIImage *rendered = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) { + [UIColor.clearColor setFill]; + UIRectFill(CGRectMake(0, 0, size.width, size.height)); + }]; + image = [rendered imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]; + }); + return image; +} + @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..f7e4c20d269 100644 --- a/ios/RNNBottomTabsController.mm +++ b/ios/RNNBottomTabsController.mm @@ -1,7 +1,9 @@ #import "RNNBottomTabsController.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 +23,9 @@ @implementation RNNBottomTabsController { BOOL _didFinishSetup; BOOL _rnnDidApplyInitialTabBarSelectionFix; BOOL _rnnSuppressTabSelectionEvents; + RNNReactComponentRegistry *_componentRegistry; + NSMutableArray *_customTabItemViews; + BOOL _useCustomItemViews; } - (instancetype)initWithLayoutInfo:(RNNLayoutInfo *)layoutInfo @@ -30,14 +35,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]) { @@ -54,18 +63,26 @@ - (instancetype)initWithLayoutInfo:(RNNLayoutInfo *)layoutInfo eventEmitter:eventEmitter childViewControllers:childViewControllers]; + [self resolveCustomItemViewMode:childViewControllers]; + if (@available(iOS 13.0, *)) { - UITabBarAppearance *appearance = [UITabBarAppearance new]; - [appearance configureWithOpaqueBackground]; - appearance.backgroundEffect = nil; - appearance.backgroundColor = UIColor.systemBackgroundColor; - self.tabBar.standardAppearance = appearance; + // The opaque-white standardAppearance below was added long ago for an + // iOS 13/15 background-color issue. On iOS 26 it overrides the new + // floating-glass platter look. Skip it when custom item views are + // active so iOS 26 renders its native floating tab bar. + if (!_useCustomItemViews) { + UITabBarAppearance *appearance = [UITabBarAppearance new]; + [appearance configureWithOpaqueBackground]; + appearance.backgroundEffect = nil; + appearance.backgroundColor = UIColor.systemBackgroundColor; + self.tabBar.standardAppearance = appearance; #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 150000 - if (@available(iOS 15.0, *)) { - self.tabBar.scrollEdgeAppearance = [appearance copy]; - } + if (@available(iOS 15.0, *)) { + self.tabBar.scrollEdgeAppearance = [appearance copy]; + } #endif - self.tabBar.translucent = NO; + self.tabBar.translucent = NO; + } } [self createTabBarItems:childViewControllers]; @@ -128,13 +145,205 @@ - (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 syncTabBarItemTestIDs]; } +- (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]; +} + +// Walks `tabBar`'s view tree and returns the views that look like tab buttons. +// On iOS <26 these are direct `UITabBarButton` subviews of `UITabBar`. On +// iOS 26 the floating platter wraps a layout container that holds private +// `_UITabButton` views. We don't depend on the exact class hierarchy: we +// match by class name suffix and return only views that are visible (non-zero +// frame, not hidden, alpha > 0). +- (NSArray *)findTabBarButtonViews { + NSMutableArray *result = [NSMutableArray array]; + [self collectTabBarButtonViewsInView:self.tabBar into:result]; + + [result filterUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(UIView *view, NSDictionary *_) { + if (view.hidden || view.alpha < 0.01) { + return NO; + } + return view.bounds.size.width > 0 && view.bounds.size.height > 0; + }]]; + + UITabBarController *strongSelf = self; + [result sortUsingComparator:^NSComparisonResult(UIView *a, UIView *b) { + CGRect frameA = [a.superview convertRect:a.frame toView:strongSelf.view]; + CGRect frameB = [b.superview convertRect:b.frame toView:strongSelf.view]; + if (frameA.origin.x < frameB.origin.x) return NSOrderedAscending; + if (frameA.origin.x > frameB.origin.x) return NSOrderedDescending; + return NSOrderedSame; + }]; + + return result; +} + +- (void)collectTabBarButtonViewsInView:(UIView *)view + into:(NSMutableArray *)result { + NSString *className = NSStringFromClass([view class]); + // `UITabBarButton` (legacy) and `_UITabButton` (iOS 26) are the leaf tap + // targets we care about. + if ([className isEqualToString:@"UITabBarButton"] || + [className isEqualToString:@"_UITabButton"]) { + // iOS 26 renders TWO `_UITabButton`s per tab: one inside the + // platter (`_UITabBarPlatterView.ContentView`) — that's the real + // interactive button — and one inside the selection-content view + // (`_UITabBarVisualProvider_Floating.SelectedContentView`) used to + // animate the highlighted pill background. Skip the latter so we + // don't attach our React view to a non-interactive duplicate. + UIView *ancestor = view.superview; + while (ancestor) { + NSString *aName = NSStringFromClass([ancestor class]); + if ([aName containsString:@"SelectedContentView"]) { + return; + } + ancestor = ancestor.superview; + } + [result addObject:view]; + return; // Don't descend — these are leaf controls. + } + for (UIView *subview in view.subviews) { + [self collectTabBarButtonViewsInView:subview into:result]; + } +} + +// Attaches each `RNNCustomTabBarItemView` as a subview of the corresponding +// native tab button, frame-matched to the button's bounds. The native bar +// keeps drawing its background (legacy chrome / iOS 26 floating glass) and +// the system selected pill — our React view renders inside the slot. +// +// Native taps reach the underlying button because our React view stack has +// `userInteractionEnabled = NO`, so hit-testing falls through to the button. +// UIKit then triggers `tabBarController:didSelectViewController:` and +// `setSelectedViewController:`, which already update our selection state. +- (void)attachCustomItemViewsToButtons { + if (!_useCustomItemViews || _customTabItemViews.count == 0) { + return; + } + + NSArray *buttons = [self findTabBarButtonViews]; + NSLog(@"[RNN.CustomTab] attach attempt buttons=%lu items=%lu tabBar.bounds=%@ tabBar.safeBottom=%.1f", + (unsigned long)buttons.count, + (unsigned long)_customTabItemViews.count, + NSStringFromCGRect(self.tabBar.bounds), + self.tabBar.safeAreaInsets.bottom); + for (NSUInteger i = 0; i < buttons.count; i++) { + UIView *b = buttons[i]; + CGRect inSelf = [b.superview convertRect:b.frame toView:self.view]; + NSLog(@"[RNN.CustomTab] candidate %lu cls=%@ parent=%@ frame=%@ inSelf=%@ hidden=%d alpha=%.2f", + (unsigned long)i, + NSStringFromClass([b class]), + NSStringFromClass([b.superview class]), + NSStringFromCGRect(b.frame), + NSStringFromCGRect(inSelf), + b.hidden, + b.alpha); + } + NSUInteger count = MIN(buttons.count, _customTabItemViews.count); + if (count == 0) { + return; + } + + CGFloat safeBottom = self.tabBar.safeAreaInsets.bottom; + + for (NSUInteger i = 0; i < count; i++) { + UIView *button = buttons[i]; + RNNCustomTabBarItemView *itemView = _customTabItemViews[i]; + if (itemView.superview != button) { + [itemView removeFromSuperview]; + [button addSubview:itemView]; + } + CGRect contentFrame = button.bounds; + if (safeBottom > 0 && contentFrame.size.height > safeBottom + 24) { + contentFrame.size.height -= safeBottom; + } + itemView.frame = contentFrame; + itemView.autoresizingMask = UIViewAutoresizingFlexibleWidth; + [button bringSubviewToFront:itemView]; + } +} + +- (void)updateCustomTabItemSelection { + if (!_useCustomItemViews) { + return; + } + for (NSUInteger i = 0; i < _customTabItemViews.count; i++) { + [_customTabItemViews[i] setSelected:(i == _currentTabIndex)]; + } +} + - (void)mergeChildOptions:(RNNNavigationOptions *)options child:(UIViewController *)child { [super mergeChildOptions:options child:child]; UIViewController *childViewController = [self findViewController:child]; @@ -145,6 +354,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 +377,7 @@ - (void)viewDidLayoutSubviews { [self syncTabBarItemTestIDs]; [self.presenter viewDidLayoutSubviews]; [_dotIndicatorPresenter bottomTabsDidLayoutSubviews:self]; + [self attachCustomItemViewsToButtons]; } - (UIViewController *)getCurrentChild { @@ -195,6 +412,7 @@ - (void)setSelectedIndex:(NSUInteger)selectedIndex { } [super setSelectedIndex:_currentTabIndex]; + [self updateCustomTabItemSelection]; } - (UIViewController *)selectedViewController { @@ -205,6 +423,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 +502,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/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..4e862c89133 100644 --- a/ios/ReactNativeNavigation.xcodeproj/project.pbxproj +++ b/ios/ReactNativeNavigation.xcodeproj/project.pbxproj @@ -73,6 +73,8 @@ 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 */; }; 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 +570,8 @@ 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 = ""; }; 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 +1667,8 @@ children = ( 5016E8ED2020968F009D4F7C /* RNNCustomTitleView.h */, 5016E8EE2020968F009D4F7C /* RNNCustomTitleView.mm */, + EE4A5C0001000000ABCD0003 /* RNNCustomTabBarItemView.h */, + EE4A5C0001000000ABCD0004 /* RNNCustomTabBarItemView.mm */, E8A5CD601F49114F00E89D0D /* RNNElement.h */, E8A5CD611F49114F00E89D0D /* RNNElement.mm */, E8AEDB3A1F55A1C2000F5A6A /* RNNElementView.h */, @@ -1737,6 +1743,7 @@ E5F6C3A422DB4D0F0093C2CE /* UIColor+RNNUtils.h in Headers */, 5038A3C1216E1E66009280BC /* RNNFontAttributesCreator.h in Headers */, 5016E8EF20209690009D4F7C /* RNNCustomTitleView.h in Headers */, + EE4A5C0001000000ABCD0001 /* RNNCustomTabBarItemView.h in Headers */, 500623A525B7003A0086AB39 /* RNNShadowOptions.h in Headers */, 50415CBA20553B8E00BB682E /* RNNScreenTransition.h in Headers */, 5039558B2174829400B0A663 /* IntNumberParser.h in Headers */, @@ -2042,6 +2049,7 @@ 50CB3B6A1FDE911400AA153B /* RNNSideMenuOptions.mm in Sources */, 503A8A0623BB850A0094D1C4 /* TimeInterval.mm in Sources */, 5016E8F020209690009D4F7C /* RNNCustomTitleView.mm in Sources */, + EE4A5C0001000000ABCD0002 /* RNNCustomTabBarItemView.mm in Sources */, 5061B6C823D48449008B9827 /* VerticalRotationTransition.mm in Sources */, 5022EDB62405224B00852BA6 /* BottomTabPresenter.mm in Sources */, 503955982174864E00B0A663 /* NullDouble.mm in Sources */, diff --git a/playground/e2e/CustomBottomTabComponent.test.js b/playground/e2e/CustomBottomTabComponent.test.js new file mode 100644 index 00000000000..45502b072db --- /dev/null +++ b/playground/e2e/CustomBottomTabComponent.test.js @@ -0,0 +1,38 @@ +import Utils from './Utils'; +import TestIDs from '../src/testIDs'; + +const { elementById } = Utils; + +describe.e2e('Custom BottomTab Component', () => { + beforeEach(async () => { + await device.launchApp({ newInstance: true }); + await elementById(TestIDs.BOTTOM_TABS_CUSTOM_COMPONENT_BTN).tap(); + await new Promise((r) => setTimeout(r, 800)); + await device.takeScreenshot('after_open_modal'); + await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_0)).toBeVisible(); + }); + + 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('switches to the second tab when its custom item is tapped', async () => { + await elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_1).tap(); + await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_SELECTED_LABEL)).toHaveText( + 'Search content' + ); + }); + + it('switches to the third tab when its custom item is tapped', async () => { + await elementById(TestIDs.CUSTOM_BOTTOM_TAB_ITEM_2).tap(); + await expect(elementById(TestIDs.CUSTOM_BOTTOM_TAB_SELECTED_LABEL)).toHaveText( + 'Profile content' + ); + }); +}); 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/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..c330e64b963 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]; 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..eff62f9dbd2 --- /dev/null +++ b/playground/src/screens/CustomBottomTabItem.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { 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 TAB_GLYPHS = ['◉', '◎', '◍']; + +export default function CustomBottomTabItem(props: Props) { + const tabIndex = props.tabIndex ?? 0; + const label = props.label ?? TAB_LABELS[tabIndex] ?? `Tab ${tabIndex}`; + const glyph = TAB_GLYPHS[tabIndex] ?? '●'; + const testID = TAB_TEST_IDS[tabIndex]; + const selected = !!props.selected; + + return ( + + {glyph} + {label} + {props.badge ? {props.badge} : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + selectedContainer: { + backgroundColor: 'rgba(0, 122, 255, 0.08)', + }, + glyph: { + fontSize: 22, + color: '#9aa0a6', + }, + label: { + marginTop: 2, + fontSize: 11, + color: '#9aa0a6', + }, + selectedText: { + color: '#007aff', + fontWeight: '600', + }, + badge: { + position: 'absolute', + top: 4, + right: 16, + minWidth: 16, + paddingHorizontal: 4, + borderRadius: 8, + fontSize: 10, + color: 'white', + backgroundColor: '#ff3b30', + overflow: 'hidden', + textAlign: 'center', + }, +}); diff --git a/playground/src/screens/LayoutsScreen.tsx b/playground/src/screens/LayoutsScreen.tsx index 0dc044aee3d..8aec4ccbb66 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 +