@soundtrackio/react-native-bottom-tabs 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +32 -0
  2. package/android/build.gradle +132 -0
  3. package/android/gradle.properties +5 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/AndroidManifestNew.xml +2 -0
  6. package/android/src/main/java/com/rcttabview/ImageSource.kt +86 -0
  7. package/android/src/main/java/com/rcttabview/LatestRequestTracker.kt +19 -0
  8. package/android/src/main/java/com/rcttabview/RCTTabView.kt +689 -0
  9. package/android/src/main/java/com/rcttabview/RCTTabViewManager.kt +222 -0
  10. package/android/src/main/java/com/rcttabview/RCTTabViewPackage.kt +19 -0
  11. package/android/src/main/java/com/rcttabview/RCTTabViewPrewarm.kt +40 -0
  12. package/android/src/main/java/com/rcttabview/RebuildBatch.kt +31 -0
  13. package/android/src/main/java/com/rcttabview/TabBarVisibility.kt +14 -0
  14. package/android/src/main/java/com/rcttabview/Utils.kt +31 -0
  15. package/android/src/main/java/com/rcttabview/events/OnNativeLayoutEvent.kt +25 -0
  16. package/android/src/main/java/com/rcttabview/events/OnTabBarMeasuredEvent.kt +24 -0
  17. package/android/src/main/java/com/rcttabview/events/PageSelectedEvent.kt +32 -0
  18. package/android/src/main/java/com/rcttabview/events/TabLongPressedEvent.kt +24 -0
  19. package/android/src/test/java/com/rcttabview/LatestRequestTrackerTest.kt +37 -0
  20. package/android/src/test/java/com/rcttabview/RebuildBatchTest.kt +44 -0
  21. package/android/src/test/java/com/rcttabview/TabBarVisibilityTest.kt +58 -0
  22. package/app.plugin.js +1 -0
  23. package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewComponentDescriptor.h +33 -0
  24. package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewShadowNode.cpp +19 -0
  25. package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewShadowNode.h +35 -0
  26. package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewState.cpp +15 -0
  27. package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewState.h +25 -0
  28. package/ios/BottomAccessoryProvider.swift +27 -0
  29. package/ios/Extensions.swift +101 -0
  30. package/ios/PlatformAliases.swift +43 -0
  31. package/ios/RCTBottomAccessoryComponentView.h +17 -0
  32. package/ios/RCTBottomAccessoryComponentView.mm +76 -0
  33. package/ios/RCTTabViewComponentView.h +16 -0
  34. package/ios/RCTTabViewComponentView.mm +285 -0
  35. package/ios/RepresentableView.swift +55 -0
  36. package/ios/SVG/CoreSVG.h +19 -0
  37. package/ios/SVG/CoreSVG.mm +177 -0
  38. package/ios/SVG/SvgDecoder.h +10 -0
  39. package/ios/SVG/SvgDecoder.mm +38 -0
  40. package/ios/TabAppearModifier.swift +38 -0
  41. package/ios/TabBarFontSize.swift +88 -0
  42. package/ios/TabItem.swift +35 -0
  43. package/ios/TabItemEventModifier.swift +163 -0
  44. package/ios/TabView/AnyTabView.swift +7 -0
  45. package/ios/TabView/LegacyTabView.swift +70 -0
  46. package/ios/TabView/NewTabView.swift +138 -0
  47. package/ios/TabViewImpl.swift +566 -0
  48. package/ios/TabViewProps.swift +109 -0
  49. package/ios/TabViewProvider.swift +368 -0
  50. package/ios/react-native-bottom-tabs-Bridging-Header.h +4 -0
  51. package/lib/module/BottomAccessoryView.js +47 -0
  52. package/lib/module/BottomAccessoryView.js.map +1 -0
  53. package/lib/module/BottomAccessoryViewNativeComponent.ts +27 -0
  54. package/lib/module/DelayedFreeze.js +31 -0
  55. package/lib/module/DelayedFreeze.js.map +1 -0
  56. package/lib/module/NativeSVGDecoder.js +5 -0
  57. package/lib/module/NativeSVGDecoder.js.map +1 -0
  58. package/lib/module/SceneMap.js +28 -0
  59. package/lib/module/SceneMap.js.map +1 -0
  60. package/lib/module/TabView.js +294 -0
  61. package/lib/module/TabView.js.map +1 -0
  62. package/lib/module/TabViewNativeComponent.ts +73 -0
  63. package/lib/module/expo.js +35 -0
  64. package/lib/module/expo.js.map +1 -0
  65. package/lib/module/index.js +20 -0
  66. package/lib/module/index.js.map +1 -0
  67. package/lib/module/package.json +1 -0
  68. package/lib/module/types.js +4 -0
  69. package/lib/module/types.js.map +1 -0
  70. package/lib/module/utils/BottomTabBarHeightContext.js +5 -0
  71. package/lib/module/utils/BottomTabBarHeightContext.js.map +1 -0
  72. package/lib/module/utils/useBottomTabBarHeight.js +12 -0
  73. package/lib/module/utils/useBottomTabBarHeight.js.map +1 -0
  74. package/lib/typescript/package.json +1 -0
  75. package/lib/typescript/src/BottomAccessoryView.d.ts +7 -0
  76. package/lib/typescript/src/BottomAccessoryView.d.ts.map +1 -0
  77. package/lib/typescript/src/BottomAccessoryView.js +24 -0
  78. package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts +15 -0
  79. package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts.map +1 -0
  80. package/lib/typescript/src/BottomAccessoryViewNativeComponent.js +4 -0
  81. package/lib/typescript/src/DelayedFreeze.d.ts +7 -0
  82. package/lib/typescript/src/DelayedFreeze.d.ts.map +1 -0
  83. package/lib/typescript/src/DelayedFreeze.js +21 -0
  84. package/lib/typescript/src/NativeSVGDecoder.d.ts +5 -0
  85. package/lib/typescript/src/NativeSVGDecoder.d.ts.map +1 -0
  86. package/lib/typescript/src/NativeSVGDecoder.js +2 -0
  87. package/lib/typescript/src/SceneMap.d.ts +9 -0
  88. package/lib/typescript/src/SceneMap.d.ts.map +1 -0
  89. package/lib/typescript/src/SceneMap.js +15 -0
  90. package/lib/typescript/src/TabView.d.ts +219 -0
  91. package/lib/typescript/src/TabView.d.ts.map +1 -0
  92. package/lib/typescript/src/TabView.js +157 -0
  93. package/lib/typescript/src/TabViewNativeComponent.d.ts +59 -0
  94. package/lib/typescript/src/TabViewNativeComponent.d.ts.map +1 -0
  95. package/lib/typescript/src/TabViewNativeComponent.js +4 -0
  96. package/lib/typescript/src/expo.d.ts +6 -0
  97. package/lib/typescript/src/expo.d.ts.map +1 -0
  98. package/lib/typescript/src/expo.js +34 -0
  99. package/lib/typescript/src/index.d.ts +15 -0
  100. package/lib/typescript/src/index.d.ts.map +1 -0
  101. package/lib/typescript/src/index.js +11 -0
  102. package/lib/typescript/src/types.d.ts +31 -0
  103. package/lib/typescript/src/types.d.ts.map +1 -0
  104. package/lib/typescript/src/types.js +1 -0
  105. package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts +2 -0
  106. package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts.map +1 -0
  107. package/lib/typescript/src/utils/BottomTabBarHeightContext.js +2 -0
  108. package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts +1 -0
  109. package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts.map +1 -0
  110. package/lib/typescript/src/utils/useBottomTabBarHeight.js +9 -0
  111. package/lib/typescript/tsconfig.tsbuildinfo +1 -0
  112. package/package.json +135 -0
  113. package/react-native-bottom-tabs.podspec +43 -0
  114. package/src/BottomAccessoryView.tsx +59 -0
  115. package/src/BottomAccessoryViewNativeComponent.ts +27 -0
  116. package/src/DelayedFreeze.tsx +31 -0
  117. package/src/NativeSVGDecoder.ts +5 -0
  118. package/src/SceneMap.tsx +34 -0
  119. package/src/TabView.tsx +558 -0
  120. package/src/TabViewNativeComponent.ts +73 -0
  121. package/src/expo.ts +58 -0
  122. package/src/index.tsx +23 -0
  123. package/src/types.ts +36 -0
  124. package/src/utils/BottomTabBarHeightContext.ts +5 -0
  125. package/src/utils/useBottomTabBarHeight.ts +15 -0
@@ -0,0 +1,689 @@
1
+ package com.rcttabview
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.ContentResolver
5
+ import android.content.Context
6
+ import android.content.res.ColorStateList
7
+ import android.content.res.Configuration
8
+ import android.content.res.Resources
9
+ import android.graphics.Color
10
+ import android.graphics.PorterDuff
11
+ import android.graphics.drawable.ColorDrawable
12
+ import android.graphics.drawable.Drawable
13
+ import android.graphics.drawable.GradientDrawable
14
+ import android.os.Build
15
+ import android.transition.TransitionManager
16
+ import android.util.Log
17
+ import android.util.Size
18
+ import android.util.TypedValue
19
+ import android.view.Choreographer
20
+ import android.view.HapticFeedbackConstants
21
+ import android.view.MenuItem
22
+ import android.view.View
23
+ import android.view.ViewGroup
24
+ import android.widget.FrameLayout
25
+ import android.widget.LinearLayout
26
+ import android.widget.TextView
27
+ import androidx.core.content.res.ResourcesCompat
28
+ import androidx.core.view.AccessibilityDelegateCompat
29
+ import androidx.core.view.MenuItemCompat
30
+ import androidx.core.view.ViewCompat
31
+ import androidx.core.view.WindowInsetsCompat
32
+ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
33
+ import androidx.core.view.forEachIndexed
34
+ import coil3.ImageLoader
35
+ import coil3.asDrawable
36
+ import coil3.request.ImageRequest
37
+ import coil3.svg.SvgDecoder
38
+ import coil3.size.Precision
39
+ import coil3.size.Size as CoilSize
40
+ import coil3.size.Scale
41
+ import com.facebook.react.bridge.ReadableArray
42
+ import com.facebook.react.common.assets.ReactFontManager
43
+ import com.facebook.react.modules.core.ReactChoreographer
44
+ import com.facebook.react.views.text.ReactTypefaceUtils
45
+ import com.google.android.material.bottomnavigation.BottomNavigationMenuView
46
+ import com.google.android.material.bottomnavigation.BottomNavigationView
47
+ import com.google.android.material.navigation.NavigationBarMenuView
48
+ import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_AUTO
49
+ import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_LABELED
50
+ import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_UNLABELED
51
+ import com.google.android.material.transition.platform.MaterialFadeThrough
52
+ import java.util.concurrent.ExecutorService
53
+ import java.util.concurrent.Executors
54
+
55
+ /**
56
+ * Material rebuilds every item view each time the menu changes, so adding the tabs one by one
57
+ * inflates 1 + 2 + … + n item views. This menu view holds the rebuild while a batch of changes
58
+ * is applied and runs it once afterwards.
59
+ */
60
+ @SuppressLint("RestrictedApi")
61
+ class BatchingBottomNavigationMenuView(context: Context) : BottomNavigationMenuView(context) {
62
+ private val rebuilds = RebuildBatch()
63
+
64
+ override fun buildMenuView() {
65
+ if (rebuilds.shouldRebuildNow()) {
66
+ super.buildMenuView()
67
+ }
68
+ }
69
+
70
+ fun beginBatch() {
71
+ rebuilds.begin()
72
+ }
73
+
74
+ fun endBatch() {
75
+ if (rebuilds.end()) {
76
+ buildMenuView()
77
+ }
78
+ }
79
+ }
80
+
81
+ class ExtendedBottomNavigationView(context: Context) : BottomNavigationView(context) {
82
+ @SuppressLint("RestrictedApi")
83
+ override fun createNavigationBarMenuView(context: Context): NavigationBarMenuView {
84
+ return BatchingBottomNavigationMenuView(context)
85
+ }
86
+
87
+ /** Applies [block]'s menu changes with a single rebuild of the item views. */
88
+ @SuppressLint("RestrictedApi")
89
+ fun batchMenuChanges(block: () -> Unit) {
90
+ val menuView = menuView as? BatchingBottomNavigationMenuView
91
+ menuView?.beginBatch()
92
+ try {
93
+ block()
94
+ } finally {
95
+ menuView?.endBatch()
96
+ }
97
+ }
98
+
99
+ override fun getMaxItemCount(): Int {
100
+ return 100
101
+ }
102
+ }
103
+
104
+ class ReactBottomNavigationView(context: Context) : LinearLayout(context) {
105
+ private var bottomNavigation = ExtendedBottomNavigationView(context)
106
+ private val tabBarVisibility = TabBarVisibility()
107
+ val layoutHolder = FrameLayout(context)
108
+
109
+ var onTabSelectedListener: ((key: String) -> Unit)? = null
110
+ var onTabLongPressedListener: ((key: String) -> Unit)? = null
111
+ var onNativeLayoutListener: ((width: Double, height: Double) -> Unit)? = null
112
+ var onTabBarMeasuredListener: ((height: Int) -> Unit)? = null
113
+ var disablePageAnimations = false
114
+ var items: MutableList<TabInfo> = mutableListOf()
115
+ private val iconSources: MutableMap<Int, ImageSource> = mutableMapOf()
116
+ // Icon loads finish asynchronously and can complete out of order, so a menu item only takes
117
+ // the result of the newest load started for it.
118
+ private val iconLoads = LatestRequestTracker<Int>()
119
+ private val drawableCache: MutableMap<ImageSource, Drawable> = mutableMapOf()
120
+
121
+ private var isLayoutEnqueued = false
122
+ private var selectedItem: String? = null
123
+ private var activeTintColor: Int? = null
124
+ private var inactiveTintColor: Int? = null
125
+ private val checkedStateSet = intArrayOf(android.R.attr.state_checked)
126
+ private val uncheckedStateSet = intArrayOf(-android.R.attr.state_checked)
127
+ private var hapticFeedbackEnabled = false
128
+ private var fontSize: Int? = null
129
+ private var fontFamily: String? = null
130
+ private var fontWeight: Int? = null
131
+ private var labeled: Boolean? = null
132
+ private var lastReportedSize: Size? = null
133
+ private var hasCustomAppearance = false
134
+ private var uiModeConfiguration: Int = Configuration.UI_MODE_NIGHT_UNDEFINED
135
+
136
+ private companion object {
137
+ /**
138
+ * Icons are decoded off the main thread. The Coil loader is shared by all tab bars and
139
+ * built on this thread the first time an icon is requested, so creating a tab bar costs
140
+ * neither Coil's setup nor its class loading.
141
+ */
142
+ private val iconExecutor: ExecutorService =
143
+ Executors.newSingleThreadExecutor { Thread(it, "rcttabview-icons") }
144
+
145
+ private var sharedImageLoader: ImageLoader? = null
146
+
147
+ /** Only called on [iconExecutor]. */
148
+ private fun imageLoader(context: Context): ImageLoader =
149
+ sharedImageLoader ?: ImageLoader.Builder(context.applicationContext)
150
+ .components {
151
+ add(SvgDecoder.Factory())
152
+ }
153
+ .build()
154
+ .also { sharedImageLoader = it }
155
+ }
156
+
157
+ init {
158
+ orientation = VERTICAL
159
+
160
+ addView(
161
+ layoutHolder, LayoutParams(
162
+ LayoutParams.MATCH_PARENT,
163
+ 0,
164
+ ).apply { weight = 1f }
165
+ )
166
+ layoutHolder.isSaveEnabled = false
167
+
168
+ addView(bottomNavigation, LayoutParams(
169
+ LayoutParams.MATCH_PARENT,
170
+ LayoutParams.WRAP_CONTENT
171
+ ))
172
+ uiModeConfiguration = resources.configuration.uiMode
173
+
174
+ post {
175
+ addOnLayoutChangeListener { _, left, top, right, bottom,
176
+ _, _, _, _ ->
177
+ val newWidth = right - left
178
+ val newHeight = bottom - top
179
+
180
+ // Notify about tab bar height.
181
+ onTabBarMeasuredListener?.invoke(Utils.convertPixelsToDp(context, bottomNavigation.height).toInt())
182
+
183
+ if (newWidth != lastReportedSize?.width || newHeight != lastReportedSize?.height) {
184
+ val dpWidth = Utils.convertPixelsToDp(context, layoutHolder.width)
185
+ val dpHeight = Utils.convertPixelsToDp(context, layoutHolder.height)
186
+
187
+ onNativeLayoutListener?.invoke(dpWidth, dpHeight)
188
+ lastReportedSize = Size(newWidth, newHeight)
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ private val layoutCallback = Choreographer.FrameCallback {
195
+ isLayoutEnqueued = false
196
+ refreshLayout()
197
+ }
198
+
199
+ private fun refreshLayout() {
200
+ measure(
201
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
202
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
203
+ )
204
+ layout(left, top, right, bottom)
205
+ }
206
+
207
+ fun applyDirection(dir: Int) {
208
+ bottomNavigation.layoutDirection = dir
209
+ }
210
+
211
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
212
+ // With `adjustResize` the keyboard shrinks this view through a layout commit, so the bar's
213
+ // visibility is decided in that same measure pass. Hiding it from JS reaches the view at least
214
+ // a frame later, and in between the bar renders lifted above the keyboard.
215
+ if (tabBarVisibility.hideOnKeyboard) {
216
+ tabBarVisibility.keyboardVisible =
217
+ ViewCompat.getRootWindowInsets(this)?.isVisible(WindowInsetsCompat.Type.ime()) == true
218
+ applyTabBarVisibility()
219
+ }
220
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
221
+ }
222
+
223
+ override fun requestLayout() {
224
+ super.requestLayout()
225
+ @Suppress("SENSELESS_COMPARISON") // layoutCallback can be null here since this method can be called in init
226
+
227
+ if (!isLayoutEnqueued && layoutCallback != null) {
228
+ isLayoutEnqueued = true
229
+ // we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current
230
+ // looper loop instead of enqueueing the update in the next loop causing a one frame delay.
231
+ ReactChoreographer
232
+ .getInstance()
233
+ .postFrameCallback(
234
+ ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE,
235
+ layoutCallback,
236
+ )
237
+ }
238
+ }
239
+
240
+ fun setSelectedItem(value: String) {
241
+ selectedItem = value
242
+ setSelectedIndex(items.indexOfFirst { it.key == value })
243
+ }
244
+
245
+ override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams?) {
246
+ if (child === layoutHolder || child === bottomNavigation) {
247
+ super.addView(child, index, params)
248
+ return
249
+ }
250
+
251
+ val container = createContainer()
252
+ container.addView(child, params)
253
+ layoutHolder.addView(container, index)
254
+
255
+ val itemKey = items[index].key
256
+ if (selectedItem == itemKey) {
257
+ setSelectedIndex(index)
258
+ refreshLayout()
259
+ }
260
+ }
261
+
262
+ private fun createContainer(): FrameLayout {
263
+ val container = FrameLayout(context).apply {
264
+ layoutParams = FrameLayout.LayoutParams(
265
+ FrameLayout.LayoutParams.MATCH_PARENT,
266
+ FrameLayout.LayoutParams.MATCH_PARENT
267
+ )
268
+ isSaveEnabled = false
269
+ visibility = GONE
270
+ isEnabled = false
271
+ }
272
+ return container
273
+ }
274
+
275
+ private fun setSelectedIndex(itemId: Int) {
276
+ bottomNavigation.selectedItemId = itemId
277
+ if (!disablePageAnimations) {
278
+ val fadeThrough = MaterialFadeThrough()
279
+ TransitionManager.beginDelayedTransition(layoutHolder, fadeThrough)
280
+ }
281
+
282
+ layoutHolder.forEachIndexed { index, view ->
283
+ if (itemId == index) {
284
+ toggleViewVisibility(view, true)
285
+ } else {
286
+ toggleViewVisibility(view, false)
287
+ }
288
+ }
289
+
290
+ layoutHolder.requestLayout()
291
+ layoutHolder.invalidate()
292
+ }
293
+
294
+ private fun toggleViewVisibility(view: View, isVisible: Boolean) {
295
+ check(view is ViewGroup) { "Native component tree is corrupted." }
296
+
297
+ view.visibility = if (isVisible) VISIBLE else GONE
298
+ view.isEnabled = isVisible
299
+ }
300
+
301
+ private fun onTabSelected(item: MenuItem) {
302
+ val selectedItem = items[item.itemId]
303
+ selectedItem.let {
304
+ onTabSelectedListener?.invoke(selectedItem.key)
305
+ emitHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
306
+ }
307
+ }
308
+
309
+ private fun onTabLongPressed(item: MenuItem) {
310
+ val longPressedItem = items[item.itemId]
311
+ longPressedItem.let {
312
+ onTabLongPressedListener?.invoke(longPressedItem.key)
313
+ emitHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
314
+ }
315
+ }
316
+
317
+ fun setTabBarHidden(isHidden: Boolean) {
318
+ tabBarVisibility.hidden = isHidden
319
+ applyTabBarVisibility()
320
+ }
321
+
322
+ fun setTabBarHideOnKeyboard(value: Boolean) {
323
+ tabBarVisibility.hideOnKeyboard = value
324
+ applyTabBarVisibility()
325
+ }
326
+
327
+ private fun applyTabBarVisibility() {
328
+ bottomNavigation.visibility = if (tabBarVisibility.isVisible) VISIBLE else GONE
329
+ }
330
+
331
+ fun updateItems(items: MutableList<TabInfo>) {
332
+ val removedItems = items.size < this.items.size
333
+ this.items = items
334
+ bottomNavigation.batchMenuChanges {
335
+ // If an item got removed, let's re-add all items
336
+ if (removedItems) {
337
+ bottomNavigation.menu.clear()
338
+ }
339
+ items.forEachIndexed { index, item ->
340
+ applyItem(index, item)
341
+ }
342
+ // Drop items beyond the current tabs: placeholders from a prewarm, or tabs that were removed
343
+ val menu = bottomNavigation.menu
344
+ for (i in menu.size() - 1 downTo items.size) {
345
+ menu.removeItem(menu.getItem(i).itemId)
346
+ }
347
+ }
348
+ // The item views exist once the batch has been applied. Styling them now, rather than in a
349
+ // post, means the first frame already shows the final appearance instead of Material's
350
+ // defaults for a frame.
351
+ updateTextAppearance()
352
+ updateTintColors()
353
+ }
354
+
355
+ private fun applyItem(index: Int, item: TabInfo) {
356
+ val menuItem = getOrCreateItem(index, item.title)
357
+ if (item.title !== menuItem.title) {
358
+ menuItem.title = item.title
359
+ }
360
+
361
+ menuItem.isVisible = !item.hidden
362
+ updateIconTintMode(menuItem, item)
363
+ iconSources[index]?.let { loadMenuItemIcon(index, menuItem, it) }
364
+
365
+ if (item.badge?.isNotEmpty() == true) {
366
+ val badge = bottomNavigation.getOrCreateBadge(index)
367
+ badge.isVisible = true
368
+ // Set the badge text only if it's different than an empty space to show a small badge.
369
+ // More context: https://github.com/callstackincubator/react-native-bottom-tabs/issues/422
370
+ if (item.badge != " ") {
371
+ badge.text = item.badge
372
+ }
373
+ // Apply badge colors if provided (Material will use its default theme colors otherwise)
374
+ item.badgeBackgroundColor?.let { badge.backgroundColor = it }
375
+ item.badgeTextColor?.let { badge.badgeTextColor = it }
376
+ } else {
377
+ bottomNavigation.removeBadge(index)
378
+ }
379
+ post {
380
+ val itemView = bottomNavigation.findViewById<View>(menuItem.itemId)
381
+ itemView?.let { view ->
382
+ view.setOnLongClickListener {
383
+ onTabLongPressed(menuItem)
384
+ true
385
+ }
386
+ view.setOnClickListener {
387
+ onTabSelected(menuItem)
388
+ }
389
+
390
+ view.findViewById<View>(com.google.android.material.R.id.navigation_bar_item_content_container)
391
+ ?.setTabTestID(item.testID)
392
+ }
393
+ }
394
+ }
395
+
396
+ private fun getOrCreateItem(index: Int, title: String): MenuItem {
397
+ return bottomNavigation.menu.findItem(index) ?: bottomNavigation.menu.add(0, index, 0, title)
398
+ }
399
+
400
+ private fun View.setTabTestID(testId: String?) {
401
+ tag = testId
402
+ if (testId == null) {
403
+ ViewCompat.setAccessibilityDelegate(this, null)
404
+ return
405
+ }
406
+
407
+ ViewCompat.setAccessibilityDelegate(this, object : AccessibilityDelegateCompat() {
408
+ override fun onInitializeAccessibilityNodeInfo(
409
+ host: View,
410
+ info: AccessibilityNodeInfoCompat
411
+ ) {
412
+ super.onInitializeAccessibilityNodeInfo(host, info)
413
+ info.viewIdResourceName = testId
414
+ }
415
+ })
416
+ }
417
+
418
+ private fun updateIconTintMode(menuItem: MenuItem, item: TabInfo) {
419
+ MenuItemCompat.setIconTintMode(
420
+ menuItem,
421
+ if (item.iconRenderingMode == "original") PorterDuff.Mode.DST else null
422
+ )
423
+ }
424
+
425
+ fun setIcons(icons: ReadableArray?) {
426
+ if (icons == null || icons.size() == 0) {
427
+ return
428
+ }
429
+
430
+ for (idx in 0 until icons.size()) {
431
+ val source = icons.getMap(idx)
432
+ val uri = source?.getString("uri")
433
+ if (uri.isNullOrEmpty()) {
434
+ continue
435
+ }
436
+
437
+ val imageSource = ImageSource(context, uri)
438
+ this.iconSources[idx] = imageSource
439
+
440
+ // Update existing item if exists.
441
+ bottomNavigation.menu.findItem(idx)?.let { menuItem ->
442
+ loadMenuItemIcon(idx, menuItem, imageSource)
443
+ }
444
+ }
445
+ }
446
+
447
+ fun setLabeled(labeled: Boolean?) {
448
+ this.labeled = labeled
449
+ bottomNavigation.labelVisibilityMode = when (labeled) {
450
+ false -> {
451
+ LABEL_VISIBILITY_UNLABELED
452
+ }
453
+ true -> {
454
+ LABEL_VISIBILITY_LABELED
455
+ }
456
+ else -> {
457
+ LABEL_VISIBILITY_AUTO
458
+ }
459
+ }
460
+ }
461
+
462
+ fun setRippleColor(color: ColorStateList) {
463
+ bottomNavigation.itemRippleColor = color
464
+ }
465
+
466
+ private fun loadMenuItemIcon(index: Int, menuItem: MenuItem, imageSource: ImageSource) {
467
+ val loadId = iconLoads.begin(index)
468
+ getDrawable(imageSource) { drawable ->
469
+ if (!iconLoads.isLatest(index, loadId)) {
470
+ return@getDrawable
471
+ }
472
+ menuItem.icon = drawable
473
+ items.getOrNull(index)?.let { updateIconTintMode(menuItem, it) }
474
+ }
475
+ if (menuItem.icon == null) {
476
+ // The icon is arriving asynchronously. Material lays the label out differently for an item
477
+ // without an icon, so reserve the icon's space to keep the label from moving once it lands.
478
+ menuItem.icon = placeholderIcon()
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Inflates [count] placeholder items so that a later [updateItems] only has to fill in titles
484
+ * and icons instead of building the item views.
485
+ */
486
+ fun prewarmItems(count: Int) {
487
+ bottomNavigation.batchMenuChanges {
488
+ for (index in 0 until count) {
489
+ bottomNavigation.menu.add(0, index, 0, "").icon = placeholderIcon()
490
+ }
491
+ }
492
+ }
493
+
494
+ private fun placeholderIcon(): Drawable {
495
+ val size = bottomNavigation.itemIconSize
496
+ return GradientDrawable().apply {
497
+ setColor(Color.TRANSPARENT)
498
+ setSize(size, size)
499
+ }
500
+ }
501
+
502
+ /**
503
+ * An XML drawable resource referenced by name (`{ uri: 'tab_home' }`), such as a vector
504
+ * drawable, is inflated right here instead of going through Coil: it inflates in well under a
505
+ * millisecond, keeps Material's tint, and being in the first frame avoids the labels moving once
506
+ * icons arrive asynchronously. Bitmap drawables and SVGs in `res/raw` stay with Coil, which
507
+ * decodes them off the main thread.
508
+ */
509
+ private fun inflateXmlDrawable(imageSource: ImageSource): Drawable? {
510
+ val uri = imageSource.getUri(context) ?: return null
511
+ if (uri.scheme != ContentResolver.SCHEME_ANDROID_RESOURCE) {
512
+ return null
513
+ }
514
+ val resId = uri.lastPathSegment?.toIntOrNull() ?: return null
515
+ val value = TypedValue()
516
+ try {
517
+ context.resources.getValue(resId, value, true)
518
+ } catch (e: Resources.NotFoundException) {
519
+ return null
520
+ }
521
+ if (value.string?.endsWith(".xml") != true) {
522
+ return null
523
+ }
524
+ return ResourcesCompat.getDrawable(context.resources, resId, context.theme)?.mutate()
525
+ }
526
+
527
+ @SuppressLint("CheckResult")
528
+ private fun getDrawable(imageSource: ImageSource, onDrawableReady: (Drawable?) -> Unit) {
529
+ drawableCache[imageSource]?.let {
530
+ onDrawableReady(it)
531
+ return
532
+ }
533
+ inflateXmlDrawable(imageSource)?.let {
534
+ drawableCache[imageSource] = it
535
+ onDrawableReady(it)
536
+ return
537
+ }
538
+ val iconSizePx = bottomNavigation.itemIconSize
539
+ val request = ImageRequest.Builder(context)
540
+ .data(imageSource.getUri(context))
541
+ .size(CoilSize(iconSizePx, iconSizePx))
542
+ .scale(Scale.FILL)
543
+ .precision(Precision.EXACT)
544
+ .target { drawable ->
545
+ post {
546
+ val stateDrawable = drawable.asDrawable(context.resources)
547
+ drawableCache[imageSource] = stateDrawable
548
+ onDrawableReady(stateDrawable)
549
+ }
550
+ }
551
+ .listener(
552
+ onError = { _, result ->
553
+ Log.e("RCTTabView", "Error loading image: ${imageSource.uri}", result.throwable)
554
+ }
555
+ )
556
+ .build()
557
+
558
+ iconExecutor.execute {
559
+ imageLoader(context).enqueue(request)
560
+ }
561
+ }
562
+
563
+ fun setBarTintColor(color: Int?) {
564
+ // Set the color, either using the active background color or a default color.
565
+ val backgroundColor =
566
+ color ?: Utils.getDefaultColorFor(context, android.R.attr.colorPrimary) ?: return
567
+
568
+ // Apply the same color to both active and inactive states
569
+ val colorDrawable = ColorDrawable(backgroundColor)
570
+
571
+ bottomNavigation.itemBackground = colorDrawable
572
+ bottomNavigation.backgroundTintList = ColorStateList.valueOf(backgroundColor)
573
+ hasCustomAppearance = true
574
+ }
575
+
576
+ fun setActiveTintColor(color: Int?) {
577
+ activeTintColor = color
578
+ updateTintColors()
579
+ }
580
+
581
+ fun setInactiveTintColor(color: Int?) {
582
+ inactiveTintColor = color
583
+ updateTintColors()
584
+ }
585
+
586
+ fun setActiveIndicatorColor(color: ColorStateList) {
587
+ bottomNavigation.itemActiveIndicatorColor = color
588
+ }
589
+
590
+ fun setFontSize(size: Int) {
591
+ fontSize = size
592
+ updateTextAppearance()
593
+ }
594
+
595
+ fun setFontFamily(family: String?) {
596
+ fontFamily = family
597
+ updateTextAppearance()
598
+ }
599
+
600
+ fun setFontWeight(weight: String?) {
601
+ val fontWeight = ReactTypefaceUtils.parseFontWeight(weight)
602
+ this.fontWeight = fontWeight
603
+ updateTextAppearance()
604
+ }
605
+
606
+ private fun updateTextAppearance() {
607
+ // Early return if there is no custom text appearance
608
+ if (fontSize == null && fontFamily == null && fontWeight == null) {
609
+ return
610
+ }
611
+
612
+ val typeface = if (fontFamily != null || fontWeight != null) {
613
+ ReactFontManager.getInstance().getTypeface(
614
+ fontFamily ?: "",
615
+ Utils.getTypefaceStyle(fontWeight),
616
+ context.assets
617
+ )
618
+ } else null
619
+ val size = fontSize?.toFloat()?.takeIf { it > 0 }
620
+
621
+ val menuView = bottomNavigation.getChildAt(0) as? ViewGroup ?: return
622
+ for (i in 0 until menuView.childCount) {
623
+ val item = menuView.getChildAt(i)
624
+ val largeLabel =
625
+ item.findViewById<TextView>(com.google.android.material.R.id.navigation_bar_item_large_label_view)
626
+ val smallLabel =
627
+ item.findViewById<TextView>(com.google.android.material.R.id.navigation_bar_item_small_label_view)
628
+
629
+ listOf(largeLabel, smallLabel).forEach { label ->
630
+ label?.apply {
631
+ size?.let { size ->
632
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, size)
633
+ }
634
+ typeface?.let { setTypeface(it) }
635
+ }
636
+ }
637
+ }
638
+ }
639
+
640
+ private fun emitHapticFeedback(feedbackConstants: Int) {
641
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && hapticFeedbackEnabled) {
642
+ this.performHapticFeedback(feedbackConstants)
643
+ }
644
+ }
645
+
646
+ private fun updateTintColors() {
647
+ // First let's check current item color.
648
+ val currentItemTintColor = items.firstOrNull { it.key == selectedItem }?.activeTintColor
649
+
650
+ // getDefaultColor will always return a valid color but to satisfy the compiler we need to check for null
651
+ val colorPrimary = currentItemTintColor ?: activeTintColor ?: Utils.getDefaultColorFor(
652
+ context,
653
+ android.R.attr.colorPrimary
654
+ ) ?: return
655
+ val colorSecondary =
656
+ inactiveTintColor ?: Utils.getDefaultColorFor(context, android.R.attr.textColorSecondary)
657
+ ?: return
658
+ val states = arrayOf(uncheckedStateSet, checkedStateSet)
659
+ val colors = intArrayOf(colorSecondary, colorPrimary)
660
+
661
+ ColorStateList(states, colors).apply {
662
+ this@ReactBottomNavigationView.bottomNavigation.itemTextColor = this
663
+ this@ReactBottomNavigationView.bottomNavigation.itemIconTintList = this
664
+ }
665
+ }
666
+
667
+ override fun onConfigurationChanged(newConfig: Configuration?) {
668
+ super.onConfigurationChanged(newConfig)
669
+ if (uiModeConfiguration == newConfig?.uiMode || hasCustomAppearance) {
670
+ return
671
+ }
672
+
673
+ // User has hidden the bottom navigation bar, don't re-attach it.
674
+ if (bottomNavigation.visibility == GONE) {
675
+ return
676
+ }
677
+
678
+ // If appearance wasn't changed re-create the bottom navigation view when configuration changes.
679
+ // React Native opts out ouf Activity re-creation when configuration changes, this workarounds that.
680
+ // We also opt-out of this recreation when custom styles are used.
681
+ removeView(bottomNavigation)
682
+ bottomNavigation = ExtendedBottomNavigationView(context)
683
+ addView(bottomNavigation)
684
+ updateItems(items)
685
+ setLabeled(this.labeled)
686
+ this.selectedItem?.let { setSelectedItem(it) }
687
+ uiModeConfiguration = newConfig?.uiMode ?: uiModeConfiguration
688
+ }
689
+ }