@honeypathkar/react-native-lq-bottom-bar 0.0.0-stage → 0.1.1

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 (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -2
  3. package/android/build.gradle +78 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/honeypathkar/lqbottombar/BackdropBlur.kt +428 -0
  6. package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarPackage.kt +34 -0
  7. package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarView.kt +503 -0
  8. package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarViewManager.kt +119 -0
  9. package/android/src/main/java/com/honeypathkar/lqbottombar/TabSelectedEvent.kt +30 -0
  10. package/ios/LQBottomBarView.swift +328 -0
  11. package/ios/LQBottomBarViewManager.m +21 -0
  12. package/ios/LQBottomBarViewManager.swift +36 -0
  13. package/lib/commonjs/NativeBottomBar.d.ts +33 -0
  14. package/lib/commonjs/NativeBottomBar.d.ts.map +1 -0
  15. package/lib/commonjs/NativeBottomBar.js +9 -0
  16. package/lib/commonjs/NativeBottomBar.js.map +1 -0
  17. package/lib/commonjs/index.d.ts +6 -0
  18. package/lib/commonjs/index.d.ts.map +1 -0
  19. package/lib/commonjs/index.js +131 -0
  20. package/lib/commonjs/index.js.map +1 -0
  21. package/lib/commonjs/rasterise.d.ts +20 -0
  22. package/lib/commonjs/rasterise.d.ts.map +1 -0
  23. package/lib/commonjs/rasterise.js +398 -0
  24. package/lib/commonjs/rasterise.js.map +1 -0
  25. package/lib/commonjs/types.d.ts +119 -0
  26. package/lib/commonjs/types.d.ts.map +1 -0
  27. package/lib/commonjs/types.js +3 -0
  28. package/lib/commonjs/types.js.map +1 -0
  29. package/package.json +65 -4
  30. package/react-native-lq-bottom-bar.podspec +27 -0
  31. package/src/NativeBottomBar.ts +43 -0
  32. package/src/index.tsx +139 -0
  33. package/src/rasterise.tsx +444 -0
  34. package/src/types.ts +136 -0
@@ -0,0 +1,503 @@
1
+ package com.honeypathkar.lqbottombar
2
+
3
+ import android.animation.ArgbEvaluator
4
+ import android.animation.ValueAnimator
5
+ import android.content.Context
6
+ import android.content.res.ColorStateList
7
+ import android.graphics.Bitmap
8
+ import android.graphics.BitmapFactory
9
+ import android.graphics.Canvas
10
+ import android.graphics.Color
11
+ import android.util.Base64
12
+ import android.util.TypedValue
13
+ import android.view.Gravity
14
+ import android.view.View
15
+ import android.view.animation.PathInterpolator
16
+ import android.widget.ImageView
17
+ import android.widget.LinearLayout
18
+ import android.widget.TextView
19
+ import com.facebook.react.bridge.ReadableArray
20
+ import com.facebook.react.bridge.ReadableMap
21
+ import com.facebook.react.uimanager.UIManagerHelper
22
+ import com.facebook.react.views.view.ReactViewGroup
23
+
24
+ /**
25
+ * THE ANDROID BAR, matching the iOS one.
26
+ *
27
+ * There is no system bottom bar to borrow here the way `UITabBar` is borrowed
28
+ * on iOS — Material's `NavigationBarView` is a different control with a
29
+ * different shape and its own opinions — so this draws the bar itself and
30
+ * matches the iOS one deliberately: the same dimensions, the same tint
31
+ * crossfade on selection, and a blurred backdrop standing in for the iOS
32
+ * material.
33
+ *
34
+ * `ReactViewGroup`, NOT a plain `FrameLayout`. Subclassing React Native's own
35
+ * view brings `borderRadius`, `backgroundColor`, `overflow` and the whole
36
+ * standard style surface with it, so the bar is shaped from JS with ordinary
37
+ * style props instead of needing a bespoke prop per corner. The JS wrapper
38
+ * passes `overflow: 'hidden'` for exactly this reason: without it the blurred
39
+ * backdrop would square off the rounded corners it sits inside.
40
+ *
41
+ * The icons arrive as base64 PNGs, captured from React elements by
42
+ * `src/rasterise.tsx`. A view that draws its own items cannot take a React
43
+ * element, which is the same constraint `UITabBar` imposes — so both
44
+ * platforms are fed from one rasteriser rather than each inventing a path.
45
+ */
46
+ class LQBottomBarView(context: Context) : ReactViewGroup(context) {
47
+
48
+ /**
49
+ * UIKit's standard bar body, excluding the home-indicator inset.
50
+ *
51
+ * Hard-coded to match iOS rather than measured, because matching is the
52
+ * point: the two platforms should reserve the same space so a screen laying
53
+ * content out above the bar can use one number.
54
+ */
55
+ private val barHeightDp = 49f
56
+
57
+ private val iconSizeDefaultDp = 24f
58
+
59
+ // MARK: - Pending props
60
+ //
61
+ // React Native sets props one at a time and in no guaranteed order, so each
62
+ // setter records and marks dirty rather than rebuilding. The manager calls
63
+ // `applyPendingUpdates` once per transaction — otherwise a four-item bar
64
+ // rebuilds itself five times for one render.
65
+
66
+ private var items: ReadableArray? = null
67
+ private var selectedIndex = 0
68
+ private var selectedColor = Color.WHITE
69
+ private var unselectedColor = Color.argb(140, 255, 255, 255)
70
+ private var labeled = true
71
+ private var iconSizeDp = iconSizeDefaultDp
72
+
73
+ private var itemsDirty = false
74
+ private var appearanceDirty = false
75
+ private var selectionDirty = false
76
+
77
+ /** -1 until the first selection lands, so the first one does not animate. */
78
+ private var appliedIndex = -1
79
+
80
+ // MARK: - Views
81
+
82
+ private val blur = BackdropBlur(this)
83
+
84
+ /**
85
+ * The material's TINT, over the blurred backdrop rather than under it.
86
+ *
87
+ * This is how UIKit composes a material and the order matters: blur first,
88
+ * then a translucent colour on top. Painted underneath instead it would be
89
+ * hidden by the backdrop entirely, and painted opaque it stops being a tint
90
+ * and becomes a lid — which is how every hand-built version of this bar
91
+ * ends up looking like a milky panel.
92
+ */
93
+ private val scrim = View(context)
94
+
95
+ private val row =
96
+ LinearLayout(context).apply {
97
+ orientation = LinearLayout.HORIZONTAL
98
+ gravity = Gravity.CENTER
99
+ }
100
+
101
+ private class ItemHolder(
102
+ val container: LinearLayout,
103
+ val icon: ImageView,
104
+ val label: TextView,
105
+ val tintable: Boolean,
106
+ ) {
107
+ var animator: ValueAnimator? = null
108
+ var progress = 0f
109
+ }
110
+
111
+ private val holders = mutableListOf<ItemHolder>()
112
+
113
+ /**
114
+ * Decoded glyphs, keyed by the data URI they came from.
115
+ *
116
+ * The URIs are stable strings produced by the JS rasteriser's own cache, so
117
+ * this is keyed on identity in practice. Without it every prop update —
118
+ * including each selection change, which re-sends `items` — would re-decode
119
+ * and re-allocate every bitmap in the bar.
120
+ */
121
+ private val bitmaps = HashMap<String, Bitmap?>()
122
+
123
+ private val argb = ArgbEvaluator()
124
+
125
+ /** UIKit's default timing curve, so the crossfade reads the same. */
126
+ private val easing = PathInterpolator(0.25f, 0.1f, 0.25f, 1f)
127
+
128
+ init {
129
+ addView(
130
+ blur.image,
131
+ LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
132
+ )
133
+ addView(
134
+ scrim,
135
+ LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
136
+ )
137
+ addView(
138
+ row,
139
+ LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
140
+ )
141
+ }
142
+
143
+ private fun dp(value: Float): Int =
144
+ TypedValue.applyDimension(
145
+ TypedValue.COMPLEX_UNIT_DIP,
146
+ value,
147
+ resources.displayMetrics,
148
+ )
149
+ .toInt()
150
+
151
+ // MARK: - Prop entry points
152
+
153
+ fun setItems(value: ReadableArray?) {
154
+ items = value
155
+ itemsDirty = true
156
+ }
157
+
158
+ fun setSelectedIndex(value: Int) {
159
+ selectedIndex = value
160
+ selectionDirty = true
161
+ }
162
+
163
+ fun setSelectedColor(value: Int) {
164
+ selectedColor = value
165
+ appearanceDirty = true
166
+ }
167
+
168
+ fun setUnselectedColor(value: Int) {
169
+ unselectedColor = value
170
+ appearanceDirty = true
171
+ }
172
+
173
+ fun setLabeled(value: Boolean) {
174
+ if (labeled == value) return
175
+ labeled = value
176
+ itemsDirty = true
177
+ }
178
+
179
+ fun setIconSize(value: Float) {
180
+ if (iconSizeDp == value) return
181
+ iconSizeDp = value
182
+ itemsDirty = true
183
+ }
184
+
185
+ fun setBlurAmount(value: Float) {
186
+ blur.radius = value
187
+ }
188
+
189
+ fun setScrimColor(value: Int) {
190
+ scrim.setBackgroundColor(value)
191
+ }
192
+
193
+ /** Called once per prop transaction by the manager. See the note above. */
194
+ fun applyPendingUpdates() {
195
+ if (itemsDirty) {
196
+ rebuildItems()
197
+ itemsDirty = false
198
+ // A rebuild produces fresh views with no tint or selection on them.
199
+ appearanceDirty = true
200
+ selectionDirty = true
201
+ appliedIndex = -1
202
+ }
203
+ if (appearanceDirty) {
204
+ applyAppearance()
205
+ appearanceDirty = false
206
+ }
207
+ if (selectionDirty) {
208
+ applySelection()
209
+ selectionDirty = false
210
+ }
211
+ }
212
+
213
+ // MARK: - Items
214
+
215
+ private fun rebuildItems() {
216
+ holders.forEach { it.animator?.cancel() }
217
+ holders.clear()
218
+ row.removeAllViews()
219
+
220
+ val source = items ?: return
221
+
222
+ for (index in 0 until source.size()) {
223
+ val spec = source.getMap(index) ?: continue
224
+
225
+ val icon =
226
+ ImageView(context).apply {
227
+ val size = dp(iconSizeDp)
228
+ layoutParams = LinearLayout.LayoutParams(size, size)
229
+ scaleType = ImageView.ScaleType.FIT_CENTER
230
+ }
231
+
232
+ bitmapFor(spec)?.let { icon.setImageBitmap(it) }
233
+
234
+ val label =
235
+ TextView(context).apply {
236
+ text = spec.getStringOrNull("title") ?: ""
237
+ /*
238
+ 10sp is UIKit's tab-bar label size. `COMPLEX_UNIT_SP` rather than
239
+ DIP on purpose: a label should follow the user's font-size setting,
240
+ which is what the iOS bar does through Dynamic Type.
241
+ */
242
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f)
243
+ includeFontPadding = false
244
+ maxLines = 1
245
+ visibility = if (labeled) View.VISIBLE else View.GONE
246
+ }
247
+
248
+ val container =
249
+ LinearLayout(context).apply {
250
+ orientation = LinearLayout.VERTICAL
251
+ gravity = Gravity.CENTER
252
+ /*
253
+ Equal weights, zero width. The bar spreads its items evenly across
254
+ whatever width it is given, which is what `UITabBar` does and what
255
+ keeps the two platforms lined up when the bar is inset into a pill.
256
+ */
257
+ layoutParams = LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)
258
+ isClickable = true
259
+ isFocusable = true
260
+ contentDescription =
261
+ spec.getStringOrNull("accessibilityLabel")
262
+ ?: spec.getStringOrNull("title")
263
+ }
264
+
265
+ container.addView(icon)
266
+ if (labeled) {
267
+ (icon.layoutParams as LinearLayout.LayoutParams).bottomMargin = dp(2f)
268
+ container.addView(label)
269
+ }
270
+
271
+ val position = index
272
+ container.setOnClickListener {
273
+ /*
274
+ REPORT, DO NOT DECIDE — the same contract as the iOS bar.
275
+
276
+ The selection is JS state: a press can be declined, so the bar must
277
+ not move until `selectedIndex` comes back down. Animating here and
278
+ letting JS correct it afterwards is what makes a declined press
279
+ flicker.
280
+ */
281
+ emitSelected(position)
282
+ }
283
+
284
+ row.addView(container)
285
+ holders.add(
286
+ ItemHolder(
287
+ container = container,
288
+ icon = icon,
289
+ label = label,
290
+ tintable = spec.getBooleanOrDefault("tintable", true),
291
+ )
292
+ )
293
+ }
294
+ }
295
+
296
+ private fun bitmapFor(spec: ReadableMap): Bitmap? {
297
+ val icon = spec.getMap("icon") ?: return null
298
+ val uri = icon.getStringOrNull("uri") ?: return null
299
+ if (bitmaps.containsKey(uri)) return bitmaps[uri]
300
+
301
+ val decoded =
302
+ if (uri.startsWith("data:")) {
303
+ /*
304
+ A RASTERISED REACT ICON, which is the only form this view handles.
305
+
306
+ `src/rasterise.tsx` captures a lucide or SVG element off-screen and
307
+ sends it as an inline base64 PNG. The capture is already at the
308
+ device's pixel density — see the unit note in that file — so the
309
+ bitmap is `iconSize * density` pixels and the ImageView, sized in dp,
310
+ scales it back down to exactly the right size without resampling it
311
+ twice.
312
+ */
313
+ val comma = uri.indexOf(',')
314
+ if (comma < 0) {
315
+ null
316
+ } else {
317
+ try {
318
+ val bytes = Base64.decode(uri.substring(comma + 1), Base64.DEFAULT)
319
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
320
+ } catch (error: IllegalArgumentException) {
321
+ // Malformed base64: no glyph, rather than no bar.
322
+ null
323
+ }
324
+ }
325
+ } else {
326
+ /*
327
+ A file or a Metro URL, which only iOS resolves.
328
+
329
+ Android is fed exclusively from the rasteriser, so an item whose icon
330
+ is a `require()`d PNG or an SF Symbol reaches here as something this
331
+ view cannot draw. Caching the miss keeps it from being retried on
332
+ every update; the item still gets its label.
333
+ */
334
+ null
335
+ }
336
+
337
+ bitmaps[uri] = decoded
338
+ return decoded
339
+ }
340
+
341
+ // MARK: - Appearance and selection
342
+
343
+ private fun applyAppearance() {
344
+ holders.forEachIndexed { index, holder ->
345
+ tint(holder, if (index == selectedIndex) 1f else 0f)
346
+ }
347
+ }
348
+
349
+ private fun tint(holder: ItemHolder, progress: Float) {
350
+ holder.progress = progress
351
+ val color = argb.evaluate(progress, unselectedColor, selectedColor) as Int
352
+ if (holder.tintable) {
353
+ /*
354
+ `imageTintList` is the Android equivalent of iOS's `.alwaysTemplate`:
355
+ the bitmap's alpha is the shape and this supplies the colour. An item
356
+ with `tintable: false` keeps its own colours and so looks identical in
357
+ both states, which is the caller's choice for an avatar or a brand mark.
358
+ */
359
+ holder.icon.imageTintList = ColorStateList.valueOf(color)
360
+ } else {
361
+ holder.icon.imageTintList = null
362
+ }
363
+ holder.label.setTextColor(color)
364
+ }
365
+
366
+ private fun applySelection() {
367
+ if (holders.isEmpty()) return
368
+ val target = selectedIndex.coerceIn(0, holders.size - 1)
369
+
370
+ // The first selection after a rebuild is the bar's initial state, not a
371
+ // transition, so it lands without animating.
372
+ val animated = appliedIndex >= 0 && appliedIndex != target
373
+ appliedIndex = target
374
+
375
+ holders.forEachIndexed { index, holder ->
376
+ val to = if (index == target) 1f else 0f
377
+ if (!animated) {
378
+ holder.animator?.cancel()
379
+ tint(holder, to)
380
+ holder.icon.scaleX = 1f
381
+ holder.icon.scaleY = 1f
382
+ return@forEachIndexed
383
+ }
384
+ animate(holder, to, bump = index == target)
385
+ }
386
+ }
387
+
388
+ private fun animate(holder: ItemHolder, to: Float, bump: Boolean) {
389
+ holder.animator?.cancel()
390
+ val from = holder.progress
391
+ if (from == to && !bump) return
392
+
393
+ val animator =
394
+ ValueAnimator.ofFloat(0f, 1f).apply {
395
+ duration = 250L
396
+ interpolator = easing
397
+ addUpdateListener { animation ->
398
+ val t = animation.animatedFraction
399
+ tint(holder, from + (to - from) * t)
400
+
401
+ if (bump) {
402
+ /*
403
+ A SINGLE HALF-SINE, which is the shape of UIKit's icon bounce:
404
+ zero at both ends and peaking in the middle, so the glyph swells
405
+ and settles without a keyframe list and without ever landing
406
+ anywhere but 1.0. Interpolating to a peak and back with two
407
+ animators is what leaves a glyph stuck slightly large when the
408
+ user taps twice quickly.
409
+ */
410
+ val scale = 1f + 0.12f * Math.sin(Math.PI * t).toFloat()
411
+ holder.icon.scaleX = scale
412
+ holder.icon.scaleY = scale
413
+ }
414
+ }
415
+ }
416
+ holder.animator = animator
417
+ animator.start()
418
+ }
419
+
420
+ private fun emitSelected(index: Int) {
421
+ val reactContext = UIManagerHelper.getReactContext(this)
422
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
423
+ dispatcher?.dispatchEvent(TabSelectedEvent(UIManagerHelper.getSurfaceId(this), id, index))
424
+ }
425
+
426
+ // MARK: - Draw and lifecycle
427
+
428
+ /**
429
+ * SKIP DRAWING WHILE THE BACKDROP IS BEING CAPTURED.
430
+ *
431
+ * This bar is a descendant of the view it captures, so without this guard
432
+ * the capture would include the bar, blur it, and feed the result back in
433
+ * next frame — a smear that deepens until the bar is a grey block. See the
434
+ * recursion note in `BackdropBlur`.
435
+ *
436
+ * Both overrides are needed: the parent reaches a child through
437
+ * `draw(Canvas)` normally, but a view promoted to a hardware layer can be
438
+ * drawn through `dispatchDraw` instead, and only guarding one of them leaves
439
+ * the feedback path open on exactly the devices most likely to promote it.
440
+ */
441
+ override fun draw(canvas: Canvas) {
442
+ if (blur.isCapturing) return
443
+ super.draw(canvas)
444
+ }
445
+
446
+ override fun dispatchDraw(canvas: Canvas) {
447
+ if (blur.isCapturing) return
448
+ super.dispatchDraw(canvas)
449
+ }
450
+
451
+ override fun onAttachedToWindow() {
452
+ super.onAttachedToWindow()
453
+ blur.attach()
454
+ }
455
+
456
+ override fun onDetachedFromWindow() {
457
+ blur.detach()
458
+ super.onDetachedFromWindow()
459
+ }
460
+
461
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
462
+ super.onSizeChanged(w, h, oldw, oldh)
463
+ // The capture bitmap is sized from the bar, so a resize invalidates it.
464
+ blur.invalidateBlur()
465
+ }
466
+
467
+ /**
468
+ * Called by the manager when React drops this view.
469
+ *
470
+ * The pre-draw listener is registered on the ROOT view's observer, which
471
+ * outlives this one — so failing to remove it leaks the bar and keeps
472
+ * running a full software draw of the tree on every frame, forever.
473
+ */
474
+ fun onDropViewInstance() {
475
+ holders.forEach { it.animator?.cancel() }
476
+ blur.release()
477
+ bitmaps.clear()
478
+ }
479
+
480
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
481
+ /*
482
+ Yoga sizes this view, exactly as on iOS, so the measured height is
483
+ whatever the style says. `barHeightDp` is only the fallback for an
484
+ unconstrained parent — it is there so the bar never measures to zero and
485
+ silently disappears, which is a confusing failure to debug.
486
+ */
487
+ if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.UNSPECIFIED) {
488
+ super.onMeasure(
489
+ widthMeasureSpec,
490
+ MeasureSpec.makeMeasureSpec(dp(barHeightDp), MeasureSpec.EXACTLY),
491
+ )
492
+ return
493
+ }
494
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
495
+ }
496
+ }
497
+
498
+ /** `getString` throws on a missing key in some RN versions; this never does. */
499
+ private fun ReadableMap.getStringOrNull(key: String): String? =
500
+ if (hasKey(key) && !isNull(key)) getString(key) else null
501
+
502
+ private fun ReadableMap.getBooleanOrDefault(key: String, default: Boolean): Boolean =
503
+ if (hasKey(key) && !isNull(key)) getBoolean(key) else default
@@ -0,0 +1,119 @@
1
+ package com.honeypathkar.lqbottombar
2
+
3
+ import com.facebook.react.bridge.ReadableArray
4
+ import com.facebook.react.uimanager.ThemedReactContext
5
+ import com.facebook.react.uimanager.annotations.ReactProp
6
+ import com.facebook.react.views.view.ReactViewGroup
7
+ import com.facebook.react.views.view.ReactViewManager
8
+
9
+ /**
10
+ * The bridge's handle on `LQBottomBarView`.
11
+ *
12
+ * `ReactViewManager`, not a bare `ViewGroupManager`. Extending React Native's
13
+ * own view manager inherits every standard style prop — `borderRadius`,
14
+ * `backgroundColor`, `overflow`, elevation, borders — so the bar is shaped
15
+ * from JS with ordinary styles rather than with a bespoke prop per corner.
16
+ * The only cost is that `createViewInstance` has to be widened to
17
+ * `ReactViewGroup`, which `LQBottomBarView` is.
18
+ *
19
+ * A LEGACY manager, deliberately, matching the iOS side: React Native 0.83
20
+ * bridges legacy managers through its interop layer, and a Fabric component
21
+ * would have to be generated from a codegen spec and kept in step with it for
22
+ * no behaviour this bar needs.
23
+ */
24
+ class LQBottomBarViewManager : ReactViewManager() {
25
+
26
+ override fun getName(): String = REACT_CLASS
27
+
28
+ override fun createViewInstance(context: ThemedReactContext): ReactViewGroup =
29
+ LQBottomBarView(context)
30
+
31
+ /**
32
+ * Rebuild ONCE per transaction, not once per prop.
33
+ *
34
+ * React Native sets props individually and in no defined order, so a
35
+ * four-item bar would otherwise tear down and rebuild its item views several
36
+ * times for a single render — and `selectedIndex` arriving before `items`
37
+ * would be applied against the previous item list. The setters only record;
38
+ * this is where the work happens.
39
+ */
40
+ override fun onAfterUpdateTransaction(view: ReactViewGroup) {
41
+ super.onAfterUpdateTransaction(view)
42
+ (view as? LQBottomBarView)?.applyPendingUpdates()
43
+ }
44
+
45
+ /**
46
+ * The pre-draw listener lives on the ROOT view's observer, which outlives
47
+ * this one. Not removing it leaks the bar and keeps a full software draw of
48
+ * the whole tree running every frame for the rest of the session.
49
+ */
50
+ override fun onDropViewInstance(view: ReactViewGroup) {
51
+ (view as? LQBottomBarView)?.onDropViewInstance()
52
+ super.onDropViewInstance(view)
53
+ }
54
+
55
+ @ReactProp(name = "items")
56
+ fun setItems(view: ReactViewGroup, value: ReadableArray?) {
57
+ (view as? LQBottomBarView)?.setItems(value)
58
+ }
59
+
60
+ @ReactProp(name = "selectedIndex", defaultInt = 0)
61
+ fun setSelectedIndex(view: ReactViewGroup, value: Int) {
62
+ (view as? LQBottomBarView)?.setSelectedIndex(value)
63
+ }
64
+
65
+ /*
66
+ `customType = "Color"` is what makes React Native parse a JS colour string
67
+ into an int. Without it a `"#ffffff"` arrives as an unconvertible string and
68
+ the prop is dropped, so the bar silently keeps its default tints.
69
+ */
70
+ @ReactProp(name = "selectedColor", customType = "Color")
71
+ fun setSelectedColor(view: ReactViewGroup, value: Int?) {
72
+ if (value != null) (view as? LQBottomBarView)?.setSelectedColor(value)
73
+ }
74
+
75
+ @ReactProp(name = "unselectedColor", customType = "Color")
76
+ fun setUnselectedColor(view: ReactViewGroup, value: Int?) {
77
+ if (value != null) (view as? LQBottomBarView)?.setUnselectedColor(value)
78
+ }
79
+
80
+ @ReactProp(name = "labeled", defaultBoolean = true)
81
+ fun setLabeled(view: ReactViewGroup, value: Boolean) {
82
+ (view as? LQBottomBarView)?.setLabeled(value)
83
+ }
84
+
85
+ @ReactProp(name = "iconSize", defaultFloat = 24f)
86
+ fun setIconSize(view: ReactViewGroup, value: Float) {
87
+ (view as? LQBottomBarView)?.setIconSize(value)
88
+ }
89
+
90
+ @ReactProp(name = "blurAmount", defaultFloat = 0f)
91
+ fun setBlurAmount(view: ReactViewGroup, value: Float) {
92
+ (view as? LQBottomBarView)?.setBlurAmount(value)
93
+ }
94
+
95
+ @ReactProp(name = "scrimColor", customType = "Color")
96
+ fun setScrimColor(view: ReactViewGroup, value: Int?) {
97
+ (view as? LQBottomBarView)?.setScrimColor(value ?: 0)
98
+ }
99
+
100
+ /**
101
+ * `topTabSelected` is the NATIVE name; `onTabSelected` is the prop JS sets.
102
+ *
103
+ * React Native requires the two to be declared as a pair here — the `top`
104
+ * prefix is the convention its event plumbing expects — and a missing entry
105
+ * means the event is dispatched and then silently dropped, which looks
106
+ * exactly like a bar that does not respond to taps.
107
+ */
108
+ override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> {
109
+ // Seeded from the superclass rather than replaced: `ReactViewManager`
110
+ // exports its own direct events, and returning a fresh map would drop them.
111
+ val base = super.getExportedCustomDirectEventTypeConstants() ?: mutableMapOf()
112
+ base[TabSelectedEvent.EVENT_NAME] = mapOf("registrationName" to "onTabSelected")
113
+ return base
114
+ }
115
+
116
+ companion object {
117
+ const val REACT_CLASS = "LQBottomBarView"
118
+ }
119
+ }
@@ -0,0 +1,30 @@
1
+ package com.honeypathkar.lqbottombar
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.WritableMap
5
+ import com.facebook.react.uimanager.events.Event
6
+
7
+ /**
8
+ * The tap, on its way to JS.
9
+ *
10
+ * A custom `Event` rather than `RCTEventEmitter.receiveEvent`, because the
11
+ * latter is the OLD bridge's path and is not delivered when the component is
12
+ * hosted through Fabric's interop layer — which is how this package's legacy
13
+ * view manager runs on the new architecture. Dispatching through the event
14
+ * dispatcher works on both, so there is only one path to keep correct.
15
+ */
16
+ internal class TabSelectedEvent(
17
+ surfaceId: Int,
18
+ viewTag: Int,
19
+ private val index: Int,
20
+ ) : Event<TabSelectedEvent>(surfaceId, viewTag) {
21
+
22
+ override fun getEventName(): String = EVENT_NAME
23
+
24
+ override fun getEventData(): WritableMap =
25
+ Arguments.createMap().apply { putInt("index", index) }
26
+
27
+ companion object {
28
+ const val EVENT_NAME = "topTabSelected"
29
+ }
30
+ }