11package com.rcttabview
22
33import android.annotation.SuppressLint
4- import android.content.Context
54import android.content.res.ColorStateList
65import android.graphics.Color
7- import android.graphics.Typeface
86import android.graphics.drawable.ColorDrawable
97import android.graphics.drawable.Drawable
108import android.os.Build
9+ import android.transition.TransitionManager
1110import android.util.Log
11+ import android.util.Size
1212import android.util.TypedValue
1313import android.view.Choreographer
1414import android.view.Gravity
@@ -18,30 +18,36 @@ import android.view.View
1818import android.view.ViewGroup
1919import android.widget.FrameLayout
2020import android.widget.TextView
21- import androidx.appcompat.content.res.AppCompatResources
21+ import androidx.viewpager2.widget.ViewPager2
2222import coil3.ImageLoader
2323import coil3.asDrawable
24+ import coil3.request.ImageRequest
25+ import coil3.svg.SvgDecoder
26+ import com.facebook.react.bridge.ReactContext
2427import com.facebook.react.bridge.ReadableArray
2528import com.facebook.react.common.assets.ReactFontManager
2629import com.facebook.react.modules.core.ReactChoreographer
2730import com.facebook.react.views.text.ReactTypefaceUtils
2831import com.google.android.material.bottomnavigation.BottomNavigationView
29- import coil3.request.ImageRequest
30- import coil3.svg.SvgDecoder
3132import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_AUTO
3233import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_LABELED
3334import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_UNLABELED
35+ import com.google.android.material.transition.platform.MaterialFadeThrough
3436
35-
36- class ReactBottomNavigationView ( context : Context ) : FrameLayout(context) {
37+ class ReactBottomNavigationView ( context : ReactContext ) : FrameLayout(context) {
38+ private val reactContext : ReactContext = context
3739 private val bottomNavigation = BottomNavigationView (context)
38- private val layoutHolder = FrameLayout (context)
39- private val iconSources: MutableMap <Int , ImageSource > = mutableMapOf ()
40- private var isLayoutEnqueued = false
41- var items: MutableList <TabInfo >? = null
40+ private val viewPager = ViewPager2 (context)
41+ val viewPagerAdapter = ViewPagerAdapter ()
42+
4243 var onTabSelectedListener: ((key: String ) -> Unit )? = null
4344 var onTabLongPressedListener: ((key: String ) -> Unit )? = null
4445 var onNativeLayoutListener: ((width: Double , height: Double ) -> Unit )? = null
46+ var disablePageTransitions = false
47+ var items: MutableList <TabInfo >? = null
48+
49+ private var selectedItem: String? = null
50+ private val iconSources: MutableMap <Int , ImageSource > = mutableMapOf ()
4551 private var activeTintColor: Int? = null
4652 private var inactiveTintColor: Int? = null
4753 private val checkedStateSet = intArrayOf(android.R .attr.state_checked)
@@ -50,80 +56,86 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
5056 private var fontSize: Int? = null
5157 private var fontFamily: String? = null
5258 private var fontWeight: Int? = null
59+ private var lastReportedSize: Size ? = null
60+
61+ private val imageLoader = ImageLoader .Builder (context)
62+ .components {
63+ add(SvgDecoder .Factory ())
64+ }
65+ .build()
5366
5467 init {
55- val layoutHolderFrameLayout = LayoutParams (
56- LayoutParams .MATCH_PARENT ,
57- LayoutParams .MATCH_PARENT
68+ viewPager.adapter = viewPagerAdapter
69+ viewPager.isUserInputEnabled = false
70+
71+ viewPager.id = View .generateViewId()
72+ addView(
73+ viewPager, LayoutParams (
74+ LayoutParams .MATCH_PARENT ,
75+ LayoutParams .MATCH_PARENT
76+ )
5877 )
59- addView(layoutHolder, layoutHolderFrameLayout)
6078
61- val bottomNavParams = LayoutParams (
79+ addView(bottomNavigation, LayoutParams (
6280 LayoutParams .MATCH_PARENT ,
6381 LayoutParams .WRAP_CONTENT
6482 ).apply {
6583 gravity = Gravity .BOTTOM
66- }
67-
68- addView(bottomNavigation, bottomNavParams)
84+ })
6985
7086 post {
71- this . addOnLayoutChangeListener { _, left, top, right, bottom,
72- oldLeft, oldTop, oldRight, oldBottom ->
87+ addOnLayoutChangeListener { _, left, top, right, bottom,
88+ _, _, _, _ ->
7389 val newWidth = right - left
7490 val newHeight = bottom - top
75- val oldWidth = oldRight - oldLeft
76- val oldHeight = oldBottom - oldTop
77- if (newWidth != oldWidth || newHeight != oldHeight) {
78- val availableHeight = height - bottomNavigation.height
79- val displayDensity = context.resources.displayMetrics.density
8091
81- val dpWidth = (width / displayDensity).toDouble()
82- val dpHeight = (availableHeight / displayDensity).toDouble()
92+ if (newWidth != lastReportedSize?.width || newHeight != lastReportedSize?.height) {
93+ // We subtract bottom navigation height from the screen height
94+ // This should be refactored for adaptive navigation
95+ val availableHeight = viewPager.height - bottomNavigation.height
96+
97+ val dpWidth = Utils .convertPixelsToDp(context, viewPager.width)
98+ val dpHeight = Utils .convertPixelsToDp(context, availableHeight)
8399
84100 onNativeLayoutListener?.invoke(dpWidth, dpHeight)
101+ lastReportedSize = Size (newWidth, newHeight)
85102 }
86103 }
87104 }
88105 }
89106
107+ fun setSelectedItem (value : String ) {
108+ selectedItem = value
109+ items?.indexOfFirst { it.key == value }?.let {
110+ setSelectedIndex(it)
111+ }
112+ }
113+
90114 override fun addView (child : View , index : Int , params : ViewGroup .LayoutParams ? ) {
91- if (child == = layoutHolder || child == = bottomNavigation) {
115+ if (child == = viewPager || child == = bottomNavigation) {
92116 super .addView(child, index, params)
93117 } else {
94- layoutHolder.addView(child, params)
118+ viewPagerAdapter.addChild(child, index)
119+ val itemKey = items?.get(index)?.key
120+ if (selectedItem == itemKey) {
121+ setSelectedIndex(index)
122+ }
95123 }
96124 }
97125
98- private val imageLoader = ImageLoader .Builder (context)
99- .components {
100- add(SvgDecoder .Factory ())
101- }
102- .build()
103-
104126 private val layoutCallback = Choreographer .FrameCallback {
105- isLayoutEnqueued = false
106127 measure(
107128 MeasureSpec .makeMeasureSpec(width, MeasureSpec .EXACTLY ),
108129 MeasureSpec .makeMeasureSpec(height, MeasureSpec .EXACTLY ),
109130 )
110131 layout(left, top, right, bottom)
111132 }
112133
113- private fun onTabLongPressed (item : MenuItem ) {
114- val longPressedItem = items?.firstOrNull { it.title == item.title }
115- longPressedItem?.let {
116- onTabLongPressedListener?.invoke(longPressedItem.key)
117- emitHapticFeedback(HapticFeedbackConstants .LONG_PRESS )
118- }
119- }
120-
121134 override fun requestLayout () {
122135 super .requestLayout()
123136 @Suppress(" SENSELESS_COMPARISON" ) // layoutCallback can be null here since this method can be called in init
124137
125- if (! isLayoutEnqueued && layoutCallback != null ) {
126- isLayoutEnqueued = true
138+ if (layoutCallback != null ) {
127139 // we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current
128140 // looper loop instead of enqueueing the update in the next loop causing a one frame delay.
129141 ReactChoreographer
@@ -135,24 +147,38 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
135147 }
136148 }
137149
138- private fun onTabSelected (item : MenuItem ) {
139- if (isLayoutEnqueued) {
140- return
150+ private fun setSelectedIndex (itemId : Int ) {
151+ bottomNavigation.selectedItemId = itemId
152+ if (! disablePageTransitions) {
153+ val fadeThrough = MaterialFadeThrough ()
154+ TransitionManager .beginDelayedTransition(this , fadeThrough)
141155 }
156+ viewPager.setCurrentItem(itemId, false )
157+ }
158+
159+ private fun onTabSelected (item : MenuItem ) {
142160 val selectedItem = items?.first { it.title == item.title }
143161 selectedItem?.let {
144162 onTabSelectedListener?.invoke(selectedItem.key)
145163 emitHapticFeedback(HapticFeedbackConstants .CONTEXT_CLICK )
146164 }
147165 }
148166
167+ private fun onTabLongPressed (item : MenuItem ) {
168+ val longPressedItem = items?.firstOrNull { it.title == item.title }
169+ longPressedItem?.let {
170+ onTabLongPressedListener?.invoke(longPressedItem.key)
171+ emitHapticFeedback(HapticFeedbackConstants .LONG_PRESS )
172+ }
173+ }
174+
149175 fun updateItems (items : MutableList <TabInfo >) {
150176 this .items = items
151177 items.forEachIndexed { index, item ->
152178 val menuItem = getOrCreateItem(index, item.title)
153179 menuItem.isVisible = ! item.hidden
154180 if (iconSources.containsKey(index)) {
155- getDrawable(iconSources[index]!! ) {
181+ getDrawable(iconSources[index]!! ) {
156182 menuItem.icon = it
157183 }
158184 }
@@ -165,7 +191,7 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
165191 bottomNavigation.removeBadge(index)
166192 }
167193 post {
168- val itemView = findViewById<View >(menuItem.itemId)
194+ val itemView = bottomNavigation. findViewById<View >(menuItem.itemId)
169195 itemView?.let { view ->
170196 view.setOnLongClickListener {
171197 onTabLongPressed(menuItem)
@@ -177,9 +203,10 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
177203 }
178204
179205 item.testID?.let { testId ->
180- view.findViewById<View >(com.google.android.material.R .id.navigation_bar_item_content_container)?.apply {
206+ view.findViewById<View >(com.google.android.material.R .id.navigation_bar_item_content_container)
207+ ?.apply {
181208 tag = testId
182- }
209+ }
183210 }
184211 }
185212 updateTextAppearance()
@@ -209,7 +236,7 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
209236
210237 // Update existing item if exists.
211238 bottomNavigation.menu.findItem(idx)?.let { menuItem ->
212- getDrawable(imageSource) {
239+ getDrawable(imageSource) {
213240 menuItem.icon = it
214241 }
215242 }
@@ -218,15 +245,17 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
218245
219246 fun setLabeled (labeled : Boolean? ) {
220247 bottomNavigation.labelVisibilityMode = when (labeled) {
221- false -> {
222- LABEL_VISIBILITY_UNLABELED
223- }
224- true -> {
225- LABEL_VISIBILITY_LABELED
226- }
227- else -> {
228- LABEL_VISIBILITY_AUTO
229- }
248+ false -> {
249+ LABEL_VISIBILITY_UNLABELED
250+ }
251+
252+ true -> {
253+ LABEL_VISIBILITY_LABELED
254+ }
255+
256+ else -> {
257+ LABEL_VISIBILITY_AUTO
258+ }
230259 }
231260 }
232261
@@ -253,13 +282,16 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
253282
254283 fun setBarTintColor (color : Int? ) {
255284 // Set the color, either using the active background color or a default color.
256- val backgroundColor = color ? : getDefaultColorFor(android.R .attr.colorPrimary) ? : return
285+ val backgroundColor =
286+ color ? : Utils .getDefaultColorFor(context, android.R .attr.colorPrimary) ? : return
257287
258288 // Apply the same color to both active and inactive states
259289 val colorDrawable = ColorDrawable (backgroundColor)
260290
261291 bottomNavigation.itemBackground = colorDrawable
262292 backgroundTintList = ColorStateList .valueOf(backgroundColor)
293+ // Set navigationBarColor for edge-to-edge.
294+ reactContext.currentActivity?.window?.navigationBarColor = backgroundColor
263295 }
264296
265297 fun setActiveTintColor (color : Int? ) {
@@ -276,10 +308,6 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
276308 bottomNavigation.itemActiveIndicatorColor = color
277309 }
278310
279- fun setHapticFeedback (enabled : Boolean ) {
280- hapticFeedbackEnabled = enabled
281- }
282-
283311 fun setFontSize (size : Int ) {
284312 fontSize = size
285313 updateTextAppearance()
@@ -296,22 +324,13 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
296324 updateTextAppearance()
297325 }
298326
299- private fun getTypefaceStyle (weight : Int? ) = when (weight) {
300- 700 -> Typeface .BOLD
301- else -> Typeface .NORMAL
302- }
303-
304- fun setSelectedItemId (itemId : Int ) {
305- bottomNavigation.selectedItemId = itemId
306- }
307-
308327 private fun updateTextAppearance () {
309328 if (fontSize != null || fontFamily != null || fontWeight != null ) {
310329 val menuView = getChildAt(0 ) as ? ViewGroup ? : return
311330 val size = fontSize?.toFloat()?.takeIf { it > 0 } ? : 12f
312331 val typeface = ReactFontManager .getInstance().getTypeface(
313332 fontFamily ? : " " ,
314- getTypefaceStyle(fontWeight),
333+ Utils . getTypefaceStyle(fontWeight),
315334 context.assets
316335 )
317336
@@ -343,9 +362,13 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
343362 val currentItemTintColor = items?.find { it.title == item?.title }?.activeTintColor
344363
345364 // getDefaultColor will always return a valid color but to satisfy the compiler we need to check for null
346- val colorPrimary = currentItemTintColor ? : activeTintColor ? : getDefaultColorFor(android.R .attr.colorPrimary) ? : return
365+ val colorPrimary = currentItemTintColor ? : activeTintColor ? : Utils .getDefaultColorFor(
366+ context,
367+ android.R .attr.colorPrimary
368+ ) ? : return
347369 val colorSecondary =
348- inactiveTintColor ? : getDefaultColorFor(android.R .attr.textColorSecondary) ? : return
370+ inactiveTintColor ? : Utils .getDefaultColorFor(context, android.R .attr.textColorSecondary)
371+ ? : return
349372 val states = arrayOf(uncheckedStateSet, checkedStateSet)
350373 val colors = intArrayOf(colorSecondary, colorPrimary)
351374
@@ -355,17 +378,8 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
355378 }
356379 }
357380
358- private fun getDefaultColorFor (baseColorThemeAttr : Int ): Int? {
359- val value = TypedValue ()
360- if (! context.theme.resolveAttribute(baseColorThemeAttr, value, true )) {
361- return null
362- }
363- val baseColor = AppCompatResources .getColorStateList(
364- context, value.resourceId
365- )
366- return baseColor.defaultColor
381+ override fun onDetachedFromWindow () {
382+ super .onDetachedFromWindow()
383+ reactContext.currentActivity?.window?.navigationBarColor = Color .TRANSPARENT
367384 }
368385}
369-
370-
371-
0 commit comments