Qué implica “multimedia y funcionalidades del dispositivo”
En una app real, el usuario espera poder tomar una foto, elegirla desde la galería, adjuntar un PDF, compartir un archivo o guardar contenido. En React Native esto suele implicar: acceso a cámara/galería, lectura de archivos, y un manejo correcto de permisos (iOS/Android) con estados intermedios (limitado, denegado, “no volver a preguntar”). El objetivo no es “pedir permisos al inicio”, sino hacerlo de forma contextual: cuando el usuario intenta usar la función, con mensajes claros y alternativas si no concede acceso.
Decisiones clave antes de implementar
- Qué librería usar: para cámara/galería suele funcionar bien
react-native-image-picker; para documentos,react-native-document-picker. Si tu proyecto usa Expo, el enfoque equivalente sería con módulos de Expo, pero aquí nos centraremos en React Native CLI. - Qué necesitas realmente: para elegir una imagen de galería normalmente no necesitas permiso explícito en Android 13+ si usas el selector del sistema; para cámara sí. En iOS, siempre debes declarar el motivo de uso en
Info.plist. - Cómo representar el estado: “sin archivo”, “cargando”, “seleccionado”, “error”, “permiso denegado”, “permiso bloqueado”. Esto evita UI inconsistente.
Permisos: pedirlos bien (contextual) y manejar denegaciones
Estados típicos que debes contemplar
- Granted: puedes proceder.
- Denied: el usuario rechazó; puedes volver a pedir en otro momento (según plataforma).
- Blocked (o “no volver a preguntar”): no puedes volver a pedir; debes guiar al usuario a Ajustes.
- Limited (iOS Fotos): acceso limitado a algunas fotos; debes permitir que el usuario elija y, si hace falta, ampliar desde Ajustes.
Recomendación práctica: encapsula permisos en una utilidad
Centraliza la lógica para que tu UI solo consuma un resultado claro. Una opción común es usar react-native-permissions para unificar iOS/Android.
// permissions.ts (ejemplo orientativo)import { Platform } from 'react-native';import { check, request, openSettings, PERMISSIONS, RESULTS } from 'react-native-permissions';export type PermissionOutcome = | { ok: true } | { ok: false; reason: 'denied' | 'blocked' | 'unavailable' | 'limited'; openSettings?: () => void };export async function ensureCameraPermission(): Promise<PermissionOutcome> { const perm = Platform.select({ ios: PERMISSIONS.IOS.CAMERA, android: PERMISSIONS.ANDROID.CAMERA, }); if (!perm) return { ok: false, reason: 'unavailable' }; const current = await check(perm); if (current === RESULTS.GRANTED) return { ok: true }; if (current === RESULTS.BLOCKED) { return { ok: false, reason: 'blocked', openSettings }; } const next = await request(perm); if (next === RESULTS.GRANTED) return { ok: true }; if (next === RESULTS.BLOCKED) return { ok: false, reason: 'blocked', openSettings }; if (next === RESULTS.LIMITED) return { ok: false, reason: 'limited' }; return { ok: false, reason: 'denied' };}Patrón de UX recomendado
- Botón → explicación breve → pedir permiso: “Para tomar una foto necesitamos acceso a la cámara”.
- Si deniega: muestra un mensaje con alternativa (p. ej., “Elegir de galería” o “Adjuntar archivo”).
- Si está bloqueado: muestra CTA “Abrir Ajustes”.
- No repitas prompts: si el usuario deniega, no vuelvas a pedir inmediatamente en bucle; espera una acción explícita.
Integración de cámara y galería (imagen)
Selección de imagen con react-native-image-picker
Esta librería puede abrir el selector del sistema (galería) o la cámara. El resultado típico incluye assets con uri, fileName, type, fileSize, width, height.
// media.ts (ejemplo orientativo)import { launchCamera, launchImageLibrary, ImageLibraryOptions, CameraOptions } from 'react-native-image-picker';export async function pickFromGallery() { const options: ImageLibraryOptions = { mediaType: 'photo', selectionLimit: 1, quality: 0.8, // compresión básica includeBase64: false, }; const res = await launchImageLibrary(options); if (res.didCancel) return null; if (res.errorCode) throw new Error(res.errorMessage || res.errorCode); const asset = res.assets?.[0]; if (!asset?.uri) return null; return asset;}export async function takePhoto() { const options: CameraOptions = { mediaType: 'photo', cameraType: 'front', quality: 0.8, saveToPhotos: false, }; const res = await launchCamera(options); if (res.didCancel) return null; if (res.errorCode) throw new Error(res.errorMessage || res.errorCode); const asset = res.assets?.[0]; if (!asset?.uri) return null; return asset;}Fallos típicos y cómo prevenirlos
- URI inválida o sin permisos: valida
asset.uriantes de renderizar/subir. - Imagen enorme: limita calidad y, si tu caso lo requiere, redimensiona con una librería de manipulación de imágenes (si no, al menos controla tamaño máximo permitido).
- Bloqueo por memoria: evita
base64para previews; usauriy un placeholder. - iOS Info.plist: si faltan descripciones de uso, la app puede fallar al solicitar acceso. Asegúrate de declarar motivos para cámara/fotos.
Adjuntar archivos (PDF, DOCX, etc.)
Selección de documentos con react-native-document-picker
Para adjuntar archivos a un formulario, el selector del sistema suele ser la opción más robusta. Te devuelve metadatos como nombre, tipo MIME y tamaño.
// files.ts (ejemplo orientativo)import DocumentPicker from 'react-native-document-picker';export async function pickDocument() { try { const res = await DocumentPicker.pickSingle({ type: [DocumentPicker.types.pdf, DocumentPicker.types.images, DocumentPicker.types.plainText], }); return res; // { uri, name, type, size } } catch (e: any) { if (DocumentPicker.isCancel(e)) return null; throw e; }}Validaciones recomendadas
- Tamaño máximo: p. ej. 5MB para imágenes, 10MB para PDF.
- Tipo permitido: valida MIME (
image/*,application/pdf). - Nombre: muestra el nombre y permite “quitar adjunto”.
Optimización de carga de imágenes: tamaños, placeholders y experiencia
Estrategia simple y efectiva
- Preview local inmediata: renderiza la imagen con
uriapenas se selecciona. - Placeholder mientras carga: muestra un contenedor con color neutro y un indicador de carga.
- Evita re-render costoso: memoriza el componente de preview si depende de props grandes.
- Limita dimensiones: para avatar, recorta/ajusta a un tamaño fijo (p. ej. 128x128) en UI; en backend, procesa a tamaños estándar.
Ejemplo de componente de avatar con placeholder
import React from 'react';import { View, Image, ActivityIndicator, StyleSheet } from 'react-native';export function AvatarPreview({ uri, loading }: { uri?: string; loading?: boolean }) { return ( <View style={styles.wrap}> {uri ? ( <Image source={{ uri }} style={styles.img} resizeMode="cover" onLoadStart={() => {}} onError={() => {}} /> ) : ( <View style={styles.placeholder} /> )} {loading ? <View style={styles.overlay}><ActivityIndicator /></View> : null} </View> );}const styles = StyleSheet.create({ wrap: { width: 128, height: 128, borderRadius: 64, overflow: 'hidden' }, img: { width: '100%', height: '100%' }, placeholder: { flex: 1, backgroundColor: '#E5E7EB' }, overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.15)' },});Práctica guiada: foto de perfil o adjuntar archivo a un formulario
Implementarás un flujo completo con: selección (cámara/galería/archivo), validaciones, estados de UI, y feedback visual. El ejemplo asume que ya tienes un formulario funcionando y solo añadirás el campo “Adjunto” o “Avatar”.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
1) Define el modelo de adjunto y estados de UI
type SelectedImage = { uri: string; fileName?: string; type?: string; fileSize?: number; width?: number; height?: number;};type SelectedDoc = { uri: string; name: string; type?: string; size?: number;};type Attachment = | { kind: 'image'; data: SelectedImage } | { kind: 'doc'; data: SelectedDoc };type AttachState = { attachment: Attachment | null; busy: boolean; error: string | null;};2) Crea acciones: tomar foto, elegir de galería, elegir documento
La clave es: pedir permiso solo al usar cámara; galería/document picker suelen funcionar con selector del sistema, pero igual debes manejar errores.
import React, { useState } from 'react';import { View, Text, Pressable, Alert } from 'react-native';import { ensureCameraPermission } from './permissions';import { takePhoto, pickFromGallery } from './media';import { pickDocument } from './files';export function AttachmentField() { const [state, setState] = useState<AttachState>({ attachment: null, busy: false, error: null }); const setBusy = (busy: boolean) => setState(s => ({ ...s, busy })); const setError = (error: string | null) => setState(s => ({ ...s, error })); async function onTakePhoto() { setError(null); const perm = await ensureCameraPermission(); if (!perm.ok) { if (perm.reason === 'blocked') { Alert.alert('Permiso bloqueado', 'Activa el acceso a la cámara en Ajustes.', [ { text: 'Cancelar', style: 'cancel' }, { text: 'Abrir Ajustes', onPress: () => perm.openSettings?.() }, ]); return; } Alert.alert('Permiso requerido', 'No se puede abrir la cámara sin permiso.'); return; } try { setBusy(true); const img = await takePhoto(); if (!img) return; validateImageOrThrow(img); setState(s => ({ ...s, attachment: { kind: 'image', data: img }, error: null })); } catch (e: any) { setError(e.message || 'No se pudo tomar la foto'); } finally { setBusy(false); } } async function onPickGallery() { setError(null); try { setBusy(true); const img = await pickFromGallery(); if (!img) return; validateImageOrThrow(img); setState(s => ({ ...s, attachment: { kind: 'image', data: img }, error: null })); } catch (e: any) { setError(e.message || 'No se pudo seleccionar la imagen'); } finally { setBusy(false); } } async function onPickDoc() { setError(null); try { setBusy(true); const doc = await pickDocument(); if (!doc) return; validateDocOrThrow(doc); setState(s => ({ ...s, attachment: { kind: 'doc', data: doc }, error: null })); } catch (e: any) { setError(e.message || 'No se pudo seleccionar el archivo'); } finally { setBusy(false); } } function onClear() { setState({ attachment: null, busy: false, error: null }); } return ( <View style={{ gap: 12 }}> <Text style={{ fontWeight: '600' }}>Adjunto</Text> <View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}> <Pressable onPress={onTakePhoto} disabled={state.busy}><Text>Tomar foto</Text></Pressable> <Pressable onPress={onPickGallery} disabled={state.busy}><Text>Galería</Text></Pressable> <Pressable onPress={onPickDoc} disabled={state.busy}><Text>Archivo</Text></Pressable> <Pressable onPress={onClear} disabled={state.busy || !state.attachment}><Text>Quitar</Text></Pressable> </View> {state.error ? <Text style={{ color: 'crimson' }}>{state.error}</Text> : null} <AttachmentPreview attachment={state.attachment} busy={state.busy} /> </View> );}function validateImageOrThrow(img: SelectedImage) { const maxBytes = 5 * 1024 * 1024; if (img.fileSize != null && img.fileSize > maxBytes) { throw new Error('La imagen supera 5MB'); } if (img.type && !img.type.startsWith('image/')) { throw new Error('Formato de imagen no permitido'); }}function validateDocOrThrow(doc: SelectedDoc) { const maxBytes = 10 * 1024 * 1024; if (doc.size != null && doc.size > maxBytes) { throw new Error('El archivo supera 10MB'); } if (doc.type && !['application/pdf', 'text/plain'].includes(doc.type) && !doc.type.startsWith('image/')) { throw new Error('Tipo de archivo no permitido'); }}3) Preview con feedback visual (imagen vs documento)
import React from 'react';import { View, Text, Image, ActivityIndicator } from 'react-native';export function AttachmentPreview({ attachment, busy }: { attachment: any; busy: boolean }) { if (!attachment) { return ( <View style={{ height: 120, borderRadius: 12, backgroundColor: '#F3F4F6', alignItems: 'center', justifyContent: 'center' }}> <Text style={{ color: '#6B7280' }}>Sin adjunto</Text> </View> ); } if (attachment.kind === 'image') { return ( <View style={{ width: 128, height: 128, borderRadius: 64, overflow: 'hidden' }}> <Image source={{ uri: attachment.data.uri }} style={{ width: '100%', height: '100%' }} /> {busy ? ( <View style={{ position: 'absolute', inset: 0, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.15)' }}> <ActivityIndicator /> </View> ) : null} </View> ); } return ( <View style={{ padding: 12, borderRadius: 12, backgroundColor: '#F9FAFB', borderWidth: 1, borderColor: '#E5E7EB' }}> <Text style={{ fontWeight: '600' }} numberOfLines={1}>{attachment.data.name}</Text> <Text style={{ color: '#6B7280' }}>{attachment.data.type || 'tipo desconocido'}</Text> {busy ? <ActivityIndicator style={{ marginTop: 8 }} /> : null} </View> );}4) Envío al backend: multipart/form-data (patrón común)
Para subir imagen o documento, normalmente usarás FormData. Asegúrate de enviar uri, type y name. En iOS, algunas URIs requieren normalización; si tu backend falla, revisa que el type sea correcto y que el name exista.
async function submitForm(values: any, attachment: Attachment | null) { const fd = new FormData(); fd.append('displayName', values.displayName); if (attachment?.kind === 'image') { const img = attachment.data; fd.append('avatar', { uri: img.uri, type: img.type || 'image/jpeg', name: img.fileName || 'avatar.jpg', } as any); } if (attachment?.kind === 'doc') { const doc = attachment.data; fd.append('file', { uri: doc.uri, type: doc.type || 'application/octet-stream', name: doc.name || 'adjunto', } as any); } const res = await fetch('https://tu-api.com/profile', { method: 'POST', body: fd, headers: { // No fuerces Content-Type; fetch lo setea con boundary en muchos casos // 'Content-Type': 'multipart/form-data' }, }); if (!res.ok) { const text = await res.text(); throw new Error(text || 'Error al subir'); } return res.json();}5) Manejo de fallos típicos en el envío
| Problema | Causa común | Mitigación |
|---|---|---|
| El backend recibe archivo vacío | uri incorrecta o falta name/type | Valida uri, setea type y name por defecto |
| La app se queda “cargando” | No se resetea estado en finally | Usa try/catch/finally y estados separados |
| Permiso bloqueado | Usuario marcó “no volver a preguntar” / Ajustes | Detecta blocked y ofrece Abrir Ajustes |
| Imagen demasiado pesada | Fotos de cámara en alta resolución | Limita quality, valida tamaño y rechaza con feedback |
| Crash o freeze al previsualizar | Uso de base64 o renders innecesarios | Evita base64, usa uri y placeholder |
Checklist de implementación (rápido)
- Declarar descripciones de permisos en iOS (cámara/fotos) y configurar Android según necesidades.
- Pedir permisos solo cuando el usuario toca “Tomar foto”.
- Modelar estados:
busy,error,attachment. - Validar tipo y tamaño antes de subir.
- Preview inmediata con placeholder y opción “Quitar”.
- Manejar
blockedcon CTA a Ajustes y alternativa (galería/archivo).