← Volver al blog

Construyendo una Pokédex en iOS — Parte 8: ViewModel con @Observable

Parte 8 de la serie PokeTracker: construimos PokemonListViewModel con el nuevo macro @Observable, manejamos paginación, búsqueda, filtros y la lógica cache-first.

Con todas las capas de datos listas, ahora construimos la capa de presentación. El ViewModel es el puente entre los datos y la vista: toma el estado, lo expone para que SwiftUI lo observe, y responde a las acciones del usuario.


@Observable vs ObservableObject

Antes de iOS 17, los ViewModels usaban ObservableObject con @Published:

// Patrón anterior — sigue funcionando pero ya no es el preferido
class PokemonListViewModel: ObservableObject {
    @Published var pokemons: [Pokemon] = []
    @Published var isLoading = false
}

Con iOS 17 llegó el macro @Observable. La diferencia más importante no es de sintaxis sino de rendimiento: con ObservableObject, cualquier cambio a cualquier propiedad @Published provoca que todas las vistas que observan ese objeto se reevalúen. Con @Observable, SwiftUI rastrés exactamente qué propiedades lee cada vista y solo re-renderiza cuando esas propiedades específicas cambian.

// Nuevo patrón — más granular y eficiente
@Observable
final class PokemonListViewModel {
    var pokemons: [Pokemon] = []
    var isLoading = false
    // Sin @Published, sin ObservableObject
}

En una lista con 20+ items esto marca la diferencia.


PokemonListViewModel

Crea Features/Pokemon/Presentation/ViewModels/PokemonListViewModel.swift. Este ViewModel es el más completo de la app:

import Foundation
import SwiftData
import Observation

enum SortOption: String, CaseIterable {
    case number   = "Number"
    case nameAsc  = "Name (A-Z)"
    case nameDesc = "Name (Z-A)"
}

@MainActor
@Observable
final class PokemonListViewModel {

    // MARK: - Estado observable

    private(set) var pokemons: [Pokemon] = []
    private(set) var filteredPokemons: [Pokemon] = []
    private(set) var favorites: Set<Int> = []
    private(set) var isLoading = false
    private(set) var isLoadingMore = false
    private(set) var error: Error?
    private(set) var hasMorePages = true
    private(set) var totalCount = 0

    var searchText = ""        { didSet { filterPokemons() } }
    var showFavoritesOnly = false { didSet { filterPokemons() } }
    var isGridView = false
    var sortOption: SortOption = .number { didSet { filterPokemons() } }
    var selectedType: PokemonType? = nil  { didSet { filterPokemons() } }
    var selectedGeneration: Int? = nil    { didSet { filterPokemons() } }

    // MARK: - Dependencias

    private let getPokemonListUseCase: GetPokemonListUseCaseProtocol
    private var dataService: PokemonDataService?

    // MARK: - Paginación

    private let pageSize = 20
    private var currentOffset = 0

    init(getPokemonListUseCase: GetPokemonListUseCaseProtocol = GetPokemonListUseCase()) {
        self.getPokemonListUseCase = getPokemonListUseCase
    }

    func configure(with modelContext: ModelContext) {
        self.dataService = PokemonDataService(modelContext: modelContext)
        loadCachedData()
    }

    // MARK: - Carga de datos

    func loadInitialData() async {
        guard !isLoading else { return }
        isLoading = true
        error = nil
        currentOffset = 0

        do {
            let result = try await getPokemonListUseCase.execute(limit: pageSize, offset: 0)
            pokemons = result.pokemons
            hasMorePages = result.hasMore
            totalCount = result.total
            currentOffset = pageSize

            try dataService?.clearCachedPokemons()
            try dataService?.cachePokemons(result.pokemons, startingOrder: 0)

            filterPokemons()
        } catch {
            self.error = error
            loadCachedData() // si falla la red, mostramos lo cacheado
        }

        isLoading = false
    }

    func loadMoreIfNeeded(currentItem: Pokemon) async {
        guard hasMorePages,
              !isLoadingMore,
              !isLoading,
              pokemons.last?.id == currentItem.id else { return }

        isLoadingMore = true

        do {
            let result = try await getPokemonListUseCase.execute(limit: pageSize, offset: currentOffset)
            pokemons.append(contentsOf: result.pokemons)
            hasMorePages = result.hasMore
            try dataService?.cachePokemons(result.pokemons, startingOrder: currentOffset)
            currentOffset += pageSize
            filterPokemons()
        } catch {
            self.error = error
        }

        isLoadingMore = false
    }

    func refresh() async {
        await loadInitialData()
    }

    func toggleFavorite(_ pokemon: Pokemon) {
        do {
            if let isFavorite = try dataService?.toggleFavorite(pokemon) {
                if isFavorite { favorites.insert(pokemon.id) }
                else { favorites.remove(pokemon.id) }
                filterPokemons()
            }
        } catch {
            self.error = error
        }
    }

    func isFavorite(_ pokemon: Pokemon) -> Bool {
        favorites.contains(pokemon.id)
    }

    // MARK: - Datos cacheados

    private func loadCachedData() {
        do {
            if let cached = try dataService?.getCachedPokemons(), !cached.isEmpty {
                pokemons = cached
                hasMorePages = true
                currentOffset = cached.count
            }
            if let favs = try dataService?.getFavorites() {
                favorites = Set(favs.map { $0.id })
            }
            filterPokemons()
        } catch {
            self.error = error
        }
    }

    // MARK: - Filtros

    private func filterPokemons() {
        var result = pokemons

        if !searchText.isEmpty {
            let query = searchText.lowercased()
            result = result.filter {
                $0.name.lowercased().contains(query) || $0.formattedID.contains(query)
            }
        }

        if showFavoritesOnly {
            result = result.filter { favorites.contains($0.id) }
        }

        if let generation = selectedGeneration {
            let range = generationRange(for: generation)
            result = result.filter { range.contains($0.id) }
        }

        if let type = selectedType {
            // El filtro por tipo requiere datos de detalle — por ahora filtra por nombre del tipo si está disponible
            _ = type
        }

        switch sortOption {
        case .number:   result.sort { $0.id < $1.id }
        case .nameAsc:  result.sort { $0.name < $1.name }
        case .nameDesc: result.sort { $0.name > $1.name }
        }

        filteredPokemons = result
    }

    private func generationRange(for generation: Int) -> ClosedRange<Int> {
        switch generation {
        case 1: return 1...151
        case 2: return 152...251
        case 3: return 252...386
        case 4: return 387...493
        case 5: return 494...649
        case 6: return 650...721
        case 7: return 722...809
        case 8: return 810...905
        case 9: return 906...1025
        default: return 1...1025
        }
    }
}

Hay algunas decisiones que vale explicar:

private(set) en las propiedades de estado. La vista puede leer estas propiedades pero no escribirlas directamente. El estado solo cambia a través de los métodos del ViewModel. Esto hace que sea imposible que la vista deje el ViewModel en un estado inconsistente.

filterPokemons() en didSet. Cada vez que el usuario cambia el texto de búsqueda, el tipo seleccionado, o la generación, se recalcula filteredPokemons. La vista siempre lee de filteredPokemons, nunca de pokemons directamente.

La estrategia cache-first. En loadInitialData: si la red falla, loadCachedData() muestra lo que hay en SwiftData. Si la red tiene éxito, actualiza el caché. El usuario nunca ve una pantalla vacía si ya descargó datos antes.

configure(with:) recibe el ModelContext desde la vista en lugar de crearlo en el ViewModel. Esto es porque ModelContext viene del entorno de SwiftUI y no está disponible hasta que la vista se monta.


PokemonListScreen

Crea Features/Pokemon/Presentation/Views/Screens/PokemonListScreen.swift. Esta es la pantalla principal:

import SwiftUI
import SwiftData

struct PokemonListScreen: View {

    @Environment(\.modelContext) private var modelContext
    @State private var viewModel = PokemonListViewModel()
    @State private var selectedPokemon: Pokemon?
    @State private var showFilters = false

    var body: some View {
        NavigationStack {
            content
                .navigationTitle("Pokédex")
                .searchable(text: $viewModel.searchText, prompt: "Search Pokémon")
                .toolbar { toolbarContent }
                .refreshable { await viewModel.refresh() }
        }
        .task {
            viewModel.configure(with: modelContext)
            if viewModel.pokemons.isEmpty {
                await viewModel.loadInitialData()
            }
        }
        .sheet(item: $selectedPokemon) { pokemon in
            NavigationStack {
                PokemonDetailScreen(pokemon: pokemon)
            }
        }
        .sheet(isPresented: $showFilters) {
            FilterScreen(
                selectedType: $viewModel.selectedType,
                selectedGeneration: $viewModel.selectedGeneration
            )
        }
    }

    @ViewBuilder
    private var content: some View {
        if viewModel.isLoading && viewModel.pokemons.isEmpty {
            ProgressView("Loading Pokémon...")
        } else if let error = viewModel.error, viewModel.pokemons.isEmpty {
            ContentUnavailableView {
                Label("Error", systemImage: "exclamationmark.triangle")
            } description: {
                Text(error.localizedDescription)
            } actions: {
                Button("Try Again") {
                    Task { await viewModel.loadInitialData() }
                }
                .buttonStyle(.borderedProminent)
            }
        } else if viewModel.filteredPokemons.isEmpty {
            ContentUnavailableView("No Pokémon Found", systemImage: "magnifyingglass")
        } else {
            pokemonList
        }
    }

    private var pokemonList: some View {
        List {
            ForEach(viewModel.filteredPokemons) { pokemon in
                Text(pokemon.displayName) // placeholder — en el post de vistas construimos PokemonRowView
                    .onTapGesture { selectedPokemon = pokemon }
                    .onAppear {
                        Task { await viewModel.loadMoreIfNeeded(currentItem: pokemon) }
                    }
            }

            if viewModel.isLoadingMore {
                HStack { Spacer(); ProgressView(); Spacer() }
                    .listRowSeparator(.hidden)
            }
        }
        .listStyle(.plain)
    }

    @ToolbarContentBuilder
    private var toolbarContent: some ToolbarContent {
        ToolbarItem(placement: .topBarLeading) {
            if viewModel.totalCount > 0 {
                Text("\(viewModel.pokemons.count)/\(viewModel.totalCount)")
                    .font(.caption).foregroundStyle(.secondary)
            }
        }
        ToolbarItem(placement: .topBarTrailing) {
            Button {
                withAnimation { viewModel.isGridView.toggle() }
            } label: {
                Image(systemName: viewModel.isGridView ? "list.bullet" : "square.grid.2x2")
            }
        }
    }
}

#Preview {
    PokemonListScreen()
        .modelContainer(for: [CachedPokemon.self, FavoritePokemon.self, CachedPokemonDetail.self], inMemory: true)
}

@State private var viewModel = PokemonListViewModel() en lugar de @StateObject. Con @Observable el ViewModel se crea como cualquier struct @State. No se necesita @StateObject, @ObservedObject ni environmentObject.

El #Preview crea un ModelContainer en memoria — los datos de la preview no persisten entre reinicios.


Qué viene en el siguiente post

En el Post 9 construimos las pruebas unitarias con Swift Testing. Veremos cómo los protocolos que definimos en los posts anteriores hacen que probar sea directo.