Building a Pokédex on iOS — Part 8: ViewModel with @Observable
Part 8 of the PokeTracker series: we build PokemonListViewModel with the new @Observable macro, handling pagination, search, filters, and the cache-first logic.
With all the data layers in place, we now build the presentation layer. The ViewModel is the bridge between data and the view: it holds state, exposes it for SwiftUI to observe, and responds to user actions.
@Observable vs ObservableObject
Before iOS 17, ViewModels used ObservableObject with @Published:
// Old pattern — still works but no longer preferred
class PokemonListViewModel: ObservableObject {
@Published var pokemons: [Pokemon] = []
@Published var isLoading = false
}
iOS 17 introduced the @Observable macro. The most important difference isn’t syntax — it’s performance: with ObservableObject, any change to any @Published property causes every view observing that object to re-evaluate. With @Observable, SwiftUI tracks exactly which properties each view reads and only re-renders when those specific properties change.
// New pattern — more granular and efficient
@Observable
final class PokemonListViewModel {
var pokemons: [Pokemon] = []
var isLoading = false
// No @Published, no ObservableObject
}
In a list with 20+ items, this makes a real difference.
PokemonListViewModel
Create Features/Pokemon/Presentation/ViewModels/PokemonListViewModel.swift. This ViewModel is the most complete one in the 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: - Observable state
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: - Dependencies
private let getPokemonListUseCase: GetPokemonListUseCaseProtocol
private var dataService: PokemonDataService?
// MARK: - Pagination
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: - Data loading
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() // if the network fails, show cached data
}
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: - Cached data
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: - Filters
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 {
// Type filtering requires detail data — for now filters by type name if available
_ = 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
}
}
}
A few decisions worth explaining:
private(set) on state properties. The view can read these properties but can’t write to them directly. State only changes through the ViewModel’s methods. This makes it impossible for the view to leave the ViewModel in an inconsistent state.
filterPokemons() in didSet. Every time the user changes the search text, the selected type, or the generation, filteredPokemons is recalculated. The view always reads from filteredPokemons, never from pokemons directly.
The cache-first strategy. In loadInitialData: if the network fails, loadCachedData() shows whatever is in SwiftData. If the network succeeds, it updates the cache. The user never sees an empty screen if they’ve already downloaded data before.
configure(with:) receives the ModelContext from the view rather than creating it in the ViewModel. This is because ModelContext comes from the SwiftUI environment and isn’t available until the view mounts.
PokemonListScreen
Create Features/Pokemon/Presentation/Views/Screens/PokemonListScreen.swift. This is the main screen:
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 — in the views post we build 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() instead of @StateObject. With @Observable, the ViewModel is created like any @State struct. You don’t need @StateObject, @ObservedObject, or environmentObject.
The #Preview creates an in-memory ModelContainer — preview data doesn’t persist between restarts.
What’s next
In Post 9 we build unit tests with Swift Testing. We’ll see how the protocols we defined in previous posts make testing straightforward.