diff --git a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView.kt new file mode 100644 index 00000000..720108a7 --- /dev/null +++ b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView.kt @@ -0,0 +1,380 @@ +package com.rcttabview + +import android.content.Context +import android.content.res.Configuration +import android.graphics.drawable.Drawable +import android.os.Build +import android.view.HapticFeedbackConstants +import android.view.MenuItem +import android.view.View +import android.widget.TextView +import coil3.ImageLoader +import coil3.asDrawable +import coil3.request.ImageRequest +import coil3.svg.SvgDecoder +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.common.assets.ReactFontManager +import com.facebook.react.views.text.ReactTypefaceUtils +import com.google.android.material.navigationrail.NavigationRailView + +/** + * A React Native compatible NavigationRailView that provides Material 3 + * sidebar navigation for tablet devices. + * + * This view extends Material's NavigationRailView to support React Native's + * requirements including image loading, theming, and event handling. + */ +class ReactNavigationRailView(context: Context) : NavigationRailView(context) { + override fun getMaxItemCount(): Int { + return 100 + } + + // Event listeners + var onTabSelectedListener: ((key: String) -> Unit)? = null + var onTabLongPressedListener: ((key: String) -> Unit)? = null + + // Data and state + var items: MutableList = mutableListOf() + private var selectedItem: String? = null + + // Visual appearance properties + private var activeTintColor: Int? = null + private var inactiveTintColor: Int? = null + private var fontSize: Int? = null + private var fontFamily: String? = null + private var fontWeight: Int? = null + private var labeled: Boolean? = null + private var hasCustomAppearance = false + private var hapticFeedbackEnabled = false + + // Icon and image management + private val iconSources: MutableMap = mutableMapOf() + private val drawableCache: MutableMap = mutableMapOf() + + // Material state constants + private val checkedStateSet = intArrayOf(android.R.attr.state_checked) + private val uncheckedStateSet = intArrayOf(-android.R.attr.state_checked) + + private val imageLoader = ImageLoader.Builder(context) + .components { + add(SvgDecoder.Factory()) + } + .build() + + init { + setupNavigationListeners() + } + + // MARK: - Initialization + + private fun setupNavigationListeners() { + setOnItemSelectedListener { menuItem -> + handleItemSelection(menuItem) + } + + setOnItemReselectedListener { menuItem -> + handleItemReselection(menuItem) + } + } + + private fun handleItemSelection(menuItem: MenuItem): Boolean { + return try { + val selectedTab = items.getOrNull(menuItem.itemId) + selectedTab?.let { tab -> + selectedItem = tab.key + onTabSelectedListener?.invoke(tab.key) + emitHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + } + true + } catch (e: Exception) { + // Silently handle selection errors + false + } + } + + private fun handleItemReselection(menuItem: MenuItem) { + val reselectedTab = items.getOrNull(menuItem.itemId) + reselectedTab?.let { + // Handle reselection if needed in the future + } + } + + // MARK: - Image Loading + + private fun getDrawable(imageSource: ImageSource, onDrawableReady: (Drawable?) -> Unit) { + drawableCache[imageSource]?.let { + onDrawableReady(it) + return + } + val request = ImageRequest.Builder(context) + .data(imageSource.getUri(context)) + .target { drawable -> + post { + val stateDrawable = drawable.asDrawable(context.resources) + drawableCache[imageSource] = stateDrawable + onDrawableReady(stateDrawable) + } + } + .listener( + onError = { _, result -> + // Silently handle image loading errors + } + ) + .build() + + imageLoader.enqueue(request) + } + + // MARK: - Tab Management + + fun updateItems(items: MutableList) { + // If an item got removed, let's re-add all items + if (items.size < this.items.size) { + menu.clear() + } + this.items = items + items.forEachIndexed { index, item -> + val menuItem = getOrCreateItem(index, item.title) + if (item.title != menuItem.title) { + menuItem.title = item.title + } + + menuItem.isVisible = !item.hidden + if (iconSources.containsKey(index)) { + getDrawable(iconSources[index]!!) { drawable -> + menuItem.icon = drawable + } + } + + // Handle badges for NavigationRail + if (item.badge?.isNotEmpty() == true) { + getOrCreateBadge(index).let { badge -> + badge.isVisible = true + badge.text = item.badge + } + } else { + removeBadge(index) + } + + // Set up long press listener and testID + post { + val itemView = findViewById(menuItem.itemId) + itemView?.let { view -> + view.setOnLongClickListener { + onTabLongPressedListener?.invoke(item.key) + emitHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + true + } + + item.testID?.let { testId -> + view.findViewById(com.google.android.material.R.id.navigation_bar_item_content_container) + ?.apply { + tag = testId + } + } + } + } + } + + // Update tint colors and text appearance after updating all items + post { + updateTextAppearance() + updateTintColors() + } + } + + private fun getOrCreateItem(index: Int, title: String): MenuItem { + return menu.findItem(index) ?: menu.add(0, index, 0, title) + } + + fun setSelectedItem(value: String) { + selectedItem = value + val index = items.indexOfFirst { it.key == value } + + // Only try to set selection if menu is populated and index is valid + if (index >= 0 && menu.size() > 0 && index < menu.size()) { + // Use post to ensure the menu is fully initialized + post { + try { + val menuItem = menu.findItem(index) + if (menuItem != null && menuItem.isVisible) { + selectedItemId = index + } + } catch (e: Exception) { + // Silently handle selection errors + } + } + } + } + + // MARK: - Configuration Methods + + fun setLabeled(labeled: Boolean?) { + this.labeled = labeled + labelVisibilityMode = when (labeled) { + false -> com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_UNLABELED + true -> com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_LABELED + else -> com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_AUTO + } + } + + fun setIcons(icons: ReadableArray?) { + if (icons == null || icons.size() == 0) { + return + } + + for (idx in 0 until icons.size()) { + val source = icons.getMap(idx) + val uri = source?.getString("uri") + if (uri.isNullOrEmpty()) { + continue + } + + val imageSource = ImageSource(context, uri) + this.iconSources[idx] = imageSource + + // Update existing item if exists + menu.findItem(idx)?.let { menuItem -> + getDrawable(imageSource) { drawable -> + menuItem.icon = drawable + } + } + } + } + + fun setBarTintColor(color: Int?) { + val backgroundColor = color ?: Utils.getDefaultColorFor(context, android.R.attr.colorPrimary) ?: return + val colorDrawable = android.graphics.drawable.ColorDrawable(backgroundColor) + itemBackground = colorDrawable + backgroundTintList = android.content.res.ColorStateList.valueOf(backgroundColor) + hasCustomAppearance = true + } + + fun setActiveTintColor(color: Int?) { + activeTintColor = color + updateTintColors() + } + + fun setInactiveTintColor(color: Int?) { + inactiveTintColor = color + updateTintColors() + } + + fun setFontSize(fontSize: Int?) { + this.fontSize = fontSize + updateTextAppearance() + } + + fun setFontFamily(fontFamily: String?) { + this.fontFamily = fontFamily + updateTextAppearance() + } + + fun setFontWeight(fontWeight: Int?) { + this.fontWeight = fontWeight + updateTextAppearance() + } + + fun setRippleColor(color: Int?) { + // NavigationRail doesn't have direct ripple color support like BottomNavigationView + // The ripple effect is handled by the Material theme + // This method exists for API compatibility but doesn't perform any action + } + + fun setActiveIndicatorColor(color: Int?) { + // NavigationRail doesn't have an active indicator like BottomNavigationView + // The active state is shown through different styling + // This method exists for API compatibility but doesn't perform any action + } + + override fun setHapticFeedbackEnabled(hapticFeedbackEnabled: Boolean) { + this.hapticFeedbackEnabled = hapticFeedbackEnabled + } + + // MARK: - Appearance Updates + + fun updateTextAppearance() { + // Early return if there is no custom text appearance + if (fontSize == null && fontFamily == null && fontWeight == null) { + return + } + + val typeface = if (fontFamily != null || fontWeight != null) { + ReactFontManager.getInstance().getTypeface( + fontFamily ?: "", + Utils.getTypefaceStyle(fontWeight), + context.assets + ) + } else null + val size = fontSize?.toFloat()?.takeIf { it > 0 } + + val menuView = getChildAt(0) as? android.view.ViewGroup ?: return + for (i in 0 until menuView.childCount) { + val item = menuView.getChildAt(i) + val largeLabel = + item.findViewById(com.google.android.material.R.id.navigation_bar_item_large_label_view) + val smallLabel = + item.findViewById(com.google.android.material.R.id.navigation_bar_item_small_label_view) + + listOf(largeLabel, smallLabel).forEach { label -> + label?.apply { + size?.let { setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, it) } + typeface?.let { setTypeface(it) } + } + } + } + } + + fun updateTintColors() { + val currentItemTintColor = items.firstOrNull { it.key == selectedItem }?.activeTintColor + val colorPrimary = currentItemTintColor ?: activeTintColor ?: Utils.getDefaultColorFor( + context, + android.R.attr.colorPrimary + ) ?: return + val colorSecondary = + inactiveTintColor ?: Utils.getDefaultColorFor(context, android.R.attr.textColorSecondary) + ?: return + val states = arrayOf(uncheckedStateSet, checkedStateSet) + val colors = intArrayOf(colorSecondary, colorPrimary) + + android.content.res.ColorStateList(states, colors).apply { + itemTextColor = this + itemIconTintList = this + } + } + + // MARK: - Utility Methods + + private fun emitHapticFeedback(feedbackConstants: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && hapticFeedbackEnabled) { + this.performHapticFeedback(feedbackConstants) + } + } + + // MARK: - Lifecycle Methods + + fun handleConfigurationChanged(newConfig: Configuration?) { + if (hasCustomAppearance) { + return + } + + // User has hidden the navigation rail, don't re-attach it + if (visibility == View.GONE) { + return + } + + // Re-setup after configuration change + updateItems(items) + setLabeled(this.labeled) + this.selectedItem?.let { setSelectedItem(it) } + } + + override fun onConfigurationChanged(newConfig: Configuration?) { + super.onConfigurationChanged(newConfig) + handleConfigurationChanged(newConfig) + } + + fun onDropViewInstance() { + imageLoader.shutdown() + } +} diff --git a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt index 1e1040eb..b293bff2 100644 --- a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt +++ b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt @@ -8,7 +8,6 @@ import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.os.Build import android.transition.TransitionManager -import android.util.Log import android.util.Size import android.util.TypedValue import android.view.Choreographer @@ -34,39 +33,91 @@ import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_UNLABELED import com.google.android.material.transition.platform.MaterialFadeThrough +/** + * Extended BottomNavigationView that supports more than 5 items + */ class ExtendedBottomNavigationView(context: Context) : BottomNavigationView(context) { - override fun getMaxItemCount(): Int { - return 100 + override fun getMaxItemCount(): Int = 100 +} + +/** + * A FrameLayout optimized for React Native measurement and layout compatibility + */ +class ReactCompatibleFrameLayout(context: Context) : FrameLayout(context) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + // Get the available dimensions + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = MeasureSpec.getSize(heightMeasureSpec) + + // Only use special handling if we have valid dimensions + if (width > 0 && height > 0) { + for (i in 0 until childCount) { + val child = getChildAt(i) + + // Force explicit dimensions for all children to avoid React Native measurement issues + child.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + ) + } + setMeasuredDimension(width, height) + } else { + // Fallback to normal measurement if dimensions aren't available + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } } } +/** + * Main React Native Tab View that intelligently switches between phone and tablet modes. + * + * - Phone mode: Uses Material's BottomNavigationView at the bottom of the screen + * - Tablet mode: Uses Material's NavigationRailView as a sidebar + * + * Provides unified content management and seamless transition between both modes + * based on device screen size configuration. + */ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { - private var bottomNavigation = ExtendedBottomNavigationView(context) + var isTablet: Boolean = (context.resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE + var bottomNavigation: ViewGroup? = null + var railNavigation: ReactNavigationRailView? = null val layoutHolder = FrameLayout(context) + // Event listeners var onTabSelectedListener: ((key: String) -> Unit)? = null var onTabLongPressedListener: ((key: String) -> Unit)? = null var onNativeLayoutListener: ((width: Double, height: Double) -> Unit)? = null var onTabBarMeasuredListener: ((height: Int) -> Unit)? = null + + // Configuration var disablePageAnimations = false + + // Data and state var items: MutableList = mutableListOf() - private val iconSources: MutableMap = mutableMapOf() - private val drawableCache: MutableMap = mutableMapOf() - - private var isLayoutEnqueued = false private var selectedItem: String? = null + + // Visual appearance properties private var activeTintColor: Int? = null private var inactiveTintColor: Int? = null - private val checkedStateSet = intArrayOf(android.R.attr.state_checked) - private val uncheckedStateSet = intArrayOf(-android.R.attr.state_checked) - private var hapticFeedbackEnabled = false private var fontSize: Int? = null private var fontFamily: String? = null private var fontWeight: Int? = null private var labeled: Boolean? = null - private var lastReportedSize: Size? = null private var hasCustomAppearance = false + private var hapticFeedbackEnabled = false + + // Layout and UI state + private var isLayoutEnqueued = false + private var lastReportedSize: Size? = null private var uiModeConfiguration: Int = Configuration.UI_MODE_NIGHT_UNDEFINED + + // Icon and image management + private val iconSources: MutableMap = mutableMapOf() + private val drawableCache: MutableMap = mutableMapOf() + + // Material state constants + private val checkedStateSet = intArrayOf(android.R.attr.state_checked) + private val uncheckedStateSet = intArrayOf(-android.R.attr.state_checked) private val imageLoader = ImageLoader.Builder(context) .components { @@ -75,32 +126,57 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { .build() init { - orientation = VERTICAL + orientation = if (isTablet) HORIZONTAL else VERTICAL + // Always add layoutHolder first - this is where React Native content lives addView( layoutHolder, LayoutParams( - LayoutParams.MATCH_PARENT, - 0, - ).apply { weight = 1f } + if (isTablet) 0 else LayoutParams.MATCH_PARENT, + if (isTablet) LayoutParams.MATCH_PARENT else 0, + ).apply { + if (isTablet) weight = 1f else weight = 1f + } ) + + if (isTablet) { + // Add rail navigation before the content (so it appears on the left) + removeView(layoutHolder) + railNavigation = ReactNavigationRailView(context) + + // Connect the rail navigation's selection listener to our tab switching logic + railNavigation?.onTabSelectedListener = { key -> + setSelectedItem(key) + // Also notify the parent component + onTabSelectedListener?.invoke(key) + } + + addView(railNavigation, LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.MATCH_PARENT + )) + addView( + layoutHolder, LayoutParams( + 0, + LayoutParams.MATCH_PARENT, + ).apply { weight = 1f } + ) + } else { + bottomNavigation = ExtendedBottomNavigationView(context) + addView(bottomNavigation, LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT + )) + } + layoutHolder.isSaveEnabled = false - - addView(bottomNavigation, LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.WRAP_CONTENT - )) uiModeConfiguration = resources.configuration.uiMode post { addOnLayoutChangeListener { _, left, top, right, bottom, - _, _, _, _ -> + oldLeft, oldTop, oldRight, oldBottom -> val newWidth = right - left val newHeight = bottom - top - - // Notify about tab bar height. - onTabBarMeasuredListener?.invoke(Utils.convertPixelsToDp(context, bottomNavigation.height).toInt()) - - if (newWidth != lastReportedSize?.width || newHeight != lastReportedSize?.height) { + if (lastReportedSize?.width != newWidth || lastReportedSize?.height != newHeight) { val dpWidth = Utils.convertPixelsToDp(context, layoutHolder.width) val dpHeight = Utils.convertPixelsToDp(context, layoutHolder.height) @@ -141,43 +217,93 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } } + private var pendingSelection: String? = null + fun setSelectedItem(value: String) { selectedItem = value - setSelectedIndex(items.indexOfFirst { it.key == value }) + val index = items.indexOfFirst { it.key == value } + if (index >= 0) { + pendingSelection = null + setSelectedIndex(index) + } else { + pendingSelection = value + } } override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams?) { - if (child === layoutHolder || child === bottomNavigation) { + if (child === layoutHolder || child === bottomNavigation || child === railNavigation) { super.addView(child, index, params) return } + // Create container exactly like bottom navigation does val container = createContainer() - container.addView(child, params) + container.addView(child, FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + )) + + // Always add to main layoutHolder - same for both phone and tablet layoutHolder.addView(container, index) - - val itemKey = items[index].key - if (selectedItem == itemKey) { - setSelectedIndex(index) - refreshLayout() + + // If we have a selected item but haven't updated visibility yet (because content wasn't ready), do it now + selectedItem?.let { selected -> + val selectedIndex = items.indexOfFirst { it.key == selected } + if (selectedIndex >= 0) { + post { + // Update content visibility now that containers exist + layoutHolder.forEachIndexed { idx, view -> + if (selectedIndex == idx) { + toggleViewVisibility(view, true) + } else { + toggleViewVisibility(view, false) + } + } + } + } } } - private fun createContainer(): FrameLayout { - val container = FrameLayout(context).apply { + private fun createContainer(): ViewGroup { + return ReactCompatibleFrameLayout(context).apply { layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) isSaveEnabled = false - visibility = GONE - isEnabled = false + + // Different default visibility logic for phone vs tablet + if (isTablet) { + // For tablet: start with the first container visible, others hidden + visibility = if (layoutHolder.childCount == 0) VISIBLE else GONE + isEnabled = layoutHolder.childCount == 0 + } else { + // For phone: also make first container visible (like tablet) + visibility = if (layoutHolder.childCount == 0) VISIBLE else GONE + isEnabled = layoutHolder.childCount == 0 + } } - return container } private fun setSelectedIndex(itemId: Int) { - bottomNavigation.selectedItemId = itemId + // Update navigation UI based on mode + if (isTablet) { + railNavigation?.setSelectedItem(items.getOrNull(itemId)?.key ?: "") + } else { + try { + (bottomNavigation as? BottomNavigationView)?.selectedItemId = itemId + } catch (e: Exception) { + // Silently handle bottom navigation selection errors + } + } + + // Check if we have content to show + if (layoutHolder.childCount == 0) { + // Store the selection but don't update visibility yet - it will be handled when containers are added + return + } + + // Content visibility logic - identical for both modes if (!disablePageAnimations) { val fadeThrough = MaterialFadeThrough() TransitionManager.beginDelayedTransition(layoutHolder, fadeThrough) @@ -197,7 +323,6 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { private fun toggleViewVisibility(view: View, isVisible: Boolean) { check(view is ViewGroup) { "Native component tree is corrupted." } - view.visibility = if (isVisible) VISIBLE else GONE view.isEnabled = isVisible } @@ -219,68 +344,130 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } fun setTabBarHidden(isHidden: Boolean) { - if (isHidden) { - bottomNavigation.visibility = GONE + if (isTablet) { + if (isHidden) { + railNavigation?.visibility = GONE + } else { + railNavigation?.visibility = VISIBLE + } } else { - bottomNavigation.visibility = VISIBLE + if (isHidden) { + bottomNavigation?.visibility = GONE + } else { + bottomNavigation?.visibility = VISIBLE + } } } fun updateItems(items: MutableList) { // If an item got removed, let's re-add all items if (items.size < this.items.size) { - bottomNavigation.menu.clear() + if (isTablet) { + railNavigation?.menu?.clear() + } else { + (bottomNavigation as? BottomNavigationView)?.menu?.clear() + } } this.items = items - items.forEachIndexed { index, item -> - val menuItem = getOrCreateItem(index, item.title) - if (item.title !== menuItem.title) { - menuItem.title = item.title - } - - menuItem.isVisible = !item.hidden - if (iconSources.containsKey(index)) { - getDrawable(iconSources[index]!!) { - menuItem.icon = it + + if (isTablet) { + railNavigation?.updateItems(items) + + // Handle any pending selection that couldn't be processed earlier (from setSelectedPage) + pendingSelection?.let { pendingKey -> + val pendingIndex = items.indexOfFirst { it.key == pendingKey } + if (pendingIndex >= 0) { + pendingSelection = null + selectedItem = pendingKey + setSelectedIndex(pendingIndex) + return // Don't do auto-selection if we processed a pending selection } } - - if (item.badge?.isNotEmpty() == true) { - val badge = bottomNavigation.getOrCreateBadge(index) - badge.isVisible = true - badge.text = item.badge - } else { - bottomNavigation.removeBadge(index) + + // Only auto-select if React Native hasn't set a selection and we have content + if (selectedItem == null && items.isNotEmpty() && layoutHolder.childCount > 0) { + selectedItem = items[0].key + setSelectedIndex(0) } - post { - val itemView = bottomNavigation.findViewById(menuItem.itemId) - itemView?.let { view -> - view.setOnLongClickListener { - onTabLongPressed(menuItem) - true - } - view.setOnClickListener { - onTabSelected(menuItem) + } else { + items.forEachIndexed { index, item -> + val menuItem = getOrCreateItem(index, item.title) + if (item.title != menuItem.title) { + menuItem.title = item.title + } + + menuItem.isVisible = !item.hidden + if (iconSources.containsKey(index)) { + getDrawable(iconSources[index]!!) { + menuItem.icon = it } + } - item.testID?.let { testId -> - view.findViewById(com.google.android.material.R.id.navigation_bar_item_content_container) - ?.apply { - tag = testId - } + if (item.badge?.isNotEmpty() == true) { + (bottomNavigation as? BottomNavigationView)?.getOrCreateBadge(index)?.let { badge -> + badge.isVisible = true + badge.text = item.badge + } + } else { + (bottomNavigation as? BottomNavigationView)?.removeBadge(index) + } + post { + val itemView = (bottomNavigation as? BottomNavigationView)?.findViewById(menuItem.itemId) + itemView?.let { view -> + view.setOnLongClickListener { + onTabLongPressed(menuItem) + true + } + view.setOnClickListener { + onTabSelected(menuItem) + } + + item.testID?.let { testId -> + view.findViewById(com.google.android.material.R.id.navigation_bar_item_content_container) + ?.apply { + tag = testId + } + } } } } + + // Auto-selection logic for phone mode + // Handle any pending selection that couldn't be processed earlier (from setSelectedPage) + pendingSelection?.let { pendingKey -> + val pendingIndex = items.indexOfFirst { it.key == pendingKey } + if (pendingIndex >= 0) { + pendingSelection = null + selectedItem = pendingKey + setSelectedIndex(pendingIndex) + return // Don't do auto-selection if we processed a pending selection + } + } + + // Only auto-select if React Native hasn't set a selection and we have content + if (selectedItem == null && items.isNotEmpty() && layoutHolder.childCount > 0) { + selectedItem = items[0].key + setSelectedIndex(0) + } } + // Update tint colors and text appearance after updating all items. post { - updateTextAppearance() - updateTintColors() + if (isTablet) { + railNavigation?.post { + railNavigation?.updateTextAppearance() + railNavigation?.updateTintColors() + } + } else { + updateTextAppearance() + updateTintColors() + } } } private fun getOrCreateItem(index: Int, title: String): MenuItem { - return bottomNavigation.menu.findItem(index) ?: bottomNavigation.menu.add(0, index, 0, title) + val menu = (bottomNavigation as? BottomNavigationView)?.menu + return menu?.findItem(index) ?: menu?.add(0, index, 0, title)!! } fun setIcons(icons: ReadableArray?) { @@ -299,7 +486,7 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { this.iconSources[idx] = imageSource // Update existing item if exists. - bottomNavigation.menu.findItem(idx)?.let { menuItem -> + (bottomNavigation as? BottomNavigationView)?.menu?.findItem(idx)?.let { menuItem -> getDrawable(imageSource) { menuItem.icon = it } @@ -309,7 +496,7 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { fun setLabeled(labeled: Boolean?) { this.labeled = labeled - bottomNavigation.labelVisibilityMode = when (labeled) { + (bottomNavigation as? BottomNavigationView)?.labelVisibilityMode = when (labeled) { false -> { LABEL_VISIBILITY_UNLABELED } @@ -323,7 +510,7 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } fun setRippleColor(color: ColorStateList) { - bottomNavigation.itemRippleColor = color + (bottomNavigation as? BottomNavigationView)?.itemRippleColor = color } @SuppressLint("CheckResult") @@ -343,7 +530,7 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } .listener( onError = { _, result -> - Log.e("RCTTabView", "Error loading image: ${imageSource.uri}", result.throwable) + // Silently handle image loading errors } ) .build() @@ -359,8 +546,8 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { // Apply the same color to both active and inactive states val colorDrawable = ColorDrawable(backgroundColor) - bottomNavigation.itemBackground = colorDrawable - bottomNavigation.backgroundTintList = ColorStateList.valueOf(backgroundColor) + (bottomNavigation as? BottomNavigationView)?.itemBackground = colorDrawable + (bottomNavigation as? BottomNavigationView)?.backgroundTintList = ColorStateList.valueOf(backgroundColor) hasCustomAppearance = true } @@ -375,7 +562,7 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } fun setActiveIndicatorColor(color: ColorStateList) { - bottomNavigation.itemActiveIndicatorColor = color + (bottomNavigation as? BottomNavigationView)?.itemActiveIndicatorColor = color } fun setFontSize(size: Int) { @@ -413,7 +600,7 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } else null val size = fontSize?.toFloat()?.takeIf { it > 0 } - val menuView = bottomNavigation.getChildAt(0) as? ViewGroup ?: return + val menuView = (bottomNavigation as? BottomNavigationView)?.getChildAt(0) as? ViewGroup ?: return for (i in 0 until menuView.childCount) { val item = menuView.getChildAt(i) val largeLabel = @@ -454,8 +641,8 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { val colors = intArrayOf(colorSecondary, colorPrimary) ColorStateList(states, colors).apply { - this@ReactBottomNavigationView.bottomNavigation.itemTextColor = this - this@ReactBottomNavigationView.bottomNavigation.itemIconTintList = this + (bottomNavigation as? BottomNavigationView)?.itemTextColor = this + (bottomNavigation as? BottomNavigationView)?.itemIconTintList = this } } @@ -465,20 +652,31 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { return } - // User has hidden the bottom navigation bar, don't re-attach it. - if (bottomNavigation.visibility == GONE) { - return - } + if (isTablet) { + // User has hidden the navigation rail, don't re-attach it. + if (railNavigation?.visibility == GONE) { + return + } - // If appearance wasn't changed re-create the bottom navigation view when configuration changes. - // React Native opts out ouf Activity re-creation when configuration changes, this workarounds that. - // We also opt-out of this recreation when custom styles are used. - removeView(bottomNavigation) - bottomNavigation = ExtendedBottomNavigationView(context) - addView(bottomNavigation) - updateItems(items) - setLabeled(this.labeled) - this.selectedItem?.let { setSelectedItem(it) } + // If appearance wasn't changed re-create the navigation rail when configuration changes. + railNavigation?.handleConfigurationChanged(newConfig) + } else { + // User has hidden the bottom navigation bar, don't re-attach it. + if (bottomNavigation?.visibility == GONE) { + return + } + + // If appearance wasn't changed re-create the bottom navigation view when configuration changes. + // React Native opts out ouf Activity re-creation when configuration changes, this workarounds that. + // We also opt-out of this recreation when custom styles are used. + removeView(bottomNavigation) + bottomNavigation = ExtendedBottomNavigationView(context) + addView(bottomNavigation) + updateItems(items) + setLabeled(this.labeled) + this.selectedItem?.let { setSelectedItem(it) } + } + uiModeConfiguration = newConfig?.uiMode ?: uiModeConfiguration } } diff --git a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt index 0e6df67b..7ffcac43 100644 --- a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt +++ b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt @@ -10,66 +10,104 @@ import com.rcttabview.events.OnTabBarMeasuredEvent import com.rcttabview.events.PageSelectedEvent import com.rcttabview.events.TabLongPressEvent +/** + * Data class representing tab information + */ data class TabInfo( - val key: String, - val title: String, - val badge: String?, - val activeTintColor: Int?, - val hidden: Boolean, - val testID: String? + val key: String, + val title: String, + val badge: String?, + val activeTintColor: Int?, + val hidden: Boolean, + val testID: String? ) +/** + * Implementation class for RCTTabView that handles the bridge between + * React Native props and native Android view methods. + * Supports both phone (BottomNavigationView) and tablet (NavigationRailView) modes. + */ class RCTTabViewImpl { fun getName(): String { return NAME - } + } - fun setItems(view: ReactBottomNavigationView, items: ReadableArray) { + fun setItems(view: ReactBottomNavigationView, items: ReadableArray) { val itemsArray = mutableListOf() for (i in 0 until items.size()) { items.getMap(i)?.let { item -> - itemsArray.add( - TabInfo( - key = item.getString("key") ?: "", - title = item.getString("title") ?: "", - badge = if (item.hasKey("badge")) item.getString("badge") else null, - activeTintColor = if (item.hasKey("activeTintColor")) item.getInt("activeTintColor") else null, - hidden = if (item.hasKey("hidden")) item.getBoolean("hidden") else false, - testID = item.getString("testID") - ) + itemsArray.add( + TabInfo( + key = item.getString("key") ?: "", + title = item.getString("title") ?: "", + badge = if (item.hasKey("badge")) item.getString("badge") else null, + activeTintColor = if (item.hasKey("activeTintColor")) item.getInt("activeTintColor") else null, + hidden = if (item.hasKey("hidden")) item.getBoolean("hidden") else false, + testID = item.getString("testID") ) + ) } } + // Always update the main view items for both tablet and phone view.updateItems(itemsArray) } + // MARK: - Selection Management + fun setSelectedPage(view: ReactBottomNavigationView, key: String) { + // Always call the main view's setSelectedItem for both modes view.setSelectedItem(key) + + // The main view will handle the rail navigation updates in tablet mode } + // MARK: - Configuration Methods + fun setLabeled(view: ReactBottomNavigationView, flag: Boolean?) { - view.setLabeled(flag) + if (view.isTablet) { + view.railNavigation?.setLabeled(flag) + } else { + view.setLabeled(flag) + } } fun setIcons(view: ReactBottomNavigationView, icons: ReadableArray?) { - view.setIcons(icons) + if (view.isTablet) { + view.railNavigation?.setIcons(icons) + } else { + view.setIcons(icons) + } } + // MARK: - Color Configuration + fun setBarTintColor(view: ReactBottomNavigationView, color: Int?) { - view.setBarTintColor(color) + if (view.isTablet) { + view.railNavigation?.setBarTintColor(color) + } else { + view.setBarTintColor(color) + } } fun setRippleColor(view: ReactBottomNavigationView, rippleColor: Int?) { - if (rippleColor != null) { - val color = ColorStateList.valueOf(rippleColor) - view.setRippleColor(color) + if (view.isTablet) { + view.railNavigation?.setRippleColor(rippleColor) + } else { + if (rippleColor != null) { + val color = ColorStateList.valueOf(rippleColor) + view.setRippleColor(color) + } } } fun setActiveIndicatorColor(view: ReactBottomNavigationView, color: Int?) { - if (color != null) { - val color = ColorStateList.valueOf(color) - view.setActiveIndicatorColor(color) + if (view.isTablet) { + view.railNavigation?.setActiveIndicatorColor(color) + } else { + if (color != null) { + val color = ColorStateList.valueOf(color) + view.setActiveIndicatorColor(color) + } } } @@ -85,6 +123,8 @@ class RCTTabViewImpl { view.isHapticFeedbackEnabled = enabled } + // MARK: - Event Management + fun getExportedCustomDirectEventTypeConstants(): MutableMap? { return MapBuilder.of( PageSelectedEvent.EVENT_NAME, @@ -98,6 +138,8 @@ class RCTTabViewImpl { ) } + // MARK: - Layout Management + fun getChildCount(parent: ReactBottomNavigationView): Int { return parent.layoutHolder.childCount ?: 0 }