PhotosPicker, cámara y permisos
Elegir fotos sin pedir un solo permiso, entender el acceso limitado a la fototeca cuando PhotoKit es inevitable, capturar con AVFoundation sin bloquear el hilo principal, y las claves de Info.plist que deciden si tu app pasa la revisión.
Pedirle al usuario acceso a sus fotos es una de las decisiones más caras que toma una app, y casi siempre es innecesaria. Apple lleva años construyendo intermediarios que te dan exactamente lo que necesitas sin darte nada más: eliges una foto, recibes esa foto, y la fototeca sigue siendo privada. Saber cuándo basta con el intermediario y cuándo hace falta la llave completa es la diferencia entre una app que el usuario acepta y una que rechaza en el primer diálogo.
- Usar
PhotosPickersin declarar ningún permiso. - Entender el acceso limitado de PhotoKit y sus trampas.
- Montar una sesión de captura con AVFoundation correctamente.
- Escribir las claves de
Info.plistque exige la revisión.
PhotosPicker: el permiso que no pides
PhotosPicker es la envoltura SwiftUI de PHPickerViewController, y su propiedad crucial es que se ejecuta en otro proceso. La interfaz que ve el usuario no pertenece a tu app: es del sistema, dibujada encima de la tuya. Tu proceso jamás toca la fototeca; recibe una copia de lo que el usuario eligió.
import PhotosUI
struct SelectorDeFoto: View {
@State private var seleccion: PhotosPickerItem?
@State private var imagen: Image?
@Environment(\.displayScale) private var escala
var body: some View {
PhotosPicker(selection: $seleccion, matching: .images) {
Label("Elegir foto", systemImage: "photo.on.rectangle")
}
.onChange(of: seleccion) { _, nuevo in
Task {
guard let datos = try? await nuevo?.loadTransferable(type: Data.self),
let reducida = reducir(datos: datos, lado: 300, escala: escala)
else { return }
imagen = Image(uiImage: reducida)
}
}
}
}
Cero claves en Info.plist. Cero diálogos. Cero rechazos posibles. El filtro matching: acepta composiciones (.any(of: [.images, .videos]), .not(.screenshots)) y hay variantes para selección múltiple con maxSelectionCount y selectionBehavior: .ordered.
Fíjate en que loadTransferable devuelve Data, no un UIImage. Es deliberado: si pidieras Image.self estarías decodificando el original completo antes de poder reducirlo, con el coste de la lección anterior. Recibe bytes, reduce, y solo entonces construye la imagen.
Hay una arquitectura de privacidad detrás de todo esto que conviene ver entera, porque se repite en toda la plataforma. Apple distingue dos modelos de acceso a datos del usuario: el permiso, donde tu app obtiene una llave permanente a un dominio completo, y el intermediario, donde un proceso del sistema le muestra al usuario sus datos, recoge su elección y te entrega solo eso. PhotosPicker es un intermediario; también lo son el selector de documentos, el de contactos con ContactAccessButton, la hoja de compartir y el selector de ubicación de una sola vez. El patrón se llama a veces privacidad por arquitectura: no es que la app prometa portarse bien, es que técnicamente no puede portarse mal.
La consecuencia para ti no es solo ética, es de producto y es medible. Cada diálogo de permiso es un embudo con una salida irreversible: si el usuario pulsa “No permitir”, tu código no puede volver a preguntar nunca; solo puedes mandarlo a Ajustes, y ese camino tiene una tasa de conversión ridícula. Un permiso pedido en el momento equivocado —al arrancar, sin contexto, antes de que el usuario entienda por qué— no cuesta una fricción: cuesta la funcionalidad entera, para siempre, en ese dispositivo. Por eso la regla de diseño correcta no es “pide bien el permiso”, sino “revisa si existe un intermediario que lo haga innecesario, y si no existe, pide en el instante exacto en que el usuario ya quiere el resultado”. El permiso es la última opción, no la primera.
flowchart TD
Q1{Necesitas leer toda la fototeca o escribir en ella} -->|no| PK[PhotosPicker sin permisos]
Q1 -->|si| Q2{Basta con guardar lo que crea la app}
Q2 -->|si| ADD[NSPhotoLibraryAddUsageDescription]
Q2 -->|no| PHK[PhotoKit con autorizacion y modo limitado]
style PK fill:#a6e3a1,color:#11111b
style PHK fill:#f9e2af,color:#11111bCuando PhotoKit es inevitable: el acceso limitado
Hay casos legítimos en los que el intermediario no basta: una app que sincroniza la biblioteca completa, que observa cambios, que lee metadatos de todos los álbumes. Ahí entras en PhotoKit y en el sistema de autorización real.
import Photos
func pedirAcceso() async -> PHAuthorizationStatus {
await PHPhotoLibrary.requestAuthorization(for: .readWrite)
}
Lo que casi nadie maneja bien es el estado .limited, introducido en iOS 14 y hoy elegido por una fracción enorme de usuarios. Significa: tienes acceso de PhotoKit, pero solo a un subconjunto que el usuario seleccionó. Tus consultas con PHAsset.fetchAssets devuelven ese subconjunto y nada indica que falten fotos. Una app que asume acceso total y encuentra doce fotos concluye alegremente que el usuario tiene doce fotos.
Dos herramientas para tratarlo con dignidad. La primera es ofrecer explícitamente ampliar la selección desde tu interfaz, con PHPhotoLibrary.shared().presentLimitedLibraryPicker(from:), bajo control del usuario y no por sorpresa. La segunda es la clave PHPhotoLibraryPreventAutomaticLimitedAccessAlert en Info.plist puesta a true: sin ella, el sistema muestra por su cuenta un recordatorio molesto cada vez que arrancas, porque asume que no has previsto el caso. Ponerla es asumir la responsabilidad de gestionarlo tú.
Cámara: AVFoundation sin bloquear la interfaz
No existe una cámara nativa en SwiftUI. Tienes dos caminos: UIImagePickerController con sourceType = .camera envuelto en un UIViewControllerRepresentable, que es la vía rápida y sin control, o AVCaptureSession, que es la vía real.
@Observable
final class Camara {
let sesion = AVCaptureSession()
private let salida = AVCapturePhotoOutput()
private let cola = DispatchQueue(label: "camara.configuracion")
func arrancar() async {
guard await AVCaptureDevice.requestAccess(for: .video) else { return }
cola.async { [sesion, salida] in
sesion.beginConfiguration()
sesion.sessionPreset = .photo
if let dispositivo = AVCaptureDevice.default(.builtInWideAngleCamera,
for: .video, position: .back),
let entrada = try? AVCaptureDeviceInput(device: dispositivo),
sesion.canAddInput(entrada) {
sesion.addInput(entrada)
}
if sesion.canAddOutput(salida) { sesion.addOutput(salida) }
sesion.commitConfiguration()
sesion.startRunning() // bloqueante: jamas en el hilo principal
}
}
}
Tres reglas que la documentación menciona de pasada y que la práctica cobra caras. startRunning() es síncrono y bloqueante, puede tardar cientos de milisegundos: llamarlo en el hilo principal congela la app justo cuando el usuario abre la cámara. Toda reconfiguración va entre beginConfiguration() y commitConfiguration(), en la misma cola en serie, o la sesión se corrompe. Y la vista previa es una capa de Core Animation, así que necesitas un puente:
struct VistaPrevia: UIViewRepresentable {
let sesion: AVCaptureSession
func makeUIView(context: Context) -> UIView {
let vista = UIView()
let capa = AVCaptureVideoPreviewLayer(session: sesion)
capa.videoGravity = .resizeAspectFill
vista.layer.addSublayer(capa)
return vista
}
func updateUIView(_ vista: UIView, context: Context) {
vista.layer.sublayers?.first?.frame = vista.bounds
}
}
Los permisos, con precisión
NSCameraUsageDescription
Obligatoria en cuanto enlazas contra la cámara. Sin ella la app no se cae al pedir permiso: se cae al intentar usarla, y solo en dispositivo.
NSMicrophoneUsageDescription
Necesaria si grabas vídeo con sonido, aunque nunca toques el micro directamente. Añadir una entrada de audio a la sesión ya la exige.
NSPhotoLibraryUsageDescription
Solo si usas PhotoKit. Con PhotosPicker no hace falta, y declararla sin usarla llama la atención de la revisión.
NSPhotoLibraryAddUsageDescription
Para guardar en la fototeca sin poder leerla. Es la mitad del permiso, y casi siempre la mitad que de verdad necesitas.
El texto de la cadena importa. La revisión rechaza las genéricas del tipo “esta app necesita acceso a la cámara”: tiene que decir para qué, en el idioma del usuario y en términos de beneficio concreto, por ejemplo “para escanear el código de barras de un libro y añadirlo a tu biblioteca”. Y son cadenas localizables: van en InfoPlist.strings, no codificadas en un solo idioma.
El patrón de solicitar cámara y fototeca en el primer lanzamiento maximiza los rechazos y no tiene ninguna ventaja técnica: el estado .notDetermined no caduca. Pide en el momento en que el usuario pulsa el botón que necesita esa capacidad, cuando la intención ya está expresada y la razón es evidente sin explicarla.
- Monta un
PhotosPickercon selección múltiple ordenada que cargueDatay reduzca cada elemento antes de mostrarlo. - Comprueba en el proyecto que no hay ninguna clave de fototeca en
Info.plisty que aun así funciona. Explica por qué. - Concede acceso limitado a una app que use PhotoKit y observa qué devuelve
PHAsset.fetchAssets. Diseña cómo se lo comunicarías al usuario. - Implementa la clase
Camarade esta lección y mide con Instruments cuánto tardastartRunning()en tu dispositivo. - Reescribe la cadena de
NSCameraUsageDescriptionde un proyecto tuyo para que supere el criterio de la revisión.