Programación en Swift y SwiftUI para iOS Developers

Cómo cargar un JSON en SwiftUI

Introducción: El Oxígeno de las Apps Modernas

Si la interfaz de usuario (UI) es el cuerpo de una aplicación, los datos son su oxígeno. En el desarrollo moderno para el ecosistema de Apple, ya sea que estés creando una app de productividad para macOS, una utilidad de salud para watchOS o la próxima red social en iOS, es casi seguro que tus datos provendrán de una API RESTful. Y el lenguaje universal de estas APIs es JSON (JavaScript Object Notation).

Con la llegada de SwiftUI y la madurez de Swift 6, el proceso de convertir ese texto plano que viaja por la red en objetos tipados y seguros (lo que llamamos parsing o decodificación) ha pasado de ser una tarea tediosa llena de código repetitivo a una experiencia elegante y segura.

En este tutorial de largo formato, no solo aprenderemos a usar JSONDecoder. Vamos a construir una arquitectura de datos robusta, escalable y agnóstica a la plataforma, capaz de manejar errores con gracia y aprovechar la concurrencia moderna de Swift (async/await).


Capítulo 1: Entendiendo la Magia de Codable

Antes de escribir una sola línea de interfaz en SwiftUI, debemos entender los cimientos. Hace años, en los tiempos de Objective-C o las primeras versiones de Swift, el parsing implicaba mapear manualmente diccionarios [String: Any]. Era propenso a errores y difícil de mantener.

Hoy, tenemos el protocolo Codable.

1.1 ¿Qué es realmente Codable?

Codable es en realidad un typealias que combina dos protocolos: Encodable (para convertir tu código a JSON) y Decodable(para convertir JSON a tu código).

typealias Codable = Decodable & Encodable

La belleza de este sistema es que el compilador de Swift hace el 90% del trabajo pesado. Si las propiedades de tu estructura (struct) coinciden con las claves del JSON, Swift sintetiza el código de decodificación automáticamente.

1.2 Structs vs Clases para Modelos de Datos

En SwiftUI, la inmutabilidad y el paso por valor son reyes. Por eso, casi siempre modelaremos nuestros datos JSON usando struct en lugar de class. Esto asegura seguridad en hilos (thread safety) y un rendimiento óptimo al actualizar las vistas.


Capítulo 2: Modelado de Datos y CodingKeys

Imaginemos que estamos consumiendo una API de usuarios. Un fragmento del JSON que recibimos se ve así:

{
  "id": 402,
  "full_name": "Ada Lovelace",
  "is_active": true,
  "registered_at": "2025-11-20T10:30:00Z",
  "contact_info": {
    "email": "ada@history.com",
    "phone": null
  }
}

Aquí nos encontramos con el primer problema del mundo real: Snake Case vs Camel Case. Las APIs suelen usar snake_case (guiones bajos), pero en Swift, la convención estricta es camelCase.

2.1 Creando el Modelo

Vamos a crear una estructura que represente este usuario, resolviendo la discrepancia de nombres sin violar las convenciones de Swift.

import Foundation

struct User: Codable, Identifiable {
    let id: Int
    let fullName: String
    let isActive: Bool
    let registeredAt: Date
    let contactInfo: ContactInfo
    
    // Nested struct para objetos anidados
    struct ContactInfo: Codable {
        let email: String
        let phone: String? // Optional, ya que en el JSON es null
    }
    
    // El mapa del tesoro: CodingKeys
    enum CodingKeys: String, CodingKey {
        case id
        case fullName = "full_name"     // Mapeo manual
        case isActive = "is_active"     // Mapeo manual
        case registeredAt = "registered_at"
        case contactInfo = "contact_info"
    }
}

2.2 Estrategias de Decodificación

Aunque CodingKeys nos permite un control total, JSONDecoder ofrece una estrategia automática para convertir snake_case. Si todas tus claves siguen este patrón, puedes ahorrarte el enum CodingKeys configurando el decodificador (veremos esto en el Capítulo 3).

Nota importante sobre Opcionales: Fíjate en phone: String?. Si una clave en el JSON puede venir como null o simplemente no existir, debes declarar la propiedad como opcional en Swift. Si no lo haces, el parsing fallará y lanzará una excepción.


Capítulo 3: La Capa de Red (Networking Layer) Moderna

Aquí es donde muchos tutoriales fallan. A menudo ponen el código de red dentro de la Vista. Nunca hagas esto. Viola el principio de responsabilidad única y hace que tu código sea imposible de testear.

Vamos a crear un APIService genérico utilizando async/await.

3.1 El Servicio Genérico

Este servicio será capaz de descargar y decodificar cualquier tipo de dato que conforme a Decodable.

import Foundation

enum APIError: Error {
    case invalidURL
    case serverError(Int)
    case decodingError(Error)
    case networkError(Error)
}

struct APIService {
    
    // Función genérica 'T' que debe ser Decodable
    func fetch<T: Decodable>(url: URL) async throws -> T {
        
        // 1. La llamada de red
        let (data, response) = try await URLSession.shared.data(from: url)
        
        // 2. Validación del código de estado HTTP
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw APIError.serverError((response as? HTTPURLResponse)?.statusCode ?? 500)
        }
        
        // 3. Configuración del Decodificador
        let decoder = JSONDecoder()
        
        // Estrategia para fechas (ISO 8601 es estándar)
        decoder.dateDecodingStrategy = .iso8601 
        
        // Estrategia para snake_case (opcional si usas CodingKeys, pero útil saberlo)
        // decoder.keyDecodingStrategy = .convertFromSnakeCase
        
        // 4. El Parsing
        do {
            let decodedData = try decoder.decode(T.self, from: data)
            return decodedData
        } catch {
            // Capturamos el error específico de decodificación para debuggear mejor
            print("Error decodificando: \(error)")
            throw APIError.decodingError(error)
        }
    }
}

Por qué este enfoque es mejor:

  1. Reutilizable: Sirve para UserProductOrder, etc.
  2. Limpio: Separa la lógica de red de la lógica de UI.
  3. Moderno: Usa async throws, eliminando el “callback hell” de los closures antiguos (@escaping).

Capítulo 4: Gestión del Estado con @Observable (MVVM)

Con la llegada de las macros en Swift (iOS 17+), la gestión del estado se ha simplificado enormemente. Si todavía soportas versiones anteriores (iOS 15/16), usarías ObservableObject y @Published. Aquí usaremos la sintaxis moderna @Observable.

Necesitamos un ViewModel que actúe como intermediario entre nuestro APIService y la View. Este ViewModel manejará los diferentes estados de la carga de datos: Cargando, Éxito, y Error.

import Foundation
import Observation // Necesario para la macro @Observable

@Observable
class UsersViewModel {
    
    // Estados de la UI
    var users: [User] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
    
    private let service = APIService()
    
    func loadUsers() async {
        // Reiniciamos estados antes de la carga
        isLoading = true
        errorMessage = nil
        
        guard let url = URL(string: "https://api.ejemplo.com/v1/users") else {
            errorMessage = "URL Inválida"
            isLoading = false
            return
        }
        
        do {
            // El compilador infiere que 'T' es [User] por la variable donde guardamos el resultado
            let fetchedUsers: [User] = try await service.fetch(url: url)
            
            // Actualización exitosa
            self.users = fetchedUsers
        } catch {
            // Manejo de errores amigable para el usuario
            if case let APIError.serverError(code) = error {
                self.errorMessage = "Error del servidor: \(code)"
            } else {
                self.errorMessage = "Algo salió mal: \(error.localizedDescription)"
            }
        }
        
        // Finalizamos la carga (se ejecuta siempre gracias a que no usamos return anticipado en el do/catch)
        isLoading = false
    }
}

Capítulo 5: La Interfaz de Usuario en SwiftUI

Ahora que tenemos los datos y la lógica, construir la UI es un placer. SwiftUI es declarativo, lo que significa que describimos la UI para cada estado de nuestros datos.

5.1 Construyendo la Vista Principal

Esta vista funcionará igual en iOS, macOS y watchOS gracias a la versatilidad de los componentes nativos de SwiftUI.

import SwiftUI

struct UsersListView: View {
    // Inyectamos el ViewModel
    @State private var viewModel = UsersViewModel()
    
    var body: some View {
        NavigationStack {
            ZStack {
                // Estado 1: Lista de Usuarios (Éxito)
                if !viewModel.users.isEmpty {
                    List(viewModel.users) { user in
                        UserRow(user: user)
                    }
                    .refreshable {
                        // Pull to refresh nativo
                        await viewModel.loadUsers()
                    }
                }
                
                // Estado 2: Loading
                if viewModel.isLoading {
                    ProgressView("Cargando datos...")
                        .controlSize(.large)
                        .padding()
                        .background(.ultraThinMaterial)
                        .cornerRadius(10)
                }
                
                // Estado 3: Error
                if let error = viewModel.errorMessage {
                    ContentUnavailableView(
                        "Ocurrió un error",
                        systemImage: "exclamationmark.triangle",
                        description: Text(error)
                    )
                    .onTapGesture {
                        Task { await viewModel.loadUsers() }
                    }
                }
            }
            .navigationTitle("Usuarios")
        }
        // El trigger inicial
        .task {
            // .task se cancela automáticamente si la vista desaparece
            await viewModel.loadUsers()
        }
    }
}

// Subvista para mantener el código limpio
struct UserRow: View {
    let user: User
    
    var body: some View {
        HStack(alignment: .top) {
            // Avatar placeholder
            Circle()
                .fill(user.isActive ? Color.green : Color.gray)
                .frame(width: 12, height: 12)
                .padding(.top, 6)
            
            VStack(alignment: .leading, spacing: 4) {
                Text(user.fullName)
                    .font(.headline)
                
                Text(user.contactInfo.email)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                
                Text("Registrado: \(user.registeredAt.formatted(date: .abbreviated, time: .omitted))")
                    .font(.caption2)
                    .foregroundStyle(.tertiary)
            }
        }
        .padding(.vertical, 4)
    }
}

5.2 Análisis de la Vista

  • .task modifier: Es el reemplazo moderno de .onAppear para tareas asíncronas. Tiene la ventaja de que si el usuario sale de la pantalla antes de que termine la descarga, la tarea se cancela automáticamente, ahorrando datos y batería.
  • ContentUnavailableView: Un componente nativo precioso (disponible en versiones recientes) para mostrar estados vacíos o de error.
  • Independencia de Plataforma: NavigationStackList, y HStack funcionan en todas las plataformas de Apple. En watchOS, la lista se adaptará automáticamente al bisel digital; en macOS, se comportará como una lista de Finder o Mail.

Capítulo 6: Depuración Avanzada (Cuando el JSON falla)

El error más frustrante para un desarrollador es ver en consola: DecodingError. Pero, ¿qué significa realmente? JSONDecoderes muy estricto. Un solo tipo de dato incorrecto (un String donde se esperaba un Int) hará fallar todo el proceso.

Para depurar esto, recomiendo este fragmento de código auxiliar en tu bloque catch:

} catch let DecodingError.dataCorrupted(context) {
    print("Datos corruptos: \(context)")
} catch let DecodingError.keyNotFound(key, context) {
    print("Clave no encontrada: '\(key.stringValue)' en \(context.debugDescription)")
} catch let DecodingError.valueNotFound(value, context) {
    print("Valor no encontrado: '\(value)' en \(context.debugDescription)")
} catch let DecodingError.typeMismatch(type, context) {
    print("Tipo incorrecto: Se esperaba \(type) pero se encontró otra cosa en \(context.debugDescription)")
} catch {
    print("Error desconocido: \(error)")
}

Poner esto en tu APIService te ahorrará horas de adivinanzas, indicándote exactamente qué campo del JSON no coincide con tu struct.


Capítulo 7: Adaptación Cross-Platform (macOS y watchOS)

Una de las promesas de SwiftUI es “Aprende una vez, aplica en cualquier lugar”. El código de Parsing (Modelos y ViewModel) es 100% compartible. No tienes que cambiar ni una coma entre iOS, macOS y watchOS.

Sin embargo, en la Vista, podrías querer pequeñas adaptaciones.

Ejemplo para macOS

En macOS, quizás no quieras una lista simple, sino una tabla con columnas. Puedes usar compilación condicional:

#if os(macOS)
Table(viewModel.users) {
    TableColumn("Nombre", value: \.fullName)
    TableColumn("Email", value: \.contactInfo.email)
}
#else
List(viewModel.users) { ... }
#endif

Ejemplo para watchOS

En el reloj, el espacio es vital. Podrías eliminar la información secundaria (como la fecha de registro) para que la celda sea más legible en una pantalla pequeña.


Conclusión

Parsear JSON en SwiftUI en 2026 no se trata solo de convertir datos; se trata de construir una arquitectura resiliente. Hemos pasado de diccionarios inseguros a un sistema fuertemente tipado que nos protege de errores en tiempo de ejecución.

Al combinar Codableasync/await, y el patrón MVVM con la macro @Observable, has creado una base sólida sobre la cual puedes construir aplicaciones complejas y profesionales.

Próximos pasos

  • Persistencia: ¿Qué pasa si no hay internet? El siguiente paso natural es guardar este JSON decodificado en SwiftData para caché local.
  • Testing: Crea un MockAPIService que implemente el mismo protocolo que tu servicio real para inyectar datos de prueba en tus Previews de Xcode sin hacer llamadas de red reales.

El desarrollo en el ecosistema Apple está en su mejor momento. Tienes las herramientas; ahora, ¡ve y construye algo increíble!

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

@Binding vs @State en SwiftUI

Next Article

Cómo redimensionar imágenes y mantener la relación de aspecto

Related Posts