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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -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 +287 -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,428 @@
1
+ package com.honeypathkar.lqbottombar
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Canvas
5
+ import android.graphics.RenderEffect
6
+ import android.graphics.Shader
7
+ import android.os.Build
8
+ import android.os.SystemClock
9
+ import android.view.View
10
+ import android.view.ViewTreeObserver
11
+ import android.widget.ImageView
12
+
13
+ /**
14
+ * BLURRING WHAT IS BEHIND A VIEW, on a platform that has no API for it.
15
+ *
16
+ * This is the one genuinely awkward corner of the package, so it is worth
17
+ * being precise about why it looks like this.
18
+ *
19
+ * iOS hands the whole problem to UIKit: a `UITabBar` with
20
+ * `configureWithDefaultBackground()` gets the system material, and the system
21
+ * knows what is underneath it. Android has no equivalent. The two things that
22
+ * sound like one are not:
23
+ *
24
+ * - `RenderEffect.createBlurEffect` (API 31+) blurs a view's OWN content.
25
+ * Setting it on this bar would blur the bar's icons, not the page behind
26
+ * them.
27
+ * - `Window.setBackgroundBlurRadius` (API 31+) blurs behind a WINDOW, and
28
+ * only for a window that is not fullscreen — a dialog. A tab bar inside
29
+ * the activity's own window cannot use it.
30
+ *
31
+ * So the backdrop has to be produced by hand, which is what every Android
32
+ * blur library does and what this class does:
33
+ *
34
+ * 1. Draw the root view into a small off-screen bitmap, offset so the
35
+ * bar's own rectangle lands at the bitmap's origin.
36
+ * 2. Blur that bitmap.
37
+ * 3. Draw it under the bar's contents, scaled back up.
38
+ *
39
+ * The downscale in step 1 is not only for speed: a bilinear upscale in step 3
40
+ * is itself a blur, so the explicit blur in step 2 has much less work to do.
41
+ *
42
+ * KNOWN LIMITATION, inherent to the technique rather than to this code.
43
+ * `View.draw(Canvas)` onto a bitmap is a SOFTWARE draw, and content the GPU
44
+ * owns exclusively does not participate: a `SurfaceView` or `TextureView` —
45
+ * video, a map, a camera preview — captures as a blank region, so the bar
46
+ * reads as frosted over nothing there. There is no fix available from inside
47
+ * the view hierarchy. Where that matters, pass `blurAmount={0}` and let the
48
+ * flat scrim carry the bar.
49
+ */
50
+ internal class BackdropBlur(private val target: View) {
51
+
52
+ /**
53
+ * How far the capture is downscaled.
54
+ *
55
+ * Eight is BlurView's default and a good trade: a 1080x150px bar region
56
+ * becomes 135x19px, which is ~2.5k pixels to blur instead of 162k, and the
57
+ * upscale smooths what the small radius leaves behind.
58
+ */
59
+ private val downscale = 8
60
+
61
+ /** Radius in downscaled pixels; set from the JS `blurAmount`. */
62
+ var radius: Float = 0f
63
+ set(value) {
64
+ val clamped = value.coerceIn(0f, 25f)
65
+ if (clamped == field) return
66
+ field = clamped
67
+ applyEffect()
68
+ if (clamped == 0f) {
69
+ detach()
70
+ image.setImageBitmap(null)
71
+ attachedBitmap = null
72
+ } else {
73
+ attach()
74
+ invalidateBlur()
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The blurred backdrop, as a real child view rather than something painted
80
+ * in `onDraw`.
81
+ *
82
+ * On API 31+ this is what makes the GPU do the blur: a `RenderEffect` set on
83
+ * a view blurs that view's content, and this view's content is the captured
84
+ * page. That is the one case where the "wrong" API is exactly right — the
85
+ * capture has already turned the backdrop INTO our own content.
86
+ */
87
+ val image: ImageView = ImageView(target.context).apply {
88
+ scaleType = ImageView.ScaleType.FIT_XY
89
+ }
90
+
91
+ private var bitmap: Bitmap? = null
92
+
93
+ /** Which bitmap instance the ImageView currently holds. See `invalidateBlur`. */
94
+ private var attachedBitmap: Bitmap? = null
95
+
96
+ private val canvas = Canvas()
97
+
98
+ /**
99
+ * Set while capturing, and read by the target's own `draw` override.
100
+ *
101
+ * The bar is a DESCENDANT of the view it is capturing, so an unguarded
102
+ * capture would draw the bar into its own backdrop, then blur that, then
103
+ * capture the result — feedback, visible as a smear that deepens every
104
+ * frame. The target skips drawing itself entirely while this is set.
105
+ */
106
+ var isCapturing = false
107
+ private set
108
+
109
+ private var attached = false
110
+ private var lastCaptureMs = 0L
111
+
112
+ private var blurPixels: IntArray? = null
113
+ private var blurScratch: IntArray? = null
114
+
115
+ private val location = IntArray(2)
116
+ private val rootLocation = IntArray(2)
117
+
118
+ /**
119
+ * Capture on every frame the root draws.
120
+ *
121
+ * A pre-draw listener rather than a timer, because the backdrop is only
122
+ * stale when something behind the bar actually changed, and that is exactly
123
+ * when the root redraws. The listener is removed whenever blur is off, so a
124
+ * bar with `blurAmount={0}` costs nothing at all.
125
+ */
126
+ private val preDrawListener =
127
+ ViewTreeObserver.OnPreDrawListener {
128
+ invalidateBlur()
129
+ true
130
+ }
131
+
132
+ /**
133
+ * THE ACTIVITY'S CONTENT VIEW, not the decor view.
134
+ *
135
+ * `getRootView()` returns the decor view, which also holds the status and
136
+ * navigation bar backgrounds. Capturing from there is not WRONG — the offset
137
+ * is computed from both views' on-screen positions, so the maths lands in
138
+ * the same place either way — but it means a full software draw of the
139
+ * system bar decorations on every frame for pixels the bar never samples.
140
+ *
141
+ * `android.R.id.content` is the standard handle on the content view, and it
142
+ * falls back to the decor view for the case where this is somehow hosted
143
+ * outside an activity's content frame.
144
+ */
145
+ private fun rootView(): View? {
146
+ val root = target.rootView ?: return null
147
+ return root.findViewById(android.R.id.content) ?: root
148
+ }
149
+
150
+ fun attach() {
151
+ if (attached || radius == 0f) return
152
+ val root = rootView() ?: return
153
+ root.viewTreeObserver.addOnPreDrawListener(preDrawListener)
154
+ attached = true
155
+ }
156
+
157
+ fun detach() {
158
+ if (!attached) return
159
+ rootView()?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
160
+ attached = false
161
+ }
162
+
163
+ fun release() {
164
+ detach()
165
+ image.setImageBitmap(null)
166
+ attachedBitmap = null
167
+ bitmap?.recycle()
168
+ bitmap = null
169
+ }
170
+
171
+ /** Re-capture and re-blur, unless it has already happened this frame. */
172
+ fun invalidateBlur() {
173
+ if (radius == 0f) return
174
+ if (target.width == 0 || target.height == 0) return
175
+
176
+ /*
177
+ ONE CAPTURE PER FRAME.
178
+
179
+ A single frame can fire several pre-draws — a layout pass that dirties
180
+ an ancestor, an animation committing, a child invalidating — and each
181
+ would otherwise run a full software draw of the whole tree. Capping at
182
+ frame cadence makes the repeats free without the backdrop ever lagging
183
+ more than it already does.
184
+ */
185
+ val now = SystemClock.uptimeMillis()
186
+ if (now - lastCaptureMs < 16L) return
187
+ lastCaptureMs = now
188
+
189
+ val root = rootView() ?: return
190
+ if (root.width == 0 || root.height == 0) return
191
+
192
+ val width = (target.width / downscale).coerceAtLeast(1)
193
+ val height = (target.height / downscale).coerceAtLeast(1)
194
+
195
+ var current = bitmap
196
+ if (current == null || current.width != width || current.height != height) {
197
+ current?.recycle()
198
+ // Recycled, so the ImageView must be given the replacement rather than
199
+ // being told to repaint a bitmap that no longer has pixels.
200
+ attachedBitmap = null
201
+ current =
202
+ try {
203
+ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
204
+ } catch (error: OutOfMemoryError) {
205
+ // A bar-sized bitmap at 1/8 scale is a few kilobytes, so this is
206
+ // effectively unreachable — but failing to a flat scrim is much
207
+ // better than taking the app down for a decoration.
208
+ bitmap = null
209
+ return
210
+ }
211
+ bitmap = current
212
+ }
213
+
214
+ target.getLocationOnScreen(location)
215
+ root.getLocationOnScreen(rootLocation)
216
+ val offsetX = (location[0] - rootLocation[0]).toFloat()
217
+ val offsetY = (location[1] - rootLocation[1]).toFloat()
218
+
219
+ current.eraseColor(0)
220
+ canvas.setBitmap(current)
221
+ canvas.save()
222
+
223
+ /*
224
+ SCALE THEN TRANSLATE, and the order is not interchangeable.
225
+
226
+ Canvas transforms compose right-to-left on the points being drawn, so
227
+ this pair maps a root pixel p to (p - offset) / downscale: shift the
228
+ bar's own rectangle to the origin first, then shrink. Scaling after
229
+ translating would shrink the offset too and the capture would come from
230
+ a point an eighth of the way towards the bar.
231
+ */
232
+ canvas.scale(1f / downscale, 1f / downscale)
233
+ canvas.translate(-offsetX, -offsetY)
234
+
235
+ isCapturing = true
236
+ try {
237
+ root.draw(canvas)
238
+ } catch (error: Throwable) {
239
+ // A custom view in the tree that cannot tolerate a software canvas
240
+ // should cost the blur, not the frame.
241
+ } finally {
242
+ isCapturing = false
243
+ canvas.restore()
244
+ canvas.setBitmap(null)
245
+ }
246
+
247
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
248
+ // No RenderEffect here, so the blur is done to the pixels directly.
249
+ stackBlur(current, radius.toInt().coerceAtLeast(1))
250
+ }
251
+
252
+ /*
253
+ HANDED OVER ONCE, THEN JUST INVALIDATED.
254
+
255
+ `setImageBitmap` wraps the bitmap in a fresh `BitmapDrawable` every call,
256
+ so doing it per frame allocates a drawable per frame for a bitmap the view
257
+ already holds. The pixels are mutated in place, so once the ImageView has
258
+ this instance an `invalidate()` is all that is needed — the software blur
259
+ writes straight into it, and a `RenderEffect` re-reads it when the
260
+ drawable is next drawn.
261
+ */
262
+ if (attachedBitmap !== current) {
263
+ image.setImageBitmap(current)
264
+ attachedBitmap = current
265
+ } else {
266
+ image.invalidate()
267
+ }
268
+ }
269
+
270
+ private fun applyEffect() {
271
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
272
+ image.setRenderEffect(
273
+ if (radius == 0f) {
274
+ null
275
+ } else {
276
+ /*
277
+ CLAMP, not DECAL. A blur samples outside its own bounds at the edges;
278
+ clamping extends the edge pixels, while the default would fade them
279
+ towards transparent and leave a lighter hairline along the top of the
280
+ bar — which on a dark page reads as a seam.
281
+ */
282
+ RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)
283
+ }
284
+ )
285
+ }
286
+
287
+ /**
288
+ * A three-pass box blur, which is a close enough Gaussian for API < 31.
289
+ *
290
+ * Three box passes converge on a Gaussian well within what the eye can tell
291
+ * apart at this scale, and each pass is a running sum — so the cost is per
292
+ * pixel rather than per pixel per radius. On a bitmap this small (a few
293
+ * thousand pixels after the downscale) the whole thing is well under a
294
+ * millisecond, which is what makes doing it on the UI thread acceptable.
295
+ */
296
+ private fun stackBlur(target: Bitmap, radius: Int) {
297
+ val width = target.width
298
+ val height = target.height
299
+ if (width < 2 || height < 2) return
300
+
301
+ /*
302
+ The two buffers are fields, not locals.
303
+
304
+ This runs on the UI thread on every captured frame, and a pair of
305
+ `IntArray(width * height)` allocations per frame is steady garbage for no
306
+ reason — the size only changes when the bar is resized. Re-allocated only
307
+ when that happens.
308
+ */
309
+ val count = width * height
310
+ if (blurPixels?.size != count) {
311
+ blurPixels = IntArray(count)
312
+ blurScratch = IntArray(count)
313
+ }
314
+ val pixels = blurPixels ?: return
315
+ val scratch = blurScratch ?: return
316
+
317
+ target.getPixels(pixels, 0, width, 0, 0, width, height)
318
+
319
+ val boxes = boxesForGaussian(radius.toFloat(), 3)
320
+ for (box in boxes) {
321
+ val r = ((box - 1) / 2).coerceAtLeast(1)
322
+ boxBlurHorizontal(pixels, scratch, width, height, r)
323
+ boxBlurVertical(scratch, pixels, width, height, r)
324
+ }
325
+
326
+ target.setPixels(pixels, 0, width, 0, 0, width, height)
327
+ }
328
+
329
+ /**
330
+ * Box sizes whose successive application approximates a Gaussian of the
331
+ * given standard deviation. Ivan Kutskir's derivation, which is the
332
+ * standard one for this trick.
333
+ */
334
+ private fun boxesForGaussian(sigma: Float, passes: Int): IntArray {
335
+ val idealWidth = Math.sqrt((12.0 * sigma * sigma / passes) + 1.0)
336
+ var lower = Math.floor(idealWidth).toInt()
337
+ if (lower % 2 == 0) lower--
338
+ val upper = lower + 2
339
+
340
+ val idealCrossover =
341
+ (12.0 * sigma * sigma - passes * lower * lower - 4.0 * passes * lower - 3.0 * passes) /
342
+ (-4.0 * lower - 4.0)
343
+ val crossover = Math.round(idealCrossover).toInt()
344
+
345
+ return IntArray(passes) { if (it < crossover) lower else upper }
346
+ }
347
+
348
+ private fun boxBlurHorizontal(
349
+ source: IntArray,
350
+ destination: IntArray,
351
+ width: Int,
352
+ height: Int,
353
+ radius: Int,
354
+ ) {
355
+ val divisor = radius + radius + 1
356
+ for (y in 0 until height) {
357
+ val row = y * width
358
+ var alpha = 0
359
+ var red = 0
360
+ var green = 0
361
+ var blue = 0
362
+
363
+ // Prime the running sum with the window centred on the first pixel,
364
+ // clamping both ends to the row's own edge pixels.
365
+ for (x in -radius..radius) {
366
+ val pixel = source[row + x.coerceIn(0, width - 1)]
367
+ alpha += pixel ushr 24 and 0xFF
368
+ red += pixel ushr 16 and 0xFF
369
+ green += pixel ushr 8 and 0xFF
370
+ blue += pixel and 0xFF
371
+ }
372
+
373
+ for (x in 0 until width) {
374
+ destination[row + x] =
375
+ (alpha / divisor shl 24) or
376
+ (red / divisor shl 16) or
377
+ (green / divisor shl 8) or
378
+ (blue / divisor)
379
+
380
+ val leaving = source[row + (x - radius).coerceIn(0, width - 1)]
381
+ val entering = source[row + (x + radius + 1).coerceIn(0, width - 1)]
382
+ alpha += (entering ushr 24 and 0xFF) - (leaving ushr 24 and 0xFF)
383
+ red += (entering ushr 16 and 0xFF) - (leaving ushr 16 and 0xFF)
384
+ green += (entering ushr 8 and 0xFF) - (leaving ushr 8 and 0xFF)
385
+ blue += (entering and 0xFF) - (leaving and 0xFF)
386
+ }
387
+ }
388
+ }
389
+
390
+ private fun boxBlurVertical(
391
+ source: IntArray,
392
+ destination: IntArray,
393
+ width: Int,
394
+ height: Int,
395
+ radius: Int,
396
+ ) {
397
+ val divisor = radius + radius + 1
398
+ for (x in 0 until width) {
399
+ var alpha = 0
400
+ var red = 0
401
+ var green = 0
402
+ var blue = 0
403
+
404
+ for (y in -radius..radius) {
405
+ val pixel = source[y.coerceIn(0, height - 1) * width + x]
406
+ alpha += pixel ushr 24 and 0xFF
407
+ red += pixel ushr 16 and 0xFF
408
+ green += pixel ushr 8 and 0xFF
409
+ blue += pixel and 0xFF
410
+ }
411
+
412
+ for (y in 0 until height) {
413
+ destination[y * width + x] =
414
+ (alpha / divisor shl 24) or
415
+ (red / divisor shl 16) or
416
+ (green / divisor shl 8) or
417
+ (blue / divisor)
418
+
419
+ val leaving = source[(y - radius).coerceIn(0, height - 1) * width + x]
420
+ val entering = source[(y + radius + 1).coerceIn(0, height - 1) * width + x]
421
+ alpha += (entering ushr 24 and 0xFF) - (leaving ushr 24 and 0xFF)
422
+ red += (entering ushr 16 and 0xFF) - (leaving ushr 16 and 0xFF)
423
+ green += (entering ushr 8 and 0xFF) - (leaving ushr 8 and 0xFF)
424
+ blue += (entering and 0xFF) - (leaving and 0xFF)
425
+ }
426
+ }
427
+ }
428
+ }
@@ -0,0 +1,34 @@
1
+ /*
2
+ ReactShadowNode IS DEPRECATED AND UNAVOIDABLE. It appears only in
3
+ `ReactPackage.createViewManagers`'s own return type, so implementing that
4
+ interface at all means importing it and naming it in an override. The
5
+ suppression is file-level because the import itself is what gets flagged, and
6
+ this file contains nothing but that one implementation — so it cannot hide a
7
+ deprecation that is actually ours.
8
+ */
9
+ @file:Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
10
+
11
+ package com.honeypathkar.lqbottombar
12
+
13
+ import android.view.View
14
+ import com.facebook.react.ReactPackage
15
+ import com.facebook.react.bridge.NativeModule
16
+ import com.facebook.react.bridge.ReactApplicationContext
17
+ import com.facebook.react.uimanager.ReactShadowNode
18
+ import com.facebook.react.uimanager.ViewManager
19
+
20
+ /**
21
+ * Autolinking entry point. Found by `react-native config`, so no app-side
22
+ * registration is needed.
23
+ */
24
+ class LQBottomBarPackage : ReactPackage {
25
+
26
+ override fun createNativeModules(
27
+ reactContext: ReactApplicationContext
28
+ ): List<NativeModule> = emptyList()
29
+
30
+ override fun createViewManagers(
31
+ reactContext: ReactApplicationContext
32
+ ): List<ViewManager<out View, out ReactShadowNode<*>>> =
33
+ listOf(LQBottomBarViewManager())
34
+ }