Programación en Swift y SwiftUI para iOS Developers

Cómo usar la cámara en SwiftUI

Si eres un iOS Developer que busca crear experiencias inmersivas y personalizadas, tarde o temprano te enfrentarás a un desafío fascinante: integrar y controlar la cámara en SwiftUI.

Históricamente, muchos desarrolladores optaban por la solución rápida: usar UIImagePickerController o PhotosUI. Aunque son excelentes para tareas básicas, no te permiten crear una interfaz de usuario personalizada (un viewfinder a medida), aplicar filtros en tiempo real, o escanear códigos de barras de forma nativa. Para tener control total en la programación Swift, necesitamos descender un nivel y utilizar el potente framework AVFoundation.

En este extenso tutorial, aprenderás cómo construir una cámara completamente personalizada desde cero utilizando AVFoundation y cómo integrarla fluidamente en SwiftUI. Exploraremos cómo estructurar este código en Swift y cómo adaptarlo en Xcode para iOS, macOS y las realidades técnicas de watchOS.


1. ¿Por qué usar AVFoundation para la Cámara en SwiftUI?

En el ecosistema de SwiftUI, Apple nos empuja hacia soluciones declarativas. Sin embargo, el hardware de la cámara es intrínsecamente imperativo y basado en flujos de datos continuos.

Como iOS Developer, usar AVFoundation te da los superpoderes para:

  • Diseñar interfaces 100% personalizadas: Puedes poner botones, superposiciones y animaciones exactas sobre la vista previa de la cámara.
  • Procesamiento de fotogramas (Frames): Extraer cada fotograma de video en tiempo real para usar CoreML (Inteligencia Artificial) o Vision (reconocimiento de texto/rostros).
  • Control de Hardware: Ajustar manualmente el enfoque, la exposición, el balance de blancos y el flash.

2. Arquitectura de una Sesión de Captura

Antes de escribir código en Swift, debemos entender cómo funciona AVFoundation. Piensa en ello como un sistema de tuberías.

La arquitectura se compone de cuatro elementos principales:

  1. AVCaptureSession: Es el “cerebro” o motor. Administra el flujo de datos desde las entradas hasta las salidas.
  2. AVCaptureDevice: Representa el hardware físico (la cámara frontal, la cámara trasera gran angular, el micrófono).
  3. AVCaptureDeviceInput: Es el adaptador que conecta el dispositivo de hardware (la cámara) a la sesión de captura.
  4. AVCaptureOutput: Es el destino final de los datos. Puede ser una salida de foto (AVCapturePhotoOutput), una salida de video en bruto (AVCaptureVideoDataOutput) o una salida de película (AVCaptureMovieFileOutput).

3. Paso 0: Permisos en Xcode (El Info.plist)

Ninguna aplicación de Swift puede acceder a la cámara sin el permiso explícito del usuario. Si intentas ejecutar el código sin este paso, tu aplicación se cerrará inesperadamente (crash).

  1. Abre tu proyecto en Xcode.
  2. Ve a la configuración de tu Target, pestaña Info.
  3. Añade una nueva clave llamada Privacy - Camera Usage Description (NSCameraUsageDescription).
  4. En el valor, escribe un mensaje claro para el usuario, por ejemplo: “Necesitamos acceso a la cámara para que puedas tomar fotos increíbles de tu entorno.”

4. Construyendo el Motor: CameraManager

Vamos a crear una clase que maneje toda la lógica pesada de AVFoundation. Utilizaremos el protocolo ObservableObject para que nuestra interfaz en SwiftUI pueda reaccionar a sus cambios.

Crea un nuevo archivo de Swift llamado CameraManager.swift.

import Foundation
import AVFoundation
import Combine

class CameraManager: ObservableObject {
    // 1. La sesión de captura principal
    let session = AVCaptureSession()
    
    // 2. Salida para tomar fotos
    private let photoOutput = AVCapturePhotoOutput()
    
    // 3. Estados para la UI
    @Published var isCameraAuthorized = false
    @Published var capturedImage: Data?
    
    // Hilo dedicado para no bloquear la Interfaz de Usuario
    private let sessionQueue = DispatchQueue(label: "com.tudominio.cameraQueue")
    
    init() {
        checkPermissions()
    }
    
    // MARK: - Permisos
    private func checkPermissions() {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            self.isCameraAuthorized = true
            self.setupCamera()
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
                DispatchQueue.main.async {
                    self?.isCameraAuthorized = granted
                    if granted { self?.setupCamera() }
                }
            }
        default:
            self.isCameraAuthorized = false
        }
    }
    
    // MARK: - Configuración de la Cámara
    private func setupCamera() {
        sessionQueue.async { [weak self] in
            guard let self = self else { return }
            
            self.session.beginConfiguration()
            
            // a. Buscar el dispositivo (Cámara trasera por defecto)
            guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
                print("No se encontró una cámara")
                self.session.commitConfiguration()
                return
            }
            
            // b. Crear la entrada
            do {
                let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice)
                if self.session.canAddInput(videoDeviceInput) {
                    self.session.addInput(videoDeviceInput)
                }
            } catch {
                print("Error al crear input: \(error.localizedDescription)")
                self.session.commitConfiguration()
                return
            }
            
            // c. Crear la salida de foto
            if self.session.canAddOutput(self.photoOutput) {
                self.session.addOutput(self.photoOutput)
            }
            
            self.session.commitConfiguration()
        }
    }
    
    // MARK: - Control de la Sesión
    func startSession() {
        sessionQueue.async {
            if !self.session.isRunning {
                self.session.startRunning()
            }
        }
    }
    
    func stopSession() {
        sessionQueue.async {
            if self.session.isRunning {
                self.session.stopRunning()
            }
        }
    }
    
    // MARK: - Captura de Foto
    func capturePhoto() {
        let settings = AVCapturePhotoSettings()
        // Aquí puedes configurar el flash, alta resolución, etc.
        self.photoOutput.capturePhoto(with: settings, delegate: self)
    }
}

// Implementación del delegado para recibir la foto
extension CameraManager: AVCapturePhotoCaptureDelegate {
    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
        guard let data = photo.fileDataRepresentation() else { return }
        
        DispatchQueue.main.async {
            self.capturedImage = data
        }
    }
}

Notas clave de programación Swift en este módulo:

  • Hilos (Threading): session.startRunning() es una operación bloqueante. Si la llamas en el hilo principal (Main Thread), tu aplicación se congelará momentáneamente. Por eso, como buen iOS Developer, hemos creado una sessionQueue dedicada.
  • Manejo de Permisos: Antes de intentar acceder al hardware, debemos verificar el estado de autorización de manera asíncrona.

5. El Puente hacia SwiftUI: CameraPreviewView

SwiftUI no tiene una vista nativa (todavía) para mostrar el flujo de la cámara. Necesitamos usar una capa especializada de CoreAnimation llamada AVCaptureVideoPreviewLayer y envolverla utilizando el protocolo UIViewRepresentable (en iOS) o NSViewRepresentable (en macOS).

Crea un archivo llamado CameraPreviewView.swift. Aquí utilizaremos directivas del compilador para asegurar el funcionamiento multiplataforma en Xcode.

import SwiftUI
import AVFoundation

#if os(iOS)
struct CameraPreviewView: UIViewRepresentable {
    let session: AVCaptureSession
    
    // Creamos una subclase de UIView que está respaldada por una capa de video
    class VideoPreviewView: UIView {
        override class var layerClass: AnyClass {
            AVCaptureVideoPreviewLayer.self
        }
        
        var videoPreviewLayer: AVCaptureVideoPreviewLayer {
            return layer as! AVCaptureVideoPreviewLayer
        }
    }
    
    func makeUIView(context: Context) -> VideoPreviewView {
        let view = VideoPreviewView()
        view.videoPreviewLayer.session = session
        view.videoPreviewLayer.videoGravity = .resizeAspectFill
        return view
    }
    
    func updateUIView(_ uiView: VideoPreviewView, context: Context) {
        // Aquí manejaríamos rotaciones de pantalla si fuera necesario
    }
}
#elseif os(macOS)
struct CameraPreviewView: NSViewRepresentable {
    let session: AVCaptureSession
    
    class VideoPreviewView: NSView {
        var videoPreviewLayer: AVCaptureVideoPreviewLayer!
        
        override init(frame frameRect: NSRect) {
            super.init(frame: frameRect)
            self.wantsLayer = true
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func makeBackingLayer() -> CALayer {
            videoPreviewLayer = AVCaptureVideoPreviewLayer()
            videoPreviewLayer.videoGravity = .resizeAspectFill
            return videoPreviewLayer
        }
    }
    
    func makeNSView(context: Context) -> VideoPreviewView {
        let view = VideoPreviewView()
        view.videoPreviewLayer.session = session
        return view
    }
    
    func updateNSView(_ nsView: VideoPreviewView, context: Context) {
    }
}
#endif

Con este código, hemos creado un puente perfecto que inyecta los datos visuales generados por AVFoundation directamente dentro de nuestro ecosistema declarativo de SwiftUI.


6. Construyendo la Interfaz Principal de la Cámara en SwiftUI

Ahora viene la parte divertida: ensamblar los bloques. Vamos a usar un ZStack para colocar nuestros botones personalizados sobre la vista previa en vivo de la cámara en SwiftUI.

Navega a tu ContentView.swift o crea una nueva vista llamada CameraMainView.swift.

import SwiftUI

struct CameraMainView: View {
    @StateObject private var cameraManager = CameraManager()
    
    var body: some View {
        ZStack {
            // Fondo oscuro para la cámara
            Color.black.ignoresSafeArea()
            
            if cameraManager.isCameraAuthorized {
                // 1. La capa de previsualización en el fondo
                CameraPreviewView(session: cameraManager.session)
                    .ignoresSafeArea()
                    .onAppear {
                        cameraManager.startSession()
                    }
                    .onDisappear {
                        cameraManager.stopSession()
                    }
                
                // 2. Elementos de Interfaz de Usuario superpuestos
                VStack {
                    Spacer()
                    
                    // Controles de la cámara
                    HStack {
                        Spacer()
                        
                        // Botón de Captura
                        Button(action: {
                            cameraManager.capturePhoto()
                        }) {
                            Circle()
                                .stroke(Color.white, lineWidth: 3)
                                .frame(width: 70, height: 70)
                                .overlay(
                                    Circle()
                                        .fill(Color.white)
                                        .frame(width: 60, height: 60)
                                )
                        }
                        .padding(.bottom, 30)
                        
                        Spacer()
                    }
                }
                
                // 3. Mostrar la foto capturada si existe
                if let capturedImage = cameraManager.capturedImage, 
                   #available(iOS 16.0, macOS 13.0, *),
                   let image = platformImage(from: capturedImage) {
                   
                    VStack {
                        HStack {
                            Spacer()
                            Image(nsImageOrUIImage: image)
                                .resizable()
                                .scaledToFill()
                                .frame(width: 100, height: 150)
                                .cornerRadius(10)
                                .shadow(radius: 5)
                                .padding()
                                .onTapGesture {
                                    // Descartar la foto
                                    withAnimation {
                                        cameraManager.capturedImage = nil
                                    }
                                }
                        }
                        Spacer()
                    }
                }
                
            } else {
                // Pantalla de Permisos Denegados
                VStack(spacing: 20) {
                    Image(systemName: "camera.slash")
                        .font(.system(size: 50))
                        .foregroundColor(.gray)
                    Text("No hay acceso a la cámara")
                        .font(.title3)
                        .foregroundColor(.white)
                    Text("Por favor, habilita el acceso en Configuración.")
                        .foregroundColor(.gray)
                }
            }
        }
    }
    
    // Función auxiliar multiplataforma para procesar la Data a Imagen
    #if os(iOS)
    func platformImage(from data: Data) -> UIImage? {
        UIImage(data: data)
    }
    #elseif os(macOS)
    func platformImage(from data: Data) -> NSImage? {
        NSImage(data: data)
    }
    #endif
}

// Extensión para facilitar la inicialización multiplataforma en SwiftUI
extension Image {
    #if os(iOS)
    init(nsImageOrUIImage image: UIImage) {
        self.init(uiImage: image)
    }
    #elseif os(macOS)
    init(nsImageOrUIImage image: NSImage) {
        self.init(nsImage: image)
    }
    #endif
}

Analizando nuestra Vista:

  1. Control del Ciclo de Vida: Usamos .onAppear y .onDisappear para encender y apagar el motor de la cámara. Esto es crítico para ahorrar batería y no mantener bloqueado el hardware cuando el usuario navega a otra pantalla.
  2. Multiplataforma Nativa: Hemos integrado helpers con #if os(iOS) y #elseif os(macOS) para que UIImage y NSImage convivan pacíficamente. Esta es la marca de un verdadero iOS Developer sénior operando en Xcode.

7. La Realidad Técnica: iOS vs. macOS vs. watchOS

El título de este artículo prometía abordar iOS, macOS y watchOS. Hemos implementado una arquitectura sólida y un puente representable que funciona maravillosamente tanto en un iPhone como en un Mac.

Sin embargo, como desarrollador, debes conocer las limitaciones del hardware de Apple. Aquí va una dosis de realidad técnica vital:

En iOS y iPadOS

El código que acabamos de escribir funciona perfectamente. Tienes control total, acceso a lentes ultra gran angulares, teleobjetivos, sensores LiDAR y procesamiento profundo usando Swift.

En macOS

El Mac soporta AVFoundation con una arquitectura muy similar. El puente NSViewRepresentable que escribimos asegura que tu app de SwiftUI detectará la cámara web integrada de tu MacBook o cualquier cámara Continuity (como usar tu iPhone como webcam) de forma nativa.

En watchOS: El Gran Obstáculo

Aquí debemos hacer una corrección técnica muy importante: watchOS NO soporta AVCaptureSession ni las APIs de captura en tiempo real de AVFoundation de la misma manera que iOS.

El Apple Watch no tiene una cámara integrada. Las aplicaciones nativas en watchOS interactúan con la cámara de dos maneras exclusivas:

  1. App de Cámara Remota: Controlando el visor de la cámara del iPhone emparejado (esto lo maneja el sistema, no tu código directo de AVFoundation).
  2. Cámaras en Correas de Terceros (Muy raras): E incluso entonces, la transmisión de video directa a través de AVFoundation para crear un visor personalizado (AVCaptureVideoPreviewLayer) está fuertemente bloqueada o no existe en la API pública de watchOS.

Si en el futuro Apple lanza un Apple Watch con cámara integrada, este mismo paradigma de programación Swift se adaptará, pero actualmente, AVFoundation para captura de video en vivo debe descartarse en la compilación para watchOS en Xcode.


8. Mejores Prácticas para Dominar la Cámara

Para cerrar este extenso tutorial, aquí tienes una lista de verificación mental que debes seguir al integrar una cámara en SwiftUI:

  • Evita Fugas de Memoria: Un flujo de video es pesado. Asegúrate de siempre invalidar o detener (stopRunning()) tu sesión de AVFoundation cuando la vista de SwiftUI desaparece.
  • Rotación del Dispositivo: Por defecto, el AVCaptureVideoPreviewLayer no rota automáticamente cuando giras el teléfono. Tendrás que detectar los cambios de orientación en tu updateUIView y aplicar la transformación a videoPreviewLayer.connection?.videoOrientation.
  • Pellizcar para hacer Zoom: Puedes añadir un MagnificationGesture a tu vista principal en SwiftUI y actualizar el parámetro videoZoomFactor de tu AVCaptureDevice. ¡Inténtalo como reto personal!
  • Gestión de Errores Exhaustiva: Las cámaras pueden fallar. Otra app podría estar usándola, el dispositivo podría estar demasiado caliente y el sistema bloquear el acceso. En un entorno de producción, asegúrate de capturar (catch) y mostrar notificaciones amigables en tu UI cuando algo falle.

Conclusión

Construir una cámara en SwiftUI combinando el poder subyacente de AVFoundation es uno de los proyectos más gratificantes en la programación Swift. Te obliga a entender cómo interactúa el mundo declarativo (interfaces de usuario rápidas y reactivas) con el mundo imperativo (flujos de datos de hardware en tiempo real).

Si tienes cualquier duda sobre este artículo, contacta conmigo y estaré encantado de ayudarte 🙂. Puedes contactar conmigo en mi perfil de X o en mi perfil de Instagram

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Cómo personalizar TabView en SwiftUI

Next Article

Cómo usar Face ID en SwiftUI

Related Posts