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

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