@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.
- package/README.md +32 -0
- package/android/build.gradle +132 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/AndroidManifestNew.xml +2 -0
- package/android/src/main/java/com/rcttabview/ImageSource.kt +86 -0
- package/android/src/main/java/com/rcttabview/LatestRequestTracker.kt +19 -0
- package/android/src/main/java/com/rcttabview/RCTTabView.kt +689 -0
- package/android/src/main/java/com/rcttabview/RCTTabViewManager.kt +222 -0
- package/android/src/main/java/com/rcttabview/RCTTabViewPackage.kt +19 -0
- package/android/src/main/java/com/rcttabview/RCTTabViewPrewarm.kt +40 -0
- package/android/src/main/java/com/rcttabview/RebuildBatch.kt +31 -0
- package/android/src/main/java/com/rcttabview/TabBarVisibility.kt +14 -0
- package/android/src/main/java/com/rcttabview/Utils.kt +31 -0
- package/android/src/main/java/com/rcttabview/events/OnNativeLayoutEvent.kt +25 -0
- package/android/src/main/java/com/rcttabview/events/OnTabBarMeasuredEvent.kt +24 -0
- package/android/src/main/java/com/rcttabview/events/PageSelectedEvent.kt +32 -0
- package/android/src/main/java/com/rcttabview/events/TabLongPressedEvent.kt +24 -0
- package/android/src/test/java/com/rcttabview/LatestRequestTrackerTest.kt +37 -0
- package/android/src/test/java/com/rcttabview/RebuildBatchTest.kt +44 -0
- package/android/src/test/java/com/rcttabview/TabBarVisibilityTest.kt +58 -0
- package/app.plugin.js +1 -0
- package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewComponentDescriptor.h +33 -0
- package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewShadowNode.cpp +19 -0
- package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewShadowNode.h +35 -0
- package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewState.cpp +15 -0
- package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewState.h +25 -0
- package/ios/BottomAccessoryProvider.swift +27 -0
- package/ios/Extensions.swift +101 -0
- package/ios/PlatformAliases.swift +43 -0
- package/ios/RCTBottomAccessoryComponentView.h +17 -0
- package/ios/RCTBottomAccessoryComponentView.mm +76 -0
- package/ios/RCTTabViewComponentView.h +16 -0
- package/ios/RCTTabViewComponentView.mm +285 -0
- package/ios/RepresentableView.swift +55 -0
- package/ios/SVG/CoreSVG.h +19 -0
- package/ios/SVG/CoreSVG.mm +177 -0
- package/ios/SVG/SvgDecoder.h +10 -0
- package/ios/SVG/SvgDecoder.mm +38 -0
- package/ios/TabAppearModifier.swift +38 -0
- package/ios/TabBarFontSize.swift +88 -0
- package/ios/TabItem.swift +35 -0
- package/ios/TabItemEventModifier.swift +163 -0
- package/ios/TabView/AnyTabView.swift +7 -0
- package/ios/TabView/LegacyTabView.swift +70 -0
- package/ios/TabView/NewTabView.swift +138 -0
- package/ios/TabViewImpl.swift +566 -0
- package/ios/TabViewProps.swift +109 -0
- package/ios/TabViewProvider.swift +368 -0
- package/ios/react-native-bottom-tabs-Bridging-Header.h +4 -0
- package/lib/module/BottomAccessoryView.js +47 -0
- package/lib/module/BottomAccessoryView.js.map +1 -0
- package/lib/module/BottomAccessoryViewNativeComponent.ts +27 -0
- package/lib/module/DelayedFreeze.js +31 -0
- package/lib/module/DelayedFreeze.js.map +1 -0
- package/lib/module/NativeSVGDecoder.js +5 -0
- package/lib/module/NativeSVGDecoder.js.map +1 -0
- package/lib/module/SceneMap.js +28 -0
- package/lib/module/SceneMap.js.map +1 -0
- package/lib/module/TabView.js +294 -0
- package/lib/module/TabView.js.map +1 -0
- package/lib/module/TabViewNativeComponent.ts +73 -0
- package/lib/module/expo.js +35 -0
- package/lib/module/expo.js.map +1 -0
- package/lib/module/index.js +20 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils/BottomTabBarHeightContext.js +5 -0
- package/lib/module/utils/BottomTabBarHeightContext.js.map +1 -0
- package/lib/module/utils/useBottomTabBarHeight.js +12 -0
- package/lib/module/utils/useBottomTabBarHeight.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/BottomAccessoryView.d.ts +7 -0
- package/lib/typescript/src/BottomAccessoryView.d.ts.map +1 -0
- package/lib/typescript/src/BottomAccessoryView.js +24 -0
- package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts +15 -0
- package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/BottomAccessoryViewNativeComponent.js +4 -0
- package/lib/typescript/src/DelayedFreeze.d.ts +7 -0
- package/lib/typescript/src/DelayedFreeze.d.ts.map +1 -0
- package/lib/typescript/src/DelayedFreeze.js +21 -0
- package/lib/typescript/src/NativeSVGDecoder.d.ts +5 -0
- package/lib/typescript/src/NativeSVGDecoder.d.ts.map +1 -0
- package/lib/typescript/src/NativeSVGDecoder.js +2 -0
- package/lib/typescript/src/SceneMap.d.ts +9 -0
- package/lib/typescript/src/SceneMap.d.ts.map +1 -0
- package/lib/typescript/src/SceneMap.js +15 -0
- package/lib/typescript/src/TabView.d.ts +219 -0
- package/lib/typescript/src/TabView.d.ts.map +1 -0
- package/lib/typescript/src/TabView.js +157 -0
- package/lib/typescript/src/TabViewNativeComponent.d.ts +59 -0
- package/lib/typescript/src/TabViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/TabViewNativeComponent.js +4 -0
- package/lib/typescript/src/expo.d.ts +6 -0
- package/lib/typescript/src/expo.d.ts.map +1 -0
- package/lib/typescript/src/expo.js +34 -0
- package/lib/typescript/src/index.d.ts +15 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/index.js +11 -0
- package/lib/typescript/src/types.d.ts +31 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/types.js +1 -0
- package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts +2 -0
- package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts.map +1 -0
- package/lib/typescript/src/utils/BottomTabBarHeightContext.js +2 -0
- package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts +1 -0
- package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts.map +1 -0
- package/lib/typescript/src/utils/useBottomTabBarHeight.js +9 -0
- package/lib/typescript/tsconfig.tsbuildinfo +1 -0
- package/package.json +135 -0
- package/react-native-bottom-tabs.podspec +43 -0
- package/src/BottomAccessoryView.tsx +59 -0
- package/src/BottomAccessoryViewNativeComponent.ts +27 -0
- package/src/DelayedFreeze.tsx +31 -0
- package/src/NativeSVGDecoder.ts +5 -0
- package/src/SceneMap.tsx +34 -0
- package/src/TabView.tsx +558 -0
- package/src/TabViewNativeComponent.ts +73 -0
- package/src/expo.ts +58 -0
- package/src/index.tsx +23 -0
- package/src/types.ts +36 -0
- package/src/utils/BottomTabBarHeightContext.ts +5 -0
- 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
|
+
}
|