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.