react-native-floating-bubble-turbo
v0.3.0
Published
Android-only floating bubble overlay (Messenger chat-head style) exposed as a TurboModule for the New Architecture.
Maintainers
Readme
react-native-floating-bubble-turbo
Android-only floating bubble overlay (Messenger chat-head style) for React Native, exposed as a TurboModule. Requires the New Architecture and React Native 0.82+.
- Solo Android (iOS devuelve no-ops /
false). - Burbuja arrastrable sobre cualquier app, con notificación en primer plano.
- Dos modos: al tocarla puede reabrir la app (
reopen) o abrir un popup flotante con la app montada dentro (popup), navegando a un deep link. - Autocontenida: permisos,
<service>,<activity>, estilos e ícono se inyectan solos por manifest merging. No hay que tocar elAndroidManifest.xmlni elres/values/styles.xmlde la app que la consume.
Instalación
npm install react-native-floating-bubble-turboFunciona con la New Architecture (TurboModule). Asegúrate de que tu proyecto
la tenga activada (newArchEnabled: true).
No hay pasos manuales
Con autolinking (lo normal desde RN 0.60, y siempre en Expo Dev Client):
- El paquete se registra solo. No edites
MainApplicationniMainActivity. - Los permisos
SYSTEM_ALERT_WINDOW,FOREGROUND_SERVICEyFOREGROUND_SERVICE_SPECIAL_USE, el<service>y el<activity>del popup se fusionan desde el manifest de la librería. FloatingPopupThemey el ícono vienen como recursos de la librería.
Solo queda recompilar, porque es código nativo:
npx react-native run-androidCon Expo Dev Client:
npx expo prebuild
npx expo run:androidSi usas Expo sin dev client y con EAS Build,
prebuildgenera el proyecto nativo en el servidor y la librería se autolinkea igual. No hace falta config plugin.
Uso
import {
type BubbleMode,
checkOverlayPermission,
hideBubble,
isBubbleVisible,
onBubblePress,
onBubbleRemoved,
requestOverlayPermission,
sendAppToBackground,
showBubble,
} from 'react-native-floating-bubble-turbo';
// 1) Pide el permiso (te manda a la pantalla de "Dibujar sobre otras apps")
if (!(await checkOverlayPermission())) {
await requestOverlayPermission();
}
// 2) Muestra la burbuja en (x, y) con el modo por defecto: reopen
await showBubble(20, 200);
// 2b) Modo popup: abre una ventana flotante y navega al deep link
await showBubble(20, 200, 'popup', 'myapp://assistant-chat');
// 3) Escucha eventos
onBubblePress(() => {
console.log('Burbuja tocada');
});
onBubbleRemoved(() => {
console.log('Burbuja cerrada');
});
// 4) Ocúltala
await hideBubble();Los dos modos
| Modo | Qué pasa al tocar la burbuja | Deep link |
| --- | --- | --- |
| reopen (default) | Reabre la app trayéndola a primer plano con FLAG_ACTIVITY_REORDER_TO_FRONT | se ignora |
| popup | Abre BubblePopupActivity: una ventana flotante con la app montada dentro | navega a popupDeepLink |
El deep link se pasa a la Activity como extra del Intent y se reinyecta como
android.intent.extra.INITIAL_URI, así que lo procesa el Linking de React
Native. La librería no tiene ninguna ruta hardcodeada: es el deep link que
le pases.
const mode: BubbleMode = 'popup';Mostrar la burbuja al mandar la app a segundo plano
El caso típico es mostrarla cuando el usuario sale de la app. showBubble()
debe llamarse después de que la app quede en background, porque el overlay
necesita SYSTEM_ALERT_WINDOW y Android no concede permisos en background.
Para eso está useFloatingBubbleLifecycle. Móntalo una sola vez, en el
layout del área principal de tu app:
// app/(main)/_layout.tsx
import { useFloatingBubbleLifecycle } from 'react-native-floating-bubble-turbo';
import MainDrawer from '@/features/core/components/layout/main/MainDrawer';
export default function MainLayout() {
useFloatingBubbleLifecycle();
return <MainDrawer />;
}Opciones:
useFloatingBubbleLifecycle({
x: 20, // default 20
y: 200, // default 200
mode: 'popup', // default 'reopen'
popupDeepLink: 'myapp://assistant-chat', // default ''
requestPermissionOnMount: true, // default true
enabled: isFeatureEnabled, // default true
});El hook pide el permiso de overlay al montarse, muestra la burbuja en
active → background, la oculta en background → active, y también al
desmontar el componente (para que no quede flotando al hacer logout).
No lo gatees por autenticación
Esto es la trampa más común al integrar esta librería, y no es específica de ella. Un gate así rompe la feature entera sin dar ningún error:
// MAL
const { isAuthenticated } = useAuth();
useEffect(() => {
if (!isAuthenticated) return; // <-- mata el listener de AppState
const sub = AppState.addEventListener('change', /* ... */);
return () => sub.remove();
}, [isAuthenticated]);Ese return temprano hace dos cosas:
- Nunca registra el listener de
AppState, así queshowBubble()no se llama jamás. La app compila, el módulo nativo funciona, y la burbuja no aparece nunca. - Nunca pide el permiso de overlay, así que ni siquiera sale el diálogo de "Dibujar sobre otras apps", y no hay pista de qué falló.
El problema es que isAuthenticated es false en desarrollo mucho más de lo
que uno espera. Mientras el login no valide contra backend, la app suele
navegar al área principal con router.push sin autenticar, así que llegas ahí
con isAuthenticated === false y la feature queda muerta en silencio.
Si lo que quieres es "solo para quien puede iniciar sesión", exprésalo con
enabled, no gateando el montaje:
useFloatingBubbleLifecycle({ enabled: isFeatureEnabledForThisUser });Y montalo en el layout del área autenticada, no dentro de una pantalla condicional: el hook se encarga solo de limpiarse al desmontarse.
Si prefieres hacerlo a mano
import { AppState, showBubble, hideBubble } from 'react-native-floating-bubble-turbo';
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => {
if (AppState.currentState === 'active' && next === 'background') {
showBubble(20, 200, 'reopen');
}
if (next === 'active') hideBubble();
});
return () => {
sub.remove();
hideBubble();
};
}, []);Registra el listener fuera de toda guarda. Si necesitas condicionar, usa
enabled de useFloatingBubbleLifecycle.
API
| Función | Tipo | Descripción |
| --- | --- | --- |
| checkOverlayPermission() | Promise<boolean> | ¿Se puede dibujar sobre otras apps? |
| requestOverlayPermission() | Promise<boolean> | Abre los ajustes del permiso. Resuelve true si ya estaba otorgado. |
| showBubble(x?, y?, mode?, popupDeepLink?) | Promise<void> | Muestra la burbuja. Default x=0, y=100, mode='reopen', popupDeepLink=''. Rechaza con NO_PERMISSION si falta el permiso. |
| hideBubble() | Promise<void> | Oculta la burbuja y emite onBubbleRemoved. |
| isBubbleVisible() | Promise<boolean> | ¿Hay burbuja en pantalla? |
| sendAppToBackground() | Promise<void> | Manda la app al inicio (botón Home), sin matarla. Rechaza con NO_ACTIVITY o NO_HOME. |
| onBubblePress(cb) | EmitterSubscription \| undefined | Tap en la burbuja. |
| onBubbleRemoved(cb) | EmitterSubscription \| undefined | Burbuja cerrada. |
BubbleMode es 'reopen' | 'popup'.
useFloatingBubbleLifecycle(options?) devuelve void. Las opciones son
enabled, x, y, mode, popupDeepLink y requestPermissionOnMount, todas
opcionales.
Cómo mandar la app al inicio sin matarla
Si querés un botón "volver al inicio" para disparar la burbuja a mano
(típico para probar), usá sendAppToBackground():
import { sendAppToBackground } from 'react-native-floating-bubble-turbo';
await sendAppToBackground(); // el launcher queda al frenteNo lo implementes con Linking.openURL. El truco
intent://#Intent;action=android.intent.action.MAIN;category=...HOME;end no
funciona en RN 0.86.3: IntentModule.openURL construye
Intent(ACTION_VIEW, Uri.parse(url)) y nunca llama a Intent.parseUri
(IntentModule.kt:124), así que el fragmento #Intent;... se ignora y
Android falla con No Activity found to handle Intent { act=VIEW
dat=intent:// }. Linking.sendIntent tampoco sirve: hace Intent(action) sin
categoría (IntentModule.kt:207) y un intent con ACTION_MAIN y cero
categorías no matchea un filtro {HOME, DEFAULT}.
Tampoco uses BackHandler.exitApp(): destruye la Activity y el foreground
service de la burbuja muere con ella, porque vive en el proceso de la app.
requestOverlayPermission()lanza la Activity de ajustes constartActivityForResult. El resultado llega por elonActivityResultque RN reenvía automáticamente a losActivityEventListenerregistrados en elReactContext, así que no hace falta tocarMainActivity. Si el usuario vuelve sin cambiar nada,checkOverlayPermission()tras volver a la app sigue siendo la fuente de verdad.
Example
Dentro del repo:
yarn
yarn example androidLa app de ejemplo tiene un selector para probar los dos modos y un log de los
eventos. (Necesitas dispositivo o emulador; example/ no es Expo.)
Estado y límites
- Solo Android; en iOS las funciones son no-ops /
false. minSdkVersion 24,compileSdkVersion 36.- El Service es un Foreground Service con
foregroundServiceType="specialUse". Android permite que el sistema lo mate, y en Xiaomi/Huawei la optimización de batería es especialmente agresiva. Si la burbuja desaparece sola, eso es el sistema, no la librería. - El ícono de la burbuja es
res/drawable-nodpi/bubble_icon.pngy el layout esres/layout/bubble_layout.xml. Para cambiarlo en tu app, sobreescribe el recurso con el mismo nombre en tu módulo, o haz un fork. BubblePopupActivityusa"main"como nombre del componente raíz, que es el default de Expo con expo-router. Intenta leergetMainComponentName()delApplicationdel host por reflection, pero en la práctica eso casi nunca funciona: el método lo declara elActivity, no elApplication, así que la reflexión cae al default. Si tu app raíz no se llama"main", sobreescribígetMainComponentName()en una subclase.- El popup del modo
popupmonta una segunda instancia de la app. Eso implica más memoria y un cold start de React. Es el modo más caro; para la mayoría de los casosreopenes suficiente. getMainComponentName()debe devolver el mismo nombre que usás para registrar la app, o el popup del modopopupmonta una pantalla vacía. Con expo-router es"main", que es el default, así que normalmente funciona.
Con expo-router: no pongas el host dentro de app/
Todo archivo dentro de app/ es una route para expo-router. Si creás un
componente tipo "host" dentro de ese directorio, expo-router lo registra como
screen navegable y, si no tiene export default, se queja al arrancar:
WARN Route "./(main)/FloatingBubbleHost.tsx" is missing the required default
export. Ensure a React component is exported as default.Mountalo desde src/ y dejá que el _layout.tsx lo use:
// src/features/…/FloatingBubbleHost.tsx (fuera de app/)
import { useFloatingBubbleLifecycle } from 'react-native-floating-bubble-turbo';
export default function FloatingBubbleHost() {
useFloatingBubbleLifecycle();
return <MainDrawer />;
}
// app/(main)/_layout.tsx
import FloatingBubbleHost from '@/features/…/FloatingBubbleHost';
export default FloatingBubbleHost;Contribución
License
MIT — ver LICENSE.
Made with create-react-native-library
