From dfbc97ed4f5923e54fbc109f6aa00ce52edfe335 Mon Sep 17 00:00:00 2001 From: Micah Lindley Date: Fri, 22 Aug 2025 18:16:42 -0500 Subject: [PATCH 1/2] feat: allow color customization and badges for Android nav rail --- .../com/rcttabview/RCTNavigationRailView.kt | 334 ++++++++++++++++ .../rcttabview/RCTNavigationRailView_new.kt | 307 +++++++++++++++ .../main/java/com/rcttabview/RCTTabView.kt | 359 +++++++++++++----- .../java/com/rcttabview/RCTTabViewImpl.kt | 60 ++- 4 files changed, 949 insertions(+), 111 deletions(-) create mode 100644 packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView.kt create mode 100644 packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView_new.kt 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 0000000..d80b620 --- /dev/null +++ b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView.kt @@ -0,0 +1,334 @@ +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 + +class ReactNavigationRailView(context: Context) : NavigationRailView(context) { + override fun getMaxItemCount(): Int { + return 100 + } + + var onTabSelectedListener: ((key: String) -> Unit)? = null + var onTabLongPressedListener: ((key: String) -> Unit)? = null + var items: MutableList = mutableListOf() + private val iconSources: MutableMap = mutableMapOf() + private val drawableCache: MutableMap = mutableMapOf() + private var pendingRailSelection: String? = null + + private var selectedItem: String? = null + 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 hasCustomAppearance = false + + private val imageLoader = ImageLoader.Builder(context) + .components { + add(SvgDecoder.Factory()) + } + .build() + + init { + // Set up navigation rail listeners using Material3's built-in methods + setOnItemSelectedListener { menuItem -> + try { + val selectedTab = items.getOrNull(menuItem.itemId) + selectedTab?.let { + selectedItem = it.key + onTabSelectedListener?.invoke(it.key) + emitHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + } + } catch (e: Exception) { + // Silently handle selection errors + } + true + } + + setOnItemReselectedListener { menuItem -> + val reselectedTab = items.getOrNull(menuItem.itemId) + reselectedTab?.let { + // Handle reselection if needed + } + } + } + + 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) + } + + 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 + } + } + } + } 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?) { + itemRippleColor = color?.let { android.content.res.ColorStateList.valueOf(it) } + } + + fun setActiveIndicatorColor(color: Int?) { + activeTintColor = color + } + + override fun setHapticFeedbackEnabled(hapticFeedbackEnabled: Boolean) { + this.hapticFeedbackEnabled = hapticFeedbackEnabled + } + + 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 + } + } + + private fun emitHapticFeedback(feedbackConstants: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && hapticFeedbackEnabled) { + this.performHapticFeedback(feedbackConstants) + } + } + + 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/RCTNavigationRailView_new.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView_new.kt new file mode 100644 index 0000000..ec1e06d --- /dev/null +++ b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView_new.kt @@ -0,0 +1,307 @@ +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 + +class ExtendedNavigationRailView(context: Context) : NavigationRailView(context) { + override fun getMaxItemCount(): Int { + return 100 + } +} + +class ReactNavigationRailView(context: Context) : ExtendedNavigationRailView(context) { + var onTabSelectedListener: ((key: String) -> Unit)? = null + var onTabLongPressedListener: ((key: String) -> Unit)? = null + var items: MutableList = mutableListOf() + private val iconSources: MutableMap = mutableMapOf() + private val drawableCache: MutableMap = mutableMapOf() + + private var selectedItem: String? = null + 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 hasCustomAppearance = false + + private val imageLoader = ImageLoader.Builder(context) + .components { + add(SvgDecoder.Factory()) + } + .build() + + init { + // Set up navigation rail listeners using Material3's built-in methods + setOnItemSelectedListener { menuItem -> + try { + val selectedTab = items.getOrNull(menuItem.itemId) + selectedTab?.let { + selectedItem = it.key + onTabSelectedListener?.invoke(it.key) + emitHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + } + } catch (e: Exception) { + android.util.Log.e("ReactNavigationRailView", "Error in item selection", e) + } + true + } + + setOnItemReselectedListener { menuItem -> + val reselectedTab = items.getOrNull(menuItem.itemId) + reselectedTab?.let { + // Handle reselection if needed + } + } + } + + 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 -> + android.util.Log.e("ReactNavigationRailView", "Error loading image: ${imageSource.uri}", result.throwable) + } + ) + .build() + + imageLoader.enqueue(request) + } + + 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 + } + } + + // 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 } + if (index >= 0) { + selectedItemId = index + } + } + + 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() + } + + override fun setHapticFeedbackEnabled(hapticFeedbackEnabled: Boolean) { + this.hapticFeedbackEnabled = hapticFeedbackEnabled + } + + 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 + } + } + + private fun emitHapticFeedback(feedbackConstants: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && hapticFeedbackEnabled) { + this.performHapticFeedback(feedbackConstants) + } + } + + 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 1e1040e..356c16d 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 @@ -40,8 +39,35 @@ class ExtendedBottomNavigationView(context: Context) : BottomNavigationView(cont } } +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) + } + } +} + 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) var onTabSelectedListener: ((key: String) -> Unit)? = null @@ -75,32 +101,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 +192,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 +298,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 +319,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 +461,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 +471,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 +485,7 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } fun setRippleColor(color: ColorStateList) { - bottomNavigation.itemRippleColor = color + (bottomNavigation as? BottomNavigationView)?.itemRippleColor = color } @SuppressLint("CheckResult") @@ -343,7 +505,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 +521,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 +537,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 +575,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 +616,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 +627,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 0e6df67..b902c02 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 @@ -28,48 +28,72 @@ class RCTTabViewImpl { 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) } 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 } 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) + } } 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) + } } } From ffc2b5bf76017773b70bc29a7d92bd5faf10be32 Mon Sep 17 00:00:00 2001 From: Micah Lindley Date: Fri, 22 Aug 2025 18:26:25 -0500 Subject: [PATCH 2/2] chore: comment and clean up changes for Android nav rail support --- .../com/rcttabview/RCTNavigationRailView.kt | 96 ++++-- .../rcttabview/RCTNavigationRailView_new.kt | 307 ------------------ .../main/java/com/rcttabview/RCTTabView.kt | 47 ++- .../java/com/rcttabview/RCTTabViewImpl.kt | 34 +- 4 files changed, 133 insertions(+), 351 deletions(-) delete mode 100644 packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView_new.kt 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 index d80b620..720108a 100644 --- 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 @@ -17,29 +17,43 @@ 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 val iconSources: MutableMap = mutableMapOf() - private val drawableCache: MutableMap = mutableMapOf() - private var pendingRailSelection: String? = null - 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 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 { @@ -48,29 +62,45 @@ class ReactNavigationRailView(context: Context) : NavigationRailView(context) { .build() init { - // Set up navigation rail listeners using Material3's built-in methods + setupNavigationListeners() + } + + // MARK: - Initialization + + private fun setupNavigationListeners() { setOnItemSelectedListener { menuItem -> - try { - val selectedTab = items.getOrNull(menuItem.itemId) - selectedTab?.let { - selectedItem = it.key - onTabSelectedListener?.invoke(it.key) - emitHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) - } - } catch (e: Exception) { - // Silently handle selection errors - } - true + handleItemSelection(menuItem) } setOnItemReselectedListener { menuItem -> - val reselectedTab = items.getOrNull(menuItem.itemId) - reselectedTab?.let { - // Handle reselection if needed + 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) @@ -95,6 +125,8 @@ class ReactNavigationRailView(context: Context) : NavigationRailView(context) { 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) { @@ -173,7 +205,11 @@ class ReactNavigationRailView(context: Context) : NavigationRailView(context) { } } } - } fun setLabeled(labeled: Boolean?) { + } + + // MARK: - Configuration Methods + + fun setLabeled(labeled: Boolean?) { this.labeled = labeled labelVisibilityMode = when (labeled) { false -> com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_UNLABELED @@ -240,17 +276,23 @@ class ReactNavigationRailView(context: Context) : NavigationRailView(context) { } fun setRippleColor(color: Int?) { - itemRippleColor = color?.let { android.content.res.ColorStateList.valueOf(it) } + // 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?) { - activeTintColor = color + // 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) { @@ -301,12 +343,16 @@ class ReactNavigationRailView(context: Context) : NavigationRailView(context) { } } + // 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 diff --git a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView_new.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView_new.kt deleted file mode 100644 index ec1e06d..0000000 --- a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTNavigationRailView_new.kt +++ /dev/null @@ -1,307 +0,0 @@ -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 - -class ExtendedNavigationRailView(context: Context) : NavigationRailView(context) { - override fun getMaxItemCount(): Int { - return 100 - } -} - -class ReactNavigationRailView(context: Context) : ExtendedNavigationRailView(context) { - var onTabSelectedListener: ((key: String) -> Unit)? = null - var onTabLongPressedListener: ((key: String) -> Unit)? = null - var items: MutableList = mutableListOf() - private val iconSources: MutableMap = mutableMapOf() - private val drawableCache: MutableMap = mutableMapOf() - - private var selectedItem: String? = null - 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 hasCustomAppearance = false - - private val imageLoader = ImageLoader.Builder(context) - .components { - add(SvgDecoder.Factory()) - } - .build() - - init { - // Set up navigation rail listeners using Material3's built-in methods - setOnItemSelectedListener { menuItem -> - try { - val selectedTab = items.getOrNull(menuItem.itemId) - selectedTab?.let { - selectedItem = it.key - onTabSelectedListener?.invoke(it.key) - emitHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) - } - } catch (e: Exception) { - android.util.Log.e("ReactNavigationRailView", "Error in item selection", e) - } - true - } - - setOnItemReselectedListener { menuItem -> - val reselectedTab = items.getOrNull(menuItem.itemId) - reselectedTab?.let { - // Handle reselection if needed - } - } - } - - 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 -> - android.util.Log.e("ReactNavigationRailView", "Error loading image: ${imageSource.uri}", result.throwable) - } - ) - .build() - - imageLoader.enqueue(request) - } - - 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 - } - } - - // 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 } - if (index >= 0) { - selectedItemId = index - } - } - - 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() - } - - override fun setHapticFeedbackEnabled(hapticFeedbackEnabled: Boolean) { - this.hapticFeedbackEnabled = hapticFeedbackEnabled - } - - 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 - } - } - - private fun emitHapticFeedback(feedbackConstants: Int) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && hapticFeedbackEnabled) { - this.performHapticFeedback(feedbackConstants) - } - } - - 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 356c16d..b293bff 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 @@ -33,12 +33,16 @@ 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 @@ -64,35 +68,56 @@ class ReactCompatibleFrameLayout(context: Context) : FrameLayout(context) { } } +/** + * 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) { 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 { 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 b902c02..7ffcac4 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,21 +10,29 @@ 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 -> @@ -44,6 +52,8 @@ class RCTTabViewImpl { 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) @@ -51,6 +61,8 @@ class RCTTabViewImpl { // The main view will handle the rail navigation updates in tablet mode } + // MARK: - Configuration Methods + fun setLabeled(view: ReactBottomNavigationView, flag: Boolean?) { if (view.isTablet) { view.railNavigation?.setLabeled(flag) @@ -67,6 +79,8 @@ class RCTTabViewImpl { } } + // MARK: - Color Configuration + fun setBarTintColor(view: ReactBottomNavigationView, color: Int?) { if (view.isTablet) { view.railNavigation?.setBarTintColor(color) @@ -109,6 +123,8 @@ class RCTTabViewImpl { view.isHapticFeedbackEnabled = enabled } + // MARK: - Event Management + fun getExportedCustomDirectEventTypeConstants(): MutableMap? { return MapBuilder.of( PageSelectedEvent.EVENT_NAME, @@ -122,6 +138,8 @@ class RCTTabViewImpl { ) } + // MARK: - Layout Management + fun getChildCount(parent: ReactBottomNavigationView): Int { return parent.layoutHolder.childCount ?: 0 }