@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.
- package/LICENSE +21 -0
- package/README.md +349 -2
- package/android/build.gradle +78 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/honeypathkar/lqbottombar/BackdropBlur.kt +428 -0
- package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarPackage.kt +34 -0
- package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarView.kt +503 -0
- package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarViewManager.kt +119 -0
- package/android/src/main/java/com/honeypathkar/lqbottombar/TabSelectedEvent.kt +30 -0
- package/ios/LQBottomBarView.swift +328 -0
- package/ios/LQBottomBarViewManager.m +21 -0
- package/ios/LQBottomBarViewManager.swift +36 -0
- package/lib/commonjs/NativeBottomBar.d.ts +33 -0
- package/lib/commonjs/NativeBottomBar.d.ts.map +1 -0
- package/lib/commonjs/NativeBottomBar.js +9 -0
- package/lib/commonjs/NativeBottomBar.js.map +1 -0
- package/lib/commonjs/index.d.ts +6 -0
- package/lib/commonjs/index.d.ts.map +1 -0
- package/lib/commonjs/index.js +131 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/rasterise.d.ts +20 -0
- package/lib/commonjs/rasterise.d.ts.map +1 -0
- package/lib/commonjs/rasterise.js +398 -0
- package/lib/commonjs/rasterise.js.map +1 -0
- package/lib/commonjs/types.d.ts +119 -0
- package/lib/commonjs/types.d.ts.map +1 -0
- package/lib/commonjs/types.js +3 -0
- package/lib/commonjs/types.js.map +1 -0
- package/package.json +65 -4
- package/react-native-lq-bottom-bar.podspec +27 -0
- package/src/NativeBottomBar.ts +43 -0
- package/src/index.tsx +139 -0
- package/src/rasterise.tsx +444 -0
- 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
|
+
}
|