@onekeyfe/react-native-bottom-tabs 0.0.0-stage → 6.7.0-10

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 (78) hide show
  1. package/LICENSE +46 -0
  2. package/README.md +94 -2
  3. package/android/build.gradle +119 -0
  4. package/android/gradle.properties +4 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/AndroidManifestNew.xml +2 -0
  7. package/android/src/main/java/com/onekey/bottomtabs/BottomBarBlurRenderer.kt +92 -0
  8. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsPackage.kt +16 -0
  9. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsView.kt +1558 -0
  10. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsViewManager.kt +220 -0
  11. package/android/src/main/java/com/onekey/bottomtabs/FloatingTabBarStyle.kt +50 -0
  12. package/android/src/main/java/com/onekey/bottomtabs/ImageSource.kt +86 -0
  13. package/android/src/main/java/com/onekey/bottomtabs/TabInfo.kt +13 -0
  14. package/android/src/main/java/com/onekey/bottomtabs/Utils.kt +31 -0
  15. package/android/src/main/java/com/onekey/bottomtabs/events/OnNativeLayoutEvent.kt +20 -0
  16. package/android/src/main/java/com/onekey/bottomtabs/events/OnTabBarMeasuredEvent.kt +19 -0
  17. package/android/src/main/java/com/onekey/bottomtabs/events/PageSelectedEvent.kt +21 -0
  18. package/android/src/main/java/com/onekey/bottomtabs/events/TabDoublePressEvent.kt +19 -0
  19. package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewComponentDescriptor.h +32 -0
  20. package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewShadowNode.cpp +18 -0
  21. package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewShadowNode.h +35 -0
  22. package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewState.cpp +15 -0
  23. package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewState.h +25 -0
  24. package/docs/SPEC.md +168 -0
  25. package/ios/Extensions.swift +46 -0
  26. package/ios/RCTBottomAccessoryComponentView.h +12 -0
  27. package/ios/RCTBottomAccessoryComponentView.mm +67 -0
  28. package/ios/RCTBottomAccessoryContainerView.swift +51 -0
  29. package/ios/RCTBottomTabsComponentView.h +12 -0
  30. package/ios/RCTBottomTabsComponentView.mm +338 -0
  31. package/ios/RCTBottomTabsContainerView.swift +777 -0
  32. package/ios/RCTBottomTabsLog.h +7 -0
  33. package/ios/RCTBottomTabsLog.m +32 -0
  34. package/ios/SVG/CoreSVG.h +13 -0
  35. package/ios/SVG/CoreSVG.mm +177 -0
  36. package/ios/SVG/SvgDecoder.h +10 -0
  37. package/ios/SVG/SvgDecoder.mm +32 -0
  38. package/ios/TabBarFontSize.swift +55 -0
  39. package/lib/module/BottomAccessoryView.js +45 -0
  40. package/lib/module/BottomAccessoryViewNativeComponent.ts +27 -0
  41. package/lib/module/BottomTabs.js +325 -0
  42. package/lib/module/BottomTabsNativeComponent.ts +70 -0
  43. package/lib/module/DelayedFreeze.js +31 -0
  44. package/lib/module/NativeSVGDecoder.js +5 -0
  45. package/lib/module/SceneMap.js +28 -0
  46. package/lib/module/codegen-types.d.js +2 -0
  47. package/lib/module/index.js +20 -0
  48. package/lib/module/package.json +1 -0
  49. package/lib/module/types.js +4 -0
  50. package/lib/module/utils/BottomTabBarHeightContext.js +5 -0
  51. package/lib/module/utils/useBottomTabBarHeight.js +12 -0
  52. package/lib/typescript/package.json +1 -0
  53. package/lib/typescript/src/BottomAccessoryView.d.ts +8 -0
  54. package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts +16 -0
  55. package/lib/typescript/src/BottomTabs.d.ts +209 -0
  56. package/lib/typescript/src/BottomTabsNativeComponent.d.ts +59 -0
  57. package/lib/typescript/src/DelayedFreeze.d.ts +9 -0
  58. package/lib/typescript/src/NativeSVGDecoder.d.ts +6 -0
  59. package/lib/typescript/src/SceneMap.d.ts +10 -0
  60. package/lib/typescript/src/index.d.ts +16 -0
  61. package/lib/typescript/src/types.d.ts +29 -0
  62. package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts +3 -0
  63. package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts +2 -0
  64. package/package.json +117 -4
  65. package/react-native-bottom-tabs.podspec +36 -0
  66. package/react-native.config.js +13 -0
  67. package/src/BottomAccessoryView.tsx +58 -0
  68. package/src/BottomAccessoryViewNativeComponent.ts +27 -0
  69. package/src/BottomTabs.tsx +559 -0
  70. package/src/BottomTabsNativeComponent.ts +70 -0
  71. package/src/DelayedFreeze.tsx +33 -0
  72. package/src/NativeSVGDecoder.ts +5 -0
  73. package/src/SceneMap.tsx +34 -0
  74. package/src/codegen-types.d.ts +28 -0
  75. package/src/index.tsx +18 -0
  76. package/src/types.ts +31 -0
  77. package/src/utils/BottomTabBarHeightContext.ts +5 -0
  78. package/src/utils/useBottomTabBarHeight.ts +15 -0
@@ -0,0 +1,1558 @@
1
+ package com.onekey.bottomtabs
2
+
3
+ import android.animation.ValueAnimator
4
+ import android.annotation.SuppressLint
5
+ import android.content.Context
6
+ import android.content.res.ColorStateList
7
+ import android.content.res.Configuration
8
+ import androidx.appcompat.view.ContextThemeWrapper
9
+ import android.graphics.Color
10
+ import android.graphics.Canvas
11
+ import android.graphics.Outline
12
+ import android.graphics.drawable.ColorDrawable
13
+ import android.graphics.drawable.Drawable
14
+ import android.graphics.drawable.GradientDrawable
15
+ import android.os.Build
16
+ import android.os.SystemClock
17
+ import android.transition.TransitionManager
18
+ import android.util.Log
19
+ import android.util.Size
20
+ import android.util.TypedValue
21
+ import android.view.Choreographer
22
+ import android.view.Gravity
23
+ import android.view.HapticFeedbackConstants
24
+ import android.view.MenuItem
25
+ import android.view.MotionEvent
26
+ import android.view.View
27
+ import android.view.ViewConfiguration
28
+ import android.view.ViewGroup
29
+ import android.view.ViewOutlineProvider
30
+ import android.view.animation.DecelerateInterpolator
31
+ import android.widget.FrameLayout
32
+ import android.widget.LinearLayout
33
+ import android.widget.TextView
34
+ import androidx.core.animation.doOnEnd
35
+ import androidx.core.graphics.ColorUtils
36
+ import androidx.core.view.ViewCompat
37
+ import androidx.core.view.WindowInsetsCompat
38
+ import androidx.core.view.forEachIndexed
39
+ import coil3.ImageLoader
40
+ import coil3.asDrawable
41
+ import coil3.request.ImageRequest
42
+ import coil3.svg.SvgDecoder
43
+ import coil3.size.Precision
44
+ import coil3.size.Size as CoilSize
45
+ import coil3.size.Scale
46
+ import com.facebook.react.bridge.ReadableArray
47
+ import com.facebook.react.common.assets.ReactFontManager
48
+ import com.facebook.react.modules.core.ReactChoreographer
49
+ import com.facebook.react.uimanager.PointerEvents
50
+ import com.facebook.react.uimanager.ReactPointerEventsView
51
+ import com.facebook.react.views.text.ReactTypefaceUtils
52
+ import com.google.android.material.bottomnavigation.BottomNavigationView
53
+ import com.google.android.material.navigation.NavigationBarView
54
+ import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_AUTO
55
+ import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_LABELED
56
+ import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_UNLABELED
57
+ import com.google.android.material.transition.platform.MaterialFadeThrough
58
+ import kotlin.math.abs
59
+ import kotlin.math.roundToInt
60
+
61
+ private fun getMaterialContext(context: Context): Context {
62
+ return ContextThemeWrapper(context, com.google.android.material.R.style.Theme_MaterialComponents_DayNight)
63
+ }
64
+
65
+ class ExtendedBottomNavigationView(context: Context) : BottomNavigationView(getMaterialContext(context)) {
66
+ private val basePaddingStart = paddingStart
67
+ private val basePaddingTop = paddingTop
68
+ private val basePaddingEnd = paddingEnd
69
+ private val basePaddingBottom = paddingBottom
70
+ private var ignoreBottomInsets = false
71
+ private var ignoreKeyboardInsets = true
72
+ // Floating mode lays the bar out inside an inset container that already
73
+ // accounts for system bars and the keyboard, so the menu keeps fixed padding.
74
+ private var floatingHorizontalPadding: Int? = null
75
+
76
+ init {
77
+ // OneKey patch: Material pads the bar with getSystemWindowInsetBottom(), which
78
+ // includes the IME height only while the window uses adjustResize (switched on by
79
+ // react-native-keyboard-controller hooks), so whether the bar rose above the
80
+ // keyboard depended on the screen. Pad for system bars, and add the IME only when
81
+ // ignoreKeyboardInsets is turned off.
82
+ ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
83
+ floatingHorizontalPadding?.let { padding ->
84
+ view.setPaddingRelative(padding, 0, padding, 0)
85
+ return@setOnApplyWindowInsetsListener insets
86
+ }
87
+ val barInsets = insets.getInsets(
88
+ WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
89
+ )
90
+ val navigationBarBottom = if (ignoreBottomInsets) 0 else barInsets.bottom
91
+ val keyboardBottom = if (ignoreKeyboardInsets) {
92
+ 0
93
+ } else {
94
+ insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
95
+ }
96
+ val isRtl = view.layoutDirection == View.LAYOUT_DIRECTION_RTL
97
+ view.setPaddingRelative(
98
+ basePaddingStart + if (isRtl) barInsets.right else barInsets.left,
99
+ basePaddingTop,
100
+ basePaddingEnd + if (isRtl) barInsets.left else barInsets.right,
101
+ basePaddingBottom + maxOf(navigationBarBottom, keyboardBottom),
102
+ )
103
+ insets
104
+ }
105
+ }
106
+
107
+ fun setIgnoreBottomInsets(ignore: Boolean) {
108
+ if (ignoreBottomInsets == ignore) {
109
+ return
110
+ }
111
+ ignoreBottomInsets = ignore
112
+ requestApplyInsets()
113
+ }
114
+
115
+ fun setIgnoreKeyboardInsets(ignore: Boolean) {
116
+ if (ignoreKeyboardInsets == ignore) {
117
+ return
118
+ }
119
+ ignoreKeyboardInsets = ignore
120
+ requestApplyInsets()
121
+ }
122
+
123
+ fun setFloatingHorizontalPadding(padding: Int?) {
124
+ if (floatingHorizontalPadding == padding) {
125
+ return
126
+ }
127
+ floatingHorizontalPadding = padding
128
+ if (padding != null) {
129
+ setPaddingRelative(padding, 0, padding, 0)
130
+ } else {
131
+ setPaddingRelative(basePaddingStart, basePaddingTop, basePaddingEnd, basePaddingBottom)
132
+ }
133
+ requestApplyInsets()
134
+ }
135
+
136
+ override fun getMaxItemCount(): Int {
137
+ return 100
138
+ }
139
+ }
140
+
141
+ // OneKey patch: RN hit-testing traverses INVISIBLE native wrappers unless pointer events opt out.
142
+ private class TabSceneContainer(context: Context) : FrameLayout(context), ReactPointerEventsView {
143
+ override var pointerEvents: PointerEvents = PointerEvents.NONE
144
+ }
145
+
146
+ private enum class AndroidTabBarStyle {
147
+ DEFAULT,
148
+ FLOATING,
149
+ }
150
+
151
+ class BottomTabsView(context: Context) : LinearLayout(context) {
152
+ private var bottomNavigation = ExtendedBottomNavigationView(context)
153
+ val layoutHolder = FrameLayout(context)
154
+ private val floatingRoot = object : FrameLayout(context) {
155
+ override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
156
+ if (child === floatingBar && usesBottomBlur) {
157
+ drawBlurBackdrop(canvas, width, floatingBar.top, height) { recording ->
158
+ super.drawChild(recording, layoutHolder, drawingTime)
159
+ }
160
+ }
161
+ return super.drawChild(canvas, child, drawingTime)
162
+ }
163
+ }
164
+ private val floatingBar = FrameLayout(context)
165
+ private val floatingBarBackground = View(context)
166
+ private val floatingIndicator = View(context)
167
+ private val floatingIndicatorDrawable = GradientDrawable()
168
+
169
+ var onTabSelectedListener: ((key: String) -> Unit)? = null
170
+ var onTabDoublePressedListener: ((key: String) -> Unit)? = null
171
+ var onNativeLayoutListener: ((width: Double, height: Double) -> Unit)? = null
172
+ var onTabBarMeasuredListener: ((height: Int) -> Unit)? = null
173
+ var disablePageAnimations = false
174
+ set(value) {
175
+ if (field == value) {
176
+ return
177
+ }
178
+ field = value
179
+ layoutHolder.forEachIndexed { _, view ->
180
+ if (view.visibility != VISIBLE) {
181
+ view.visibility = if (value) INVISIBLE else GONE
182
+ }
183
+ }
184
+ layoutHolder.requestLayout()
185
+ }
186
+ var items: MutableList<TabInfo> = mutableListOf()
187
+ private val iconSources: MutableMap<Int, ImageSource> = mutableMapOf()
188
+ private val drawableCache: MutableMap<ImageSource, Drawable> = mutableMapOf()
189
+
190
+ private var isLayoutEnqueued = false
191
+ private var selectedItem: String? = null
192
+ private var activeTintColor: Int? = null
193
+ private var inactiveTintColor: Int? = null
194
+ private val checkedStateSet = intArrayOf(android.R.attr.state_checked)
195
+ private val uncheckedStateSet = intArrayOf(-android.R.attr.state_checked)
196
+ // OneKey patch: rely on View.isHapticFeedbackEnabled as the single source of truth.
197
+ // private var hapticFeedbackEnabled = false
198
+ private var fontSize: Int? = null
199
+ private var fontFamily: String? = null
200
+ private var fontWeight: Int? = null
201
+ private var labeled: Boolean? = null
202
+ private var lastReportedSize: Size? = null
203
+ private var lastReportedTabBarHeight = -1
204
+ private var hasCustomAppearance = false
205
+ private var uiModeConfiguration: Int = Configuration.UI_MODE_NIGHT_UNDEFINED
206
+ private var androidTabBarStyle = AndroidTabBarStyle.DEFAULT
207
+ private var ignoreBottomInsets = false
208
+ private var ignoreKeyboardInsets = true
209
+ private var isTabBarHidden = false
210
+ private var floatingBottomInset = 0
211
+ private var floatingLeftInset = 0
212
+ private var floatingRightInset = 0
213
+ private var floatingIndicatorPositioned = false
214
+ // Lens state: 0 is the resting selection pill, 1 is the lifted glass lens.
215
+ private var floatingLift = 0f
216
+ private var floatingStretch = 0f
217
+ private var floatingLiftAnimator: ValueAnimator? = null
218
+ private var floatingSlideAnimator: ValueAnimator? = null
219
+ private var floatingTouchView: View? = null
220
+ private var floatingTouchItemId = View.NO_ID
221
+ private var floatingCandidateItemId = View.NO_ID
222
+ private var floatingDragging = false
223
+ private var floatingTouchStartRawX = 0f
224
+ private var floatingTouchStartRawY = 0f
225
+ private var floatingLastMoveRawX = 0f
226
+ private var floatingLastMoveTime = 0L
227
+ private var floatingRestingFillColor = Color.TRANSPARENT
228
+ private var floatingLensFillColor = Color.TRANSPARENT
229
+ private var floatingLensStrokeColor = Color.TRANSPARENT
230
+ private val floatingTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
231
+ private val floatingBarLocationOnScreen = IntArray(2)
232
+ private var lastPressedItemId = View.NO_ID
233
+ private var lastPressedTime = 0L
234
+ private var defaultLabelTextSizes: Pair<Float, Float>? = null
235
+ private var barTintColor: Int? = null
236
+ private var activeIndicatorColor: ColorStateList? = null
237
+ private var rippleColor: ColorStateList? = null
238
+ private var defaultItemBackground = bottomNavigation.itemBackground
239
+ private var defaultBackgroundTintList = bottomNavigation.backgroundTintList
240
+ private var defaultElevation = bottomNavigation.elevation
241
+ private var defaultActiveIndicatorEnabled = bottomNavigation.isItemActiveIndicatorEnabled
242
+ private var defaultActiveIndicatorColor = bottomNavigation.itemActiveIndicatorColor
243
+ private var defaultMinimumHeight = bottomNavigation.minimumHeight
244
+ private var defaultPaddingLeft = bottomNavigation.paddingLeft
245
+ private var defaultPaddingTop = bottomNavigation.paddingTop
246
+ private var defaultPaddingRight = bottomNavigation.paddingRight
247
+ private var defaultPaddingBottom = bottomNavigation.paddingBottom
248
+ private var defaultItemGravity = bottomNavigation.itemGravity
249
+ private var defaultItemPaddingTop = bottomNavigation.itemPaddingTop
250
+ private var defaultItemPaddingBottom = bottomNavigation.itemPaddingBottom
251
+ private var defaultItemIconSize = bottomNavigation.itemIconSize
252
+ private var defaultItemRippleColor = bottomNavigation.itemRippleColor
253
+
254
+ private val imageLoader = ImageLoader.Builder(context)
255
+ .components {
256
+ add(SvgDecoder.Factory())
257
+ }
258
+ .build()
259
+
260
+ private var androidBottomBlurEnabled = false
261
+ private var bottomBarBlur: BottomBarBlurRenderer? = null
262
+ private var blurAppearanceApplied = false
263
+ private var floatingBlurAppearanceApplied = false
264
+ private var blurSurfaceColor = Color.WHITE
265
+ private var savedBarTint: ColorStateList? = null
266
+ private var savedItemBackground: Drawable? = null
267
+ private var savedBarElevation = 0f
268
+
269
+ private val usesBottomBlur: Boolean
270
+ get() = androidBottomBlurEnabled && Build.VERSION.SDK_INT >= 31 &&
271
+ isHardwareAccelerated && !isTabBarHidden
272
+
273
+ init {
274
+ orientation = VERTICAL
275
+ layoutHolder.isSaveEnabled = false
276
+ floatingRoot.clipChildren = false
277
+ floatingRoot.clipToPadding = false
278
+ // The pressed lens grows past the bar edges, so the bar does not clip; its
279
+ // shadow comes from a capsule outline instead.
280
+ floatingBar.clipChildren = false
281
+ floatingBar.clipToPadding = false
282
+ floatingBar.outlineProvider = object : ViewOutlineProvider() {
283
+ override fun getOutline(view: View, outline: Outline) {
284
+ outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f)
285
+ }
286
+ }
287
+ floatingIndicatorDrawable.shape = GradientDrawable.RECTANGLE
288
+ floatingIndicator.background = floatingIndicatorDrawable
289
+ floatingBar.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
290
+ if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
291
+ floatingBar.invalidateOutline()
292
+ updateFloatingIndicator(animate = false)
293
+ }
294
+ }
295
+
296
+ attachDefaultLayout()
297
+ uiModeConfiguration = resources.configuration.uiMode
298
+
299
+ ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets ->
300
+ val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
301
+ val keyboardBottom = if (ignoreKeyboardInsets) {
302
+ 0
303
+ } else {
304
+ insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
305
+ }
306
+ floatingBottomInset = maxOf(if (ignoreBottomInsets) 0 else navigationBars.bottom, keyboardBottom)
307
+ floatingLeftInset = navigationBars.left
308
+ floatingRightInset = navigationBars.right
309
+ if (androidTabBarStyle == AndroidTabBarStyle.FLOATING) {
310
+ updateFloatingBarLayout()
311
+ }
312
+ insets
313
+ }
314
+
315
+
316
+ }
317
+
318
+ private fun dp(value: Float): Int =
319
+ TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, resources.displayMetrics).roundToInt()
320
+
321
+ private fun removeFromParent(view: View) {
322
+ (view.parent as? ViewGroup)?.removeView(view)
323
+ }
324
+
325
+ private fun detachTabBarViews() {
326
+ releaseBottomBlur()
327
+ removeFromParent(layoutHolder)
328
+ removeFromParent(bottomNavigation)
329
+ removeFromParent(floatingRoot)
330
+ removeFromParent(floatingBar)
331
+ removeFromParent(floatingBarBackground)
332
+ removeFromParent(floatingIndicator)
333
+ super.removeAllViews()
334
+ floatingRoot.removeAllViews()
335
+ floatingBar.removeAllViews()
336
+ }
337
+
338
+ private fun attachDefaultLayout() {
339
+ resetFloatingGesture()
340
+ detachTabBarViews()
341
+
342
+ super.addView(
343
+ layoutHolder,
344
+ LayoutParams(LayoutParams.MATCH_PARENT, 0).apply { weight = 1f },
345
+ )
346
+ super.addView(
347
+ bottomNavigation,
348
+ LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT),
349
+ )
350
+
351
+ bottomNavigation.setFloatingHorizontalPadding(null)
352
+ bottomNavigation.minimumHeight = defaultMinimumHeight
353
+ bottomNavigation.setPadding(
354
+ defaultPaddingLeft,
355
+ defaultPaddingTop,
356
+ defaultPaddingRight,
357
+ defaultPaddingBottom,
358
+ )
359
+ bottomNavigation.setIgnoreBottomInsets(ignoreBottomInsets)
360
+ bottomNavigation.setIgnoreKeyboardInsets(ignoreKeyboardInsets)
361
+ bottomNavigation.elevation = defaultElevation
362
+ bottomNavigation.isItemActiveIndicatorEnabled = defaultActiveIndicatorEnabled
363
+ bottomNavigation.itemActiveIndicatorColor =
364
+ activeIndicatorColor ?: defaultActiveIndicatorColor
365
+ bottomNavigation.itemGravity = defaultItemGravity
366
+ bottomNavigation.itemPaddingTop = defaultItemPaddingTop
367
+ bottomNavigation.itemPaddingBottom = defaultItemPaddingBottom
368
+ (bottomNavigation.getChildAt(0) as? ViewGroup)?.isMotionEventSplittingEnabled = true
369
+ setNavigationIconSize(defaultItemIconSize)
370
+ if (barTintColor != null) {
371
+ applyDefaultBarBackground()
372
+ } else {
373
+ bottomNavigation.itemBackground = defaultItemBackground
374
+ bottomNavigation.backgroundTintList = defaultBackgroundTintList
375
+ }
376
+ bottomNavigation.itemRippleColor = rippleColor ?: defaultItemRippleColor
377
+ bottomNavigation.visibility = if (isTabBarHidden) GONE else VISIBLE
378
+ ViewCompat.requestApplyInsets(this)
379
+ }
380
+
381
+ private fun attachFloatingLayout() {
382
+ resetFloatingGesture()
383
+ detachTabBarViews()
384
+
385
+ val barHeight = dp(FloatingTabBarMetrics.BAR_HEIGHT)
386
+ val indicatorInset = dp(FloatingTabBarMetrics.INDICATOR_INSET)
387
+ floatingRoot.addView(
388
+ layoutHolder,
389
+ FrameLayout.LayoutParams(
390
+ FrameLayout.LayoutParams.MATCH_PARENT,
391
+ FrameLayout.LayoutParams.MATCH_PARENT,
392
+ ),
393
+ )
394
+ floatingBar.addView(
395
+ floatingBarBackground,
396
+ FrameLayout.LayoutParams(
397
+ FrameLayout.LayoutParams.MATCH_PARENT,
398
+ FrameLayout.LayoutParams.MATCH_PARENT,
399
+ ),
400
+ )
401
+ floatingBar.addView(
402
+ floatingIndicator,
403
+ FrameLayout.LayoutParams(0, barHeight - indicatorInset * 2, Gravity.TOP or Gravity.START).apply {
404
+ topMargin = indicatorInset
405
+ },
406
+ )
407
+ floatingBar.addView(
408
+ bottomNavigation,
409
+ FrameLayout.LayoutParams(
410
+ FrameLayout.LayoutParams.MATCH_PARENT,
411
+ FrameLayout.LayoutParams.MATCH_PARENT,
412
+ ),
413
+ )
414
+ floatingRoot.addView(
415
+ floatingBar,
416
+ FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, barHeight, Gravity.BOTTOM),
417
+ )
418
+ super.addView(
419
+ floatingRoot,
420
+ LayoutParams(LayoutParams.MATCH_PARENT, 0).apply { weight = 1f },
421
+ )
422
+
423
+ bottomNavigation.setFloatingHorizontalPadding(dp(FloatingTabBarMetrics.BAR_CONTENT_PADDING))
424
+ bottomNavigation.minimumHeight = 0
425
+ bottomNavigation.setIgnoreBottomInsets(true)
426
+ bottomNavigation.setIgnoreKeyboardInsets(true)
427
+ bottomNavigation.isItemActiveIndicatorEnabled = false
428
+ bottomNavigation.itemActiveIndicatorColor = ColorStateList.valueOf(Color.TRANSPARENT)
429
+ bottomNavigation.itemBackground = ColorDrawable(Color.TRANSPARENT)
430
+ bottomNavigation.backgroundTintList = ColorStateList.valueOf(Color.TRANSPARENT)
431
+ bottomNavigation.itemRippleColor = ColorStateList.valueOf(Color.TRANSPARENT)
432
+ bottomNavigation.itemGravity = NavigationBarView.ITEM_GRAVITY_CENTER
433
+ bottomNavigation.itemPaddingTop = 0
434
+ bottomNavigation.itemPaddingBottom = 0
435
+ bottomNavigation.elevation = 0f
436
+ bottomNavigation.visibility = VISIBLE
437
+ // One finger owns the lens; a second finger must not retarget it mid-drag.
438
+ (bottomNavigation.getChildAt(0) as? ViewGroup)?.isMotionEventSplittingEnabled = false
439
+ setNavigationIconSize(dp(FloatingTabBarMetrics.ICON_SIZE))
440
+ floatingBar.elevation = dp(FloatingTabBarMetrics.SHADOW_ELEVATION).toFloat()
441
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
442
+ floatingBar.outlineAmbientShadowColor = Color.argb(38, 0, 0, 0)
443
+ floatingBar.outlineSpotShadowColor = Color.argb(64, 0, 0, 0)
444
+ }
445
+ floatingBar.visibility = if (isTabBarHidden) GONE else VISIBLE
446
+ floatingIndicatorPositioned = false
447
+ updateFloatingAppearance()
448
+ updateFloatingBarLayout()
449
+ post { updateFloatingIndicator(animate = false) }
450
+ ViewCompat.requestApplyInsets(this)
451
+ }
452
+
453
+ private fun setNavigationIconSize(size: Int) {
454
+ if (bottomNavigation.itemIconSize == size) {
455
+ return
456
+ }
457
+ bottomNavigation.itemIconSize = size
458
+ // Icons are rasterized at the icon size, so reload them for the new size.
459
+ drawableCache.clear()
460
+ iconSources.forEach { (index, source) ->
461
+ bottomNavigation.menu.findItem(index)?.let { menuItem ->
462
+ getDrawable(source) { menuItem.icon = it }
463
+ }
464
+ }
465
+ }
466
+
467
+ private fun updateFloatingBarLayout() {
468
+ if (androidTabBarStyle != AndroidTabBarStyle.FLOATING) {
469
+ return
470
+ }
471
+ val sideMargin = dp(FloatingTabBarMetrics.BAR_SIDE_MARGIN)
472
+ val params = floatingBar.layoutParams as? FrameLayout.LayoutParams ?: return
473
+ params.leftMargin = sideMargin + floatingLeftInset
474
+ params.rightMargin = sideMargin + floatingRightInset
475
+ params.bottomMargin = maxOf(floatingBottomInset, dp(FloatingTabBarMetrics.BAR_MIN_BOTTOM_GAP)) +
476
+ dp(FloatingTabBarMetrics.BAR_BOTTOM_EXTRA)
477
+ floatingBar.layoutParams = params
478
+ floatingRoot.requestLayout()
479
+ }
480
+
481
+ private fun getTabBarMeasuredHeight(): Int {
482
+ if (isTabBarHidden) {
483
+ return 0
484
+ }
485
+ if (androidTabBarStyle != AndroidTabBarStyle.FLOATING) {
486
+ return bottomNavigation.height
487
+ }
488
+ val params = floatingBar.layoutParams as? FrameLayout.LayoutParams
489
+ return floatingBar.height + (params?.bottomMargin ?: 0)
490
+ }
491
+
492
+ private fun blendColor(base: Int, overlay: Int, amount: Float): Int {
493
+ val inverse = 1f - amount
494
+ return Color.argb(
495
+ Color.alpha(base),
496
+ (Color.red(base) * inverse + Color.red(overlay) * amount).roundToInt(),
497
+ (Color.green(base) * inverse + Color.green(overlay) * amount).roundToInt(),
498
+ (Color.blue(base) * inverse + Color.blue(overlay) * amount).roundToInt(),
499
+ )
500
+ }
501
+
502
+ private fun withAlpha(color: Int, alpha: Float): Int =
503
+ Color.argb((alpha * 255f).roundToInt(), Color.red(color), Color.green(color), Color.blue(color))
504
+
505
+ private fun isDarkColor(color: Int): Boolean {
506
+ val luminance =
507
+ (Color.red(color) * 0.299f) +
508
+ (Color.green(color) * 0.587f) +
509
+ (Color.blue(color) * 0.114f)
510
+ return luminance < 128f
511
+ }
512
+
513
+ private fun resolvedBarTintColor(): Int =
514
+ barTintColor
515
+ ?: Utils.getDefaultColorFor(context, android.R.attr.colorBackground)
516
+ ?: Color.BLACK
517
+
518
+ private fun applyDefaultBarBackground() {
519
+ val color = resolvedBarTintColor()
520
+ bottomNavigation.itemBackground = ColorDrawable(color)
521
+ bottomNavigation.backgroundTintList = ColorStateList.valueOf(color)
522
+ }
523
+
524
+ private fun updateFloatingAppearance() {
525
+ if (androidTabBarStyle != AndroidTabBarStyle.FLOATING) {
526
+ return
527
+ }
528
+ // With a live backdrop, keep only a light surface tint over the blur.
529
+ // The existing rim, shadow, and pressed lens keep their own sharp layers.
530
+ val baseColor = resolvedBarTintColor()
531
+ val isDark = isDarkColor(baseColor)
532
+ val contrastColor = if (isDark) Color.WHITE else Color.BLACK
533
+ val surfaceColor = if (isDark) blendColor(baseColor, Color.WHITE, 0.08f) else baseColor
534
+ floatingBarBackground.background = GradientDrawable().apply {
535
+ shape = GradientDrawable.RECTANGLE
536
+ cornerRadius = dp(FloatingTabBarMetrics.BAR_HEIGHT / 2f).toFloat()
537
+ setColor(withAlpha(surfaceColor, if (usesBottomBlur) 0.12f else 0.95f))
538
+ setStroke(maxOf(1, dp(0.5f)), withAlpha(contrastColor, if (isDark) 0.14f else 0.07f))
539
+ }
540
+ floatingRestingFillColor = activeIndicatorColor?.defaultColor
541
+ ?: withAlpha(contrastColor, if (isDark) 0.14f else 0.07f)
542
+ // The lens reads as clear glass: a light fill that brightens what it covers,
543
+ // outlined by a rim where it extends past the bar.
544
+ floatingLensFillColor = withAlpha(Color.WHITE, if (isDark) 0.18f else 0.55f)
545
+ floatingLensStrokeColor = withAlpha(contrastColor, if (isDark) 0.3f else 0.1f)
546
+ applyFloatingIndicatorState()
547
+ }
548
+
549
+ private fun applyFloatingIndicatorState() {
550
+ val colorFraction = floatingLift.coerceIn(0f, 1f)
551
+ floatingIndicatorDrawable.cornerRadius = dp(
552
+ (FloatingTabBarMetrics.BAR_HEIGHT - FloatingTabBarMetrics.INDICATOR_INSET * 2) / 2f
553
+ ).toFloat()
554
+ floatingIndicatorDrawable.setColor(
555
+ ColorUtils.blendARGB(floatingRestingFillColor, floatingLensFillColor, colorFraction)
556
+ )
557
+ floatingIndicatorDrawable.setStroke(
558
+ dp(1f),
559
+ ColorUtils.blendARGB(
560
+ withAlpha(floatingLensStrokeColor, 0f),
561
+ floatingLensStrokeColor,
562
+ colorFraction,
563
+ ),
564
+ )
565
+ floatingIndicator.scaleX =
566
+ 1f + (FloatingTabBarMetrics.LENS_SCALE_X - 1f) * floatingLift + floatingStretch
567
+ floatingIndicator.scaleY =
568
+ 1f + (FloatingTabBarMetrics.LENS_SCALE_Y - 1f) * floatingLift - floatingStretch * 0.4f
569
+ }
570
+
571
+ private fun animateFloatingLift(target: Float) {
572
+ floatingLiftAnimator?.cancel()
573
+ val start = floatingLift
574
+ if (start == target) {
575
+ applyFloatingIndicatorState()
576
+ return
577
+ }
578
+ val lifting = target > start
579
+ floatingLiftAnimator = ValueAnimator.ofFloat(start, target).apply {
580
+ duration = if (lifting) 320L else 460L
581
+ interpolator = SpringInterpolator(dampingRatio = if (lifting) 0.6f else 0.72f)
582
+ addUpdateListener {
583
+ floatingLift = it.animatedValue as Float
584
+ applyFloatingIndicatorState()
585
+ }
586
+ start()
587
+ }
588
+ }
589
+
590
+ private fun animateFloatingIndicatorTo(targetX: Float, durationMs: Long = 440L) {
591
+ floatingSlideAnimator?.cancel()
592
+ val startX = floatingIndicator.x
593
+ if (abs(targetX - startX) < 0.5f) {
594
+ floatingIndicator.x = targetX
595
+ floatingStretch = 0f
596
+ applyFloatingIndicatorState()
597
+ return
598
+ }
599
+ var previousX = startX
600
+ floatingSlideAnimator = ValueAnimator.ofFloat(startX, targetX).apply {
601
+ duration = durationMs
602
+ interpolator = SpringInterpolator(dampingRatio = 0.74f)
603
+ addUpdateListener {
604
+ val x = it.animatedValue as Float
605
+ val frameDelta = abs(x - previousX)
606
+ previousX = x
607
+ floatingIndicator.x = x
608
+ // The capsule stretches along the travel direction, like a liquid drop.
609
+ val rawStretch = (frameDelta / floatingIndicator.width.coerceAtLeast(1) * 1.6f)
610
+ .coerceAtMost(FloatingTabBarMetrics.LENS_MAX_STRETCH)
611
+ floatingStretch = floatingStretch * 0.5f + rawStretch * 0.5f
612
+ applyFloatingIndicatorState()
613
+ }
614
+ doOnEnd {
615
+ floatingStretch = 0f
616
+ applyFloatingIndicatorState()
617
+ }
618
+ start()
619
+ }
620
+ }
621
+
622
+ private fun updateFloatingIndicator(animate: Boolean) {
623
+ if (
624
+ androidTabBarStyle != AndroidTabBarStyle.FLOATING ||
625
+ isTabBarHidden ||
626
+ floatingTouchItemId != View.NO_ID
627
+ ) {
628
+ return
629
+ }
630
+ val selectedKey = selectedItem ?: return
631
+ val selectedIndex = items.indexOfFirst { it.key == selectedKey }
632
+ if (selectedIndex == -1) {
633
+ return
634
+ }
635
+ val itemView = bottomNavigation.findViewById<View>(selectedIndex) ?: return
636
+ if (itemView.width == 0 || floatingBar.width == 0) {
637
+ floatingIndicator.visibility = INVISIBLE
638
+ return
639
+ }
640
+
641
+ updateFloatingIndicatorWidth(itemView.width)
642
+ val targetX = getFloatingItemX(selectedIndex) ?: return
643
+ floatingIndicator.visibility = VISIBLE
644
+
645
+ if (!animate || !floatingIndicatorPositioned) {
646
+ floatingSlideAnimator?.cancel()
647
+ floatingIndicator.x = targetX
648
+ floatingStretch = 0f
649
+ floatingIndicatorPositioned = true
650
+ applyFloatingIndicatorState()
651
+ return
652
+ }
653
+ animateFloatingIndicatorTo(targetX)
654
+ }
655
+
656
+ private fun updateFloatingIndicatorWidth(width: Int) {
657
+ val params = floatingIndicator.layoutParams as? FrameLayout.LayoutParams ?: return
658
+ if (params.width != width) {
659
+ params.width = width
660
+ floatingIndicator.layoutParams = params
661
+ }
662
+ }
663
+
664
+ private fun getFloatingItemX(itemId: Int): Float? {
665
+ val menuView = bottomNavigation.getChildAt(0) as? ViewGroup ?: return null
666
+ val itemView = bottomNavigation.findViewById<View>(itemId) ?: return null
667
+ if (itemView.width == 0 || itemView.visibility != VISIBLE) {
668
+ return null
669
+ }
670
+ return bottomNavigation.x + menuView.x + itemView.x
671
+ }
672
+
673
+ private fun getNearestVisibleItemId(centerX: Float): Int? {
674
+ val menuView = bottomNavigation.getChildAt(0) as? ViewGroup ?: return null
675
+ var nearestItemId: Int? = null
676
+ var nearestDistance = Float.MAX_VALUE
677
+ items.indices.forEach { itemId ->
678
+ val itemView = bottomNavigation.findViewById<View>(itemId) ?: return@forEach
679
+ if (itemView.visibility != VISIBLE || itemView.width == 0) {
680
+ return@forEach
681
+ }
682
+ val itemCenterX = bottomNavigation.x + menuView.x + itemView.x + itemView.width / 2f
683
+ val distance = abs(itemCenterX - centerX)
684
+ if (distance < nearestDistance) {
685
+ nearestDistance = distance
686
+ nearestItemId = itemId
687
+ }
688
+ }
689
+ return nearestItemId
690
+ }
691
+
692
+ private fun setFloatingItemMagnified(magnifiedItemId: Int) {
693
+ items.indices.forEach { itemId ->
694
+ val itemView = bottomNavigation.findViewById<View>(itemId) ?: return@forEach
695
+ val targetScale = if (itemId == magnifiedItemId) {
696
+ FloatingTabBarMetrics.ITEM_PRESSED_SCALE
697
+ } else {
698
+ 1f
699
+ }
700
+ if (itemView.scaleX == targetScale && itemView.scaleY == targetScale) {
701
+ return@forEach
702
+ }
703
+ itemView.animate()
704
+ .scaleX(targetScale)
705
+ .scaleY(targetScale)
706
+ .setDuration(260L)
707
+ .setInterpolator(SpringInterpolator(dampingRatio = 0.64f))
708
+ .start()
709
+ }
710
+ }
711
+
712
+ // Mirrors the iOS 26 tab bar: touching any tab lifts a glass lens onto it,
713
+ // dragging scrubs the lens across tabs, and release selects the tab under it.
714
+ @SuppressLint("ClickableViewAccessibility")
715
+ private fun handleFloatingTabTouch(view: View, item: MenuItem, event: MotionEvent): Boolean {
716
+ if (androidTabBarStyle != AndroidTabBarStyle.FLOATING) {
717
+ return false
718
+ }
719
+
720
+ when (event.actionMasked) {
721
+ MotionEvent.ACTION_DOWN -> {
722
+ if (isTabBarHidden || floatingBar.width == 0) {
723
+ return false
724
+ }
725
+ clearFloatingTouchTracking()
726
+ floatingTouchView = view
727
+ floatingTouchItemId = item.itemId
728
+ floatingCandidateItemId = item.itemId
729
+ floatingTouchStartRawX = event.rawX
730
+ floatingTouchStartRawY = event.rawY
731
+ floatingLastMoveRawX = event.rawX
732
+ floatingLastMoveTime = event.eventTime
733
+ view.parent?.requestDisallowInterceptTouchEvent(true)
734
+ beginFloatingPress(item.itemId)
735
+ return true
736
+ }
737
+ MotionEvent.ACTION_MOVE -> {
738
+ if (floatingTouchView !== view) {
739
+ return true
740
+ }
741
+ if (!floatingDragging) {
742
+ val deltaX = event.rawX - floatingTouchStartRawX
743
+ val deltaY = event.rawY - floatingTouchStartRawY
744
+ if (deltaX * deltaX + deltaY * deltaY <= floatingTouchSlop * floatingTouchSlop) {
745
+ return true
746
+ }
747
+ floatingDragging = true
748
+ }
749
+ updateFloatingDrag(event.rawX, event.eventTime)
750
+ return true
751
+ }
752
+ MotionEvent.ACTION_UP -> {
753
+ if (floatingTouchView !== view) {
754
+ return true
755
+ }
756
+ val deltaX = event.rawX - floatingTouchStartRawX
757
+ val deltaY = event.rawY - floatingTouchStartRawY
758
+ if (deltaX * deltaX + deltaY * deltaY > floatingTouchSlop * floatingTouchSlop) {
759
+ floatingDragging = true
760
+ }
761
+ if (floatingDragging) {
762
+ updateFloatingDrag(event.rawX, event.eventTime)
763
+ }
764
+ finishFloatingPress(view, cancelled = false)
765
+ return true
766
+ }
767
+ MotionEvent.ACTION_CANCEL -> {
768
+ if (floatingTouchView === view) {
769
+ finishFloatingPress(view, cancelled = true)
770
+ }
771
+ return true
772
+ }
773
+ }
774
+ return true
775
+ }
776
+
777
+ private fun beginFloatingPress(itemId: Int) {
778
+ val itemView = bottomNavigation.findViewById<View>(itemId) ?: return
779
+ val targetX = getFloatingItemX(itemId) ?: return
780
+ updateFloatingIndicatorWidth(itemView.width)
781
+ floatingIndicator.visibility = VISIBLE
782
+ if (floatingIndicatorPositioned) {
783
+ animateFloatingIndicatorTo(targetX, durationMs = 320L)
784
+ } else {
785
+ floatingIndicator.x = targetX
786
+ floatingIndicatorPositioned = true
787
+ }
788
+ animateFloatingLift(1f)
789
+ setFloatingItemMagnified(itemId)
790
+ }
791
+
792
+ private fun updateFloatingDrag(rawX: Float, eventTime: Long) {
793
+ if (floatingBar.width == 0 || floatingIndicator.width == 0) {
794
+ return
795
+ }
796
+
797
+ floatingBar.getLocationOnScreen(floatingBarLocationOnScreen)
798
+ val halfWidth = floatingIndicator.width / 2f
799
+ val contentPadding = dp(FloatingTabBarMetrics.BAR_CONTENT_PADDING).toFloat()
800
+ val minCenter = contentPadding + halfWidth
801
+ val maxCenter = floatingBar.width - contentPadding - halfWidth
802
+ val fingerCenter = rawX - floatingBarLocationOnScreen[0]
803
+ // Past either end the lens follows with resistance instead of stopping hard.
804
+ val centerX = when {
805
+ fingerCenter < minCenter ->
806
+ minCenter - (minCenter - fingerCenter) * FloatingTabBarMetrics.DRAG_EDGE_RESISTANCE
807
+ fingerCenter > maxCenter ->
808
+ maxCenter + (fingerCenter - maxCenter) * FloatingTabBarMetrics.DRAG_EDGE_RESISTANCE
809
+ else -> fingerCenter
810
+ }
811
+
812
+ val elapsed = (eventTime - floatingLastMoveTime).coerceAtLeast(1L)
813
+ val velocityDpPerMs = abs(rawX - floatingLastMoveRawX) / elapsed / resources.displayMetrics.density
814
+ floatingLastMoveRawX = rawX
815
+ floatingLastMoveTime = eventTime
816
+ val targetStretch = (velocityDpPerMs * 0.06f).coerceAtMost(FloatingTabBarMetrics.LENS_MAX_STRETCH)
817
+ floatingStretch = floatingStretch * 0.6f + targetStretch * 0.4f
818
+
819
+ floatingSlideAnimator?.cancel()
820
+ floatingIndicator.x = centerX - halfWidth
821
+ applyFloatingIndicatorState()
822
+ floatingBar.removeCallbacks(floatingStretchDecay)
823
+ floatingBar.postDelayed(floatingStretchDecay, 64L)
824
+
825
+ getNearestVisibleItemId(centerX)?.let { itemId ->
826
+ if (itemId != floatingCandidateItemId) {
827
+ floatingCandidateItemId = itemId
828
+ setFloatingItemMagnified(itemId)
829
+ emitHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
830
+ }
831
+ }
832
+ }
833
+
834
+ private val floatingStretchDecay = Runnable {
835
+ if (floatingTouchItemId == View.NO_ID || floatingStretch == 0f) {
836
+ return@Runnable
837
+ }
838
+ ValueAnimator.ofFloat(floatingStretch, 0f).apply {
839
+ duration = 180L
840
+ interpolator = DecelerateInterpolator()
841
+ addUpdateListener {
842
+ if (floatingTouchItemId != View.NO_ID) {
843
+ floatingStretch = it.animatedValue as Float
844
+ applyFloatingIndicatorState()
845
+ }
846
+ }
847
+ start()
848
+ }
849
+ }
850
+
851
+ private fun finishFloatingPress(view: View, cancelled: Boolean) {
852
+ val requestedItemId = if (floatingDragging) floatingCandidateItemId else floatingTouchItemId
853
+ val wasDragging = floatingDragging
854
+ clearFloatingTouchTracking()
855
+ setFloatingItemMagnified(View.NO_ID)
856
+ animateFloatingLift(0f)
857
+
858
+ if (!cancelled && requestedItemId != View.NO_ID) {
859
+ if (!wasDragging) {
860
+ view.performClick()
861
+ } else if (requestedItemId != bottomNavigation.selectedItemId) {
862
+ bottomNavigation.menu.findItem(requestedItemId)?.let { menuItem ->
863
+ onTabSelected(menuItem)
864
+ }
865
+ }
866
+ }
867
+ // Settles on the selected tab, which is the old one for cancelled or
868
+ // preventsDefault presses.
869
+ updateFloatingIndicator(animate = true)
870
+ }
871
+
872
+ private fun clearFloatingTouchTracking() {
873
+ floatingBar.removeCallbacks(floatingStretchDecay)
874
+ floatingTouchView = null
875
+ floatingTouchItemId = View.NO_ID
876
+ floatingCandidateItemId = View.NO_ID
877
+ floatingDragging = false
878
+ floatingTouchStartRawX = 0f
879
+ floatingTouchStartRawY = 0f
880
+ }
881
+
882
+ private fun resetFloatingGesture() {
883
+ clearFloatingTouchTracking()
884
+ floatingLiftAnimator?.cancel()
885
+ floatingSlideAnimator?.cancel()
886
+ floatingLift = 0f
887
+ floatingStretch = 0f
888
+ items.indices.forEach { itemId ->
889
+ bottomNavigation.findViewById<View>(itemId)?.apply {
890
+ animate().cancel()
891
+ scaleX = 1f
892
+ scaleY = 1f
893
+ }
894
+ }
895
+ applyFloatingIndicatorState()
896
+ }
897
+
898
+ private val layoutCallback = Choreographer.FrameCallback {
899
+ isLayoutEnqueued = false
900
+ refreshLayout()
901
+ }
902
+
903
+ private fun refreshLayout() {
904
+ measure(
905
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
906
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
907
+ )
908
+ layout(left, top, right, bottom)
909
+ }
910
+
911
+ fun setAndroidBottomBlurEnabled(enabled: Boolean) {
912
+ if (androidBottomBlurEnabled == enabled) return
913
+ androidBottomBlurEnabled = enabled
914
+ updateBlurAppearance()
915
+ requestLayout()
916
+ floatingRoot.invalidate()
917
+ invalidate()
918
+ }
919
+
920
+ private fun updateBlurAppearance() {
921
+ if (!usesBottomBlur) releaseBottomBlur()
922
+ val enabled = usesBottomBlur && androidTabBarStyle == AndroidTabBarStyle.DEFAULT
923
+ if (androidTabBarStyle == AndroidTabBarStyle.FLOATING) {
924
+ blurSurfaceColor = resolvedBarTintColor()
925
+ if (floatingBlurAppearanceApplied != usesBottomBlur) {
926
+ floatingBlurAppearanceApplied = usesBottomBlur
927
+ updateFloatingAppearance()
928
+ }
929
+ } else if (enabled) {
930
+ blurSurfaceColor = barTintColor ?: Utils.getDefaultColorFor(
931
+ bottomNavigation.context, com.google.android.material.R.attr.colorSurface,
932
+ ) ?: if (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) {
933
+ Color.BLACK
934
+ } else Color.WHITE
935
+ }
936
+ if (enabled == blurAppearanceApplied) return
937
+ if (enabled) {
938
+ savedBarTint = bottomNavigation.backgroundTintList
939
+ savedItemBackground = bottomNavigation.itemBackground
940
+ savedBarElevation = bottomNavigation.elevation
941
+ val ripple = bottomNavigation.itemRippleColor
942
+ bottomNavigation.backgroundTintList = ColorStateList.valueOf(Color.TRANSPARENT)
943
+ bottomNavigation.itemBackground = null
944
+ bottomNavigation.itemRippleColor = ripple
945
+ bottomNavigation.elevation = 0f
946
+ } else {
947
+ bottomNavigation.backgroundTintList = savedBarTint
948
+ bottomNavigation.itemBackground = savedItemBackground
949
+ bottomNavigation.elevation = savedBarElevation
950
+ releaseBottomBlur()
951
+ }
952
+ blurAppearanceApplied = enabled
953
+ }
954
+
955
+ private fun releaseBottomBlur() {
956
+ if (Build.VERSION.SDK_INT >= 31) bottomBarBlur?.release()
957
+ bottomBarBlur = null
958
+ }
959
+
960
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
961
+ updateBlurAppearance()
962
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
963
+ if (usesBottomBlur && androidTabBarStyle == AndroidTabBarStyle.DEFAULT) {
964
+ layoutHolder.measure(
965
+ MeasureSpec.makeMeasureSpec(maxOf(0, measuredWidth - paddingLeft - paddingRight), MeasureSpec.EXACTLY),
966
+ MeasureSpec.makeMeasureSpec(maxOf(0, measuredHeight - paddingTop - paddingBottom), MeasureSpec.EXACTLY),
967
+ )
968
+ }
969
+ }
970
+
971
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
972
+ super.onLayout(changed, left, top, right, bottom)
973
+ if (usesBottomBlur && androidTabBarStyle == AndroidTabBarStyle.DEFAULT) {
974
+ layoutHolder.layout(paddingLeft, paddingTop, width - paddingRight, height - paddingBottom)
975
+ bottomNavigation.layout(
976
+ paddingLeft, height - paddingBottom - bottomNavigation.measuredHeight,
977
+ width - paddingRight, height - paddingBottom,
978
+ )
979
+ }
980
+ val barHeight = Utils.convertPixelsToDp(context, getTabBarMeasuredHeight()).toInt()
981
+ if (barHeight != lastReportedTabBarHeight) {
982
+ onTabBarMeasuredListener?.invoke(barHeight)
983
+ lastReportedTabBarHeight = barHeight
984
+ }
985
+ // Scene bounds change when blur toggles even if the host bounds stay fixed.
986
+ val sceneSize = Size(layoutHolder.width, layoutHolder.height)
987
+ if (sceneSize != lastReportedSize) {
988
+ onNativeLayoutListener?.invoke(
989
+ Utils.convertPixelsToDp(context, sceneSize.width),
990
+ Utils.convertPixelsToDp(context, sceneSize.height),
991
+ )
992
+ lastReportedSize = sceneSize
993
+ }
994
+ }
995
+
996
+ private fun drawBlurBackdrop(
997
+ canvas: Canvas, width: Int, barTop: Int, bottom: Int, drawScenes: (Canvas) -> Unit,
998
+ ) {
999
+ if (!usesBottomBlur || !canvas.isHardwareAccelerated || Build.VERSION.SDK_INT < 31) return
1000
+ val blur = bottomBarBlur ?: BottomBarBlurRenderer(resources.displayMetrics.density).also {
1001
+ bottomBarBlur = it
1002
+ }
1003
+ blur.draw(canvas, width, barTop, bottom, blurSurfaceColor, drawScenes)
1004
+ }
1005
+
1006
+ override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
1007
+ if (child === bottomNavigation && androidTabBarStyle == AndroidTabBarStyle.DEFAULT) {
1008
+ drawBlurBackdrop(canvas, width, bottomNavigation.top, bottomNavigation.bottom) { recording ->
1009
+ super.drawChild(recording, layoutHolder, drawingTime)
1010
+ }
1011
+ }
1012
+ return super.drawChild(canvas, child, drawingTime)
1013
+ }
1014
+
1015
+ override fun onAttachedToWindow() {
1016
+ super.onAttachedToWindow()
1017
+ requestLayout()
1018
+ }
1019
+
1020
+ override fun onDetachedFromWindow() {
1021
+ releaseBottomBlur()
1022
+ super.onDetachedFromWindow()
1023
+ }
1024
+
1025
+ override fun requestLayout() {
1026
+ super.requestLayout()
1027
+ @Suppress("SENSELESS_COMPARISON") // layoutCallback can be null here since this method can be called in init
1028
+
1029
+ if (!isLayoutEnqueued && layoutCallback != null) {
1030
+ isLayoutEnqueued = true
1031
+ // we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current
1032
+ // looper loop instead of enqueueing the update in the next loop causing a one frame delay.
1033
+ ReactChoreographer
1034
+ .getInstance()
1035
+ .postFrameCallback(
1036
+ ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE,
1037
+ layoutCallback,
1038
+ )
1039
+ }
1040
+ }
1041
+
1042
+ fun setSelectedItem(value: String) {
1043
+ selectedItem = value
1044
+ syncSelectedItem()
1045
+ }
1046
+
1047
+ private fun syncSelectedItem() {
1048
+ val selectedKey = selectedItem ?: return
1049
+ val selectedIndex = items.indexOfFirst { it.key == selectedKey }
1050
+ if (selectedIndex == -1 || selectedIndex >= layoutHolder.childCount) {
1051
+ return
1052
+ }
1053
+ setSelectedIndex(selectedIndex)
1054
+ }
1055
+
1056
+ override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams?) {
1057
+ if (child === layoutHolder || child === bottomNavigation || child === floatingRoot) {
1058
+ super.addView(child, index, params)
1059
+ return
1060
+ }
1061
+
1062
+ val container = createContainer()
1063
+ container.addView(child, params)
1064
+ layoutHolder.addView(container, index)
1065
+
1066
+ if (index in items.indices && selectedItem == items[index].key) {
1067
+ syncSelectedItem()
1068
+ refreshLayout()
1069
+ }
1070
+ }
1071
+
1072
+ private fun createContainer(): FrameLayout {
1073
+ val container = TabSceneContainer(context).apply {
1074
+ layoutParams = FrameLayout.LayoutParams(
1075
+ FrameLayout.LayoutParams.MATCH_PARENT,
1076
+ FrameLayout.LayoutParams.MATCH_PARENT
1077
+ )
1078
+ isSaveEnabled = false
1079
+ visibility = if (disablePageAnimations) INVISIBLE else GONE
1080
+ isEnabled = false
1081
+ }
1082
+ return container
1083
+ }
1084
+
1085
+ private fun setSelectedIndex(itemId: Int) {
1086
+ val selectionChanged = bottomNavigation.selectedItemId != itemId
1087
+ bottomNavigation.selectedItemId = itemId
1088
+ if (androidTabBarStyle == AndroidTabBarStyle.FLOATING) {
1089
+ post { updateFloatingIndicator(animate = selectionChanged) }
1090
+ }
1091
+ if (disablePageAnimations) {
1092
+ // INVISIBLE scenes stay laid out, so this visibility swap is atomic at
1093
+ // the next draw boundary and does not expose an empty container frame.
1094
+ toggleViewVisibility(layoutHolder.getChildAt(itemId), true)
1095
+ layoutHolder.forEachIndexed { index, view ->
1096
+ if (itemId != index) {
1097
+ toggleViewVisibility(view, false)
1098
+ }
1099
+ }
1100
+ layoutHolder.invalidate()
1101
+ return
1102
+ }
1103
+ if (layoutHolder.getChildAt(itemId)?.visibility == VISIBLE) {
1104
+ return
1105
+ }
1106
+ val fadeThrough = MaterialFadeThrough()
1107
+ TransitionManager.beginDelayedTransition(layoutHolder, fadeThrough)
1108
+ // Apply every visibility change to the transition's captured frame.
1109
+ layoutHolder.forEachIndexed { index, view ->
1110
+ if (itemId == index) {
1111
+ toggleViewVisibility(view, true)
1112
+ } else {
1113
+ toggleViewVisibility(view, false)
1114
+ }
1115
+ }
1116
+
1117
+ layoutHolder.requestLayout()
1118
+ layoutHolder.invalidate()
1119
+ }
1120
+
1121
+ private fun toggleViewVisibility(view: View, isVisible: Boolean) {
1122
+ check(view is TabSceneContainer) { "Native component tree is corrupted." }
1123
+
1124
+ view.pointerEvents = if (isVisible) PointerEvents.AUTO else PointerEvents.NONE
1125
+ view.visibility = if (isVisible) {
1126
+ VISIBLE
1127
+ } else if (disablePageAnimations) {
1128
+ INVISIBLE
1129
+ } else {
1130
+ GONE
1131
+ }
1132
+ view.isEnabled = isVisible
1133
+ }
1134
+
1135
+ private fun onTabSelected(item: MenuItem, emitHaptic: Boolean = true) {
1136
+ val selectedItem = items[item.itemId]
1137
+ selectedItem.let {
1138
+ if (!selectedItem.preventsDefault) {
1139
+ setSelectedItem(selectedItem.key)
1140
+ }
1141
+ onTabSelectedListener?.invoke(selectedItem.key)
1142
+ if (emitHaptic) {
1143
+ emitHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
1144
+ }
1145
+ emitDoublePressIfNeeded(item.itemId, selectedItem.key)
1146
+ }
1147
+ }
1148
+
1149
+ private fun emitDoublePressIfNeeded(itemId: Int, key: String) {
1150
+ val now = SystemClock.uptimeMillis()
1151
+ if (
1152
+ lastPressedItemId == itemId &&
1153
+ now - lastPressedTime <= ViewConfiguration.getDoubleTapTimeout()
1154
+ ) {
1155
+ lastPressedItemId = View.NO_ID
1156
+ onTabDoublePressedListener?.invoke(key)
1157
+ return
1158
+ }
1159
+ lastPressedItemId = itemId
1160
+ lastPressedTime = now
1161
+ }
1162
+
1163
+ fun setIgnoreBottomInsets(ignore: Boolean) {
1164
+ ignoreBottomInsets = ignore
1165
+ bottomNavigation.setIgnoreBottomInsets(
1166
+ if (androidTabBarStyle == AndroidTabBarStyle.FLOATING) true else ignore
1167
+ )
1168
+ ViewCompat.requestApplyInsets(this)
1169
+ }
1170
+
1171
+ fun setAndroidTabBarStyle(value: String?) {
1172
+ val nextStyle = if (value == "floating") {
1173
+ AndroidTabBarStyle.FLOATING
1174
+ } else {
1175
+ AndroidTabBarStyle.DEFAULT
1176
+ }
1177
+ if (nextStyle == androidTabBarStyle) {
1178
+ return
1179
+ }
1180
+ androidTabBarStyle = nextStyle
1181
+ // Restore the docked appearance before reparenting, then capture the new
1182
+ // style only after its normal appearance has been installed.
1183
+ if (blurAppearanceApplied) updateBlurAppearance()
1184
+ if (nextStyle == AndroidTabBarStyle.FLOATING) {
1185
+ attachFloatingLayout()
1186
+ } else {
1187
+ attachDefaultLayout()
1188
+ }
1189
+ updateBlurAppearance()
1190
+ updateTextAppearance()
1191
+ requestLayout()
1192
+ }
1193
+
1194
+ fun setIgnoreKeyboardInsets(ignore: Boolean) {
1195
+ ignoreKeyboardInsets = ignore
1196
+ bottomNavigation.setIgnoreKeyboardInsets(
1197
+ if (androidTabBarStyle == AndroidTabBarStyle.FLOATING) true else ignore
1198
+ )
1199
+ ViewCompat.requestApplyInsets(this)
1200
+ }
1201
+
1202
+ fun setTabBarHidden(isHidden: Boolean) {
1203
+ isTabBarHidden = isHidden
1204
+ if (isHidden) {
1205
+ resetFloatingGesture()
1206
+ }
1207
+ if (androidTabBarStyle == AndroidTabBarStyle.FLOATING) {
1208
+ floatingBar.visibility = if (isHidden) GONE else VISIBLE
1209
+ bottomNavigation.visibility = VISIBLE
1210
+ } else {
1211
+ bottomNavigation.visibility = if (isHidden) GONE else VISIBLE
1212
+ }
1213
+
1214
+ updateBlurAppearance()
1215
+ requestLayout()
1216
+ floatingRoot.invalidate()
1217
+ invalidate()
1218
+ if (!isHidden) post { updateFloatingIndicator(animate = false) }
1219
+
1220
+ }
1221
+
1222
+ fun updateItems(items: MutableList<TabInfo>) {
1223
+ // If an item got removed, let's re-add all items
1224
+ if (items.size < this.items.size) {
1225
+ bottomNavigation.menu.clear()
1226
+ }
1227
+ this.items = items
1228
+ items.forEachIndexed { index, item ->
1229
+ val menuItem = getOrCreateItem(index, item.title)
1230
+ if (item.title != menuItem.title) {
1231
+ menuItem.title = item.title
1232
+ }
1233
+
1234
+ menuItem.isVisible = !item.hidden
1235
+ if (iconSources.containsKey(index)) {
1236
+ getDrawable(iconSources[index]!!) {
1237
+ menuItem.icon = it
1238
+ }
1239
+ }
1240
+
1241
+ if (item.badge?.isNotEmpty() == true) {
1242
+ val badge = bottomNavigation.getOrCreateBadge(index)
1243
+ badge.isVisible = true
1244
+ // Set the badge text only if it's different than an empty space to show a small badge.
1245
+ // More context: https://github.com/callstackincubator/react-native-bottom-tabs/issues/422
1246
+ if (item.badge != " ") {
1247
+ badge.text = item.badge
1248
+ }
1249
+ // Apply badge colors if provided (Material will use its default theme colors otherwise)
1250
+ item.badgeBackgroundColor?.let { badge.backgroundColor = it }
1251
+ item.badgeTextColor?.let { badge.badgeTextColor = it }
1252
+ } else {
1253
+ bottomNavigation.removeBadge(index)
1254
+ }
1255
+ post {
1256
+ val itemView = bottomNavigation.findViewById<View>(menuItem.itemId)
1257
+ itemView?.let { view ->
1258
+ view.setOnClickListener {
1259
+ onTabSelected(menuItem)
1260
+ }
1261
+ view.setOnTouchListener { touchedView, event ->
1262
+ handleFloatingTabTouch(touchedView, menuItem, event)
1263
+ }
1264
+
1265
+ item.testID?.let { testId ->
1266
+ view.findViewById<View>(com.google.android.material.R.id.navigation_bar_item_content_container)
1267
+ ?.apply {
1268
+ tag = testId
1269
+ }
1270
+ }
1271
+ }
1272
+ }
1273
+ }
1274
+
1275
+ syncSelectedItem()
1276
+
1277
+ // Update tint colors and text appearance after updating all items.
1278
+ post {
1279
+ updateTextAppearance()
1280
+ updateTintColors()
1281
+ updateFloatingIndicator(animate = false)
1282
+ }
1283
+ }
1284
+
1285
+ private fun getOrCreateItem(index: Int, title: String): MenuItem {
1286
+ return bottomNavigation.menu.findItem(index) ?: bottomNavigation.menu.add(0, index, 0, title)
1287
+ }
1288
+
1289
+ fun setIcons(icons: ReadableArray?) {
1290
+ if (icons == null || icons.size() == 0) {
1291
+ return
1292
+ }
1293
+
1294
+ for (idx in 0 until icons.size()) {
1295
+ val source = icons.getMap(idx)
1296
+ val uri = source?.getString("uri")
1297
+ if (uri.isNullOrEmpty()) {
1298
+ continue
1299
+ }
1300
+
1301
+ val imageSource = ImageSource(context, uri)
1302
+ this.iconSources[idx] = imageSource
1303
+
1304
+ // Update existing item if exists.
1305
+ bottomNavigation.menu.findItem(idx)?.let { menuItem ->
1306
+ getDrawable(imageSource) {
1307
+ menuItem.icon = it
1308
+ }
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ fun setLabeled(labeled: Boolean?) {
1314
+ this.labeled = labeled
1315
+ bottomNavigation.labelVisibilityMode = when (labeled) {
1316
+ false -> {
1317
+ LABEL_VISIBILITY_UNLABELED
1318
+ }
1319
+ true -> {
1320
+ LABEL_VISIBILITY_LABELED
1321
+ }
1322
+ else -> {
1323
+ LABEL_VISIBILITY_AUTO
1324
+ }
1325
+ }
1326
+ }
1327
+
1328
+ fun setRippleColor(color: ColorStateList) {
1329
+ rippleColor = color
1330
+ // Floating mode replaces the ripple with the pressed lens.
1331
+ if (androidTabBarStyle != AndroidTabBarStyle.FLOATING) {
1332
+ bottomNavigation.itemRippleColor = color
1333
+ if (blurAppearanceApplied) savedItemBackground = bottomNavigation.itemBackground
1334
+ }
1335
+ }
1336
+
1337
+ @SuppressLint("CheckResult")
1338
+ private fun getDrawable(imageSource: ImageSource, onDrawableReady: (Drawable?) -> Unit) {
1339
+ drawableCache[imageSource]?.let {
1340
+ onDrawableReady(it)
1341
+ return
1342
+ }
1343
+ val iconSizePx = bottomNavigation.itemIconSize
1344
+ val request = ImageRequest.Builder(context)
1345
+ .data(imageSource.getUri(context))
1346
+ .size(CoilSize(iconSizePx, iconSizePx))
1347
+ .scale(Scale.FILL)
1348
+ .precision(Precision.EXACT)
1349
+ .target { drawable ->
1350
+ post {
1351
+ val stateDrawable = drawable.asDrawable(context.resources)
1352
+ drawableCache[imageSource] = stateDrawable
1353
+ onDrawableReady(stateDrawable)
1354
+ }
1355
+ }
1356
+ .listener(
1357
+ onError = { _, result ->
1358
+ Log.e("BottomTabs", "Error loading image: ${imageSource.uri}", result.throwable)
1359
+ }
1360
+ )
1361
+ .build()
1362
+
1363
+ imageLoader.enqueue(request)
1364
+ }
1365
+
1366
+ fun setBarTintColor(color: Int?) {
1367
+ // Set the color, either using the active background color or a default color.
1368
+ val backgroundColor =
1369
+ color ?: Utils.getDefaultColorFor(context, android.R.attr.colorPrimary) ?: return
1370
+ barTintColor = backgroundColor
1371
+ blurSurfaceColor = backgroundColor
1372
+
1373
+ if (androidTabBarStyle == AndroidTabBarStyle.FLOATING) {
1374
+ updateFloatingAppearance()
1375
+ } else if (blurAppearanceApplied) {
1376
+ savedItemBackground = ColorDrawable(backgroundColor)
1377
+ savedBarTint = ColorStateList.valueOf(backgroundColor)
1378
+ } else {
1379
+ bottomNavigation.itemBackground = ColorDrawable(backgroundColor)
1380
+ bottomNavigation.backgroundTintList = ColorStateList.valueOf(backgroundColor)
1381
+ }
1382
+ hasCustomAppearance = true
1383
+ floatingRoot.invalidate()
1384
+ invalidate()
1385
+ }
1386
+
1387
+ fun setActiveTintColor(color: Int?) {
1388
+ activeTintColor = color
1389
+ updateTintColors()
1390
+ }
1391
+
1392
+ fun setInactiveTintColor(color: Int?) {
1393
+ inactiveTintColor = color
1394
+ updateTintColors()
1395
+ }
1396
+
1397
+ fun setActiveIndicatorColor(color: ColorStateList) {
1398
+ activeIndicatorColor = color
1399
+ if (androidTabBarStyle == AndroidTabBarStyle.FLOATING) {
1400
+ updateFloatingAppearance()
1401
+ } else {
1402
+ bottomNavigation.itemActiveIndicatorColor = color
1403
+ }
1404
+ }
1405
+
1406
+ fun setFontSize(size: Int) {
1407
+ fontSize = size
1408
+ updateTextAppearance()
1409
+ }
1410
+
1411
+ fun setFontFamily(family: String?) {
1412
+ fontFamily = family
1413
+ updateTextAppearance()
1414
+ }
1415
+
1416
+ fun setFontWeight(weight: String?) {
1417
+ val fontWeight = ReactTypefaceUtils.parseFontWeight(weight)
1418
+ this.fontWeight = fontWeight
1419
+ updateTextAppearance()
1420
+ }
1421
+
1422
+ fun onDropViewInstance() {
1423
+ androidBottomBlurEnabled = false
1424
+ updateBlurAppearance()
1425
+ releaseBottomBlur()
1426
+ resetFloatingGesture()
1427
+ imageLoader.shutdown()
1428
+ }
1429
+
1430
+ private fun updateTextAppearance() {
1431
+ val isFloating = androidTabBarStyle == AndroidTabBarStyle.FLOATING
1432
+ val customSize = fontSize?.toFloat()?.takeIf { it > 0 }
1433
+ // Floating labels use one compact size for both states, as on iOS. Material's
1434
+ // own sizes are remembered so leaving floating mode can restore them.
1435
+ val restoreLabelSizes = if (!isFloating && customSize == null) defaultLabelTextSizes else null
1436
+ if (
1437
+ !isFloating &&
1438
+ restoreLabelSizes == null &&
1439
+ fontSize == null &&
1440
+ fontFamily == null &&
1441
+ fontWeight == null
1442
+ ) {
1443
+ return
1444
+ }
1445
+
1446
+ val typeface = if (fontFamily != null || fontWeight != null) {
1447
+ ReactFontManager.getInstance().getTypeface(
1448
+ fontFamily ?: "",
1449
+ Utils.getTypefaceStyle(fontWeight),
1450
+ context.assets
1451
+ )
1452
+ } else null
1453
+ val size = customSize ?: if (isFloating) FloatingTabBarMetrics.LABEL_SIZE_SP else null
1454
+
1455
+ val menuView = bottomNavigation.getChildAt(0) as? ViewGroup ?: return
1456
+ for (i in 0 until menuView.childCount) {
1457
+ val item = menuView.getChildAt(i)
1458
+ val largeLabel =
1459
+ item.findViewById<TextView>(com.google.android.material.R.id.navigation_bar_item_large_label_view)
1460
+ val smallLabel =
1461
+ item.findViewById<TextView>(com.google.android.material.R.id.navigation_bar_item_small_label_view)
1462
+ if (isFloating && defaultLabelTextSizes == null && largeLabel != null && smallLabel != null) {
1463
+ defaultLabelTextSizes = largeLabel.textSize to smallLabel.textSize
1464
+ }
1465
+ if (restoreLabelSizes != null) {
1466
+ largeLabel?.setTextSize(TypedValue.COMPLEX_UNIT_PX, restoreLabelSizes.first)
1467
+ smallLabel?.setTextSize(TypedValue.COMPLEX_UNIT_PX, restoreLabelSizes.second)
1468
+ }
1469
+
1470
+ listOf(largeLabel, smallLabel).forEach { label ->
1471
+ label?.apply {
1472
+ size?.let { size ->
1473
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, size)
1474
+ }
1475
+ typeface?.let { setTypeface(it) }
1476
+ }
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ private fun emitHapticFeedback(feedbackConstants: Int) {
1482
+ // OneKey patch: use the Fabric-populated View flag on every supported API level.
1483
+ // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && hapticFeedbackEnabled) {
1484
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && isHapticFeedbackEnabled) {
1485
+ this.performHapticFeedback(feedbackConstants)
1486
+ }
1487
+ }
1488
+
1489
+ private fun updateTintColors() {
1490
+ // First let's check current item color.
1491
+ val currentItemTintColor = items.firstOrNull { it.key == selectedItem }?.activeTintColor
1492
+
1493
+ // getDefaultColor will always return a valid color but to satisfy the compiler we need to check for null
1494
+ val colorPrimary = currentItemTintColor ?: activeTintColor ?: Utils.getDefaultColorFor(
1495
+ context,
1496
+ android.R.attr.colorPrimary
1497
+ ) ?: return
1498
+ val colorSecondary =
1499
+ inactiveTintColor ?: Utils.getDefaultColorFor(context, android.R.attr.textColorSecondary)
1500
+ ?: return
1501
+ val states = arrayOf(uncheckedStateSet, checkedStateSet)
1502
+ val colors = intArrayOf(colorSecondary, colorPrimary)
1503
+
1504
+ ColorStateList(states, colors).apply {
1505
+ this@BottomTabsView.bottomNavigation.itemTextColor = this
1506
+ this@BottomTabsView.bottomNavigation.itemIconTintList = this
1507
+ }
1508
+ }
1509
+
1510
+ override fun onConfigurationChanged(newConfig: Configuration?) {
1511
+ super.onConfigurationChanged(newConfig)
1512
+ releaseBottomBlur()
1513
+ if (androidBottomBlurEnabled) {
1514
+ requestLayout()
1515
+ floatingRoot.invalidate()
1516
+ invalidate()
1517
+ }
1518
+ if (uiModeConfiguration == newConfig?.uiMode || hasCustomAppearance) {
1519
+ return
1520
+ }
1521
+
1522
+ // User has hidden the bottom navigation bar, don't re-attach it.
1523
+ if (isTabBarHidden) {
1524
+ return
1525
+ }
1526
+
1527
+ // If appearance wasn't changed re-create the bottom navigation view when configuration changes.
1528
+ // React Native opts out ouf Activity re-creation when configuration changes, this workarounds that.
1529
+ // We also opt-out of this recreation when custom styles are used.
1530
+ blurAppearanceApplied = false
1531
+ removeFromParent(bottomNavigation)
1532
+ bottomNavigation = ExtendedBottomNavigationView(context)
1533
+ defaultItemBackground = bottomNavigation.itemBackground
1534
+ defaultBackgroundTintList = bottomNavigation.backgroundTintList
1535
+ defaultElevation = bottomNavigation.elevation
1536
+ defaultActiveIndicatorEnabled = bottomNavigation.isItemActiveIndicatorEnabled
1537
+ defaultActiveIndicatorColor = bottomNavigation.itemActiveIndicatorColor
1538
+ defaultMinimumHeight = bottomNavigation.minimumHeight
1539
+ defaultPaddingLeft = bottomNavigation.paddingLeft
1540
+ defaultPaddingTop = bottomNavigation.paddingTop
1541
+ defaultPaddingRight = bottomNavigation.paddingRight
1542
+ defaultPaddingBottom = bottomNavigation.paddingBottom
1543
+ defaultItemGravity = bottomNavigation.itemGravity
1544
+ defaultItemPaddingTop = bottomNavigation.itemPaddingTop
1545
+ defaultItemPaddingBottom = bottomNavigation.itemPaddingBottom
1546
+ defaultItemIconSize = bottomNavigation.itemIconSize
1547
+ defaultItemRippleColor = bottomNavigation.itemRippleColor
1548
+ if (androidTabBarStyle == AndroidTabBarStyle.FLOATING) {
1549
+ attachFloatingLayout()
1550
+ } else {
1551
+ attachDefaultLayout()
1552
+ }
1553
+ updateItems(items)
1554
+ setLabeled(this.labeled)
1555
+ this.selectedItem?.let { setSelectedItem(it) }
1556
+ uiModeConfiguration = newConfig?.uiMode ?: uiModeConfiguration
1557
+ }
1558
+ }