Building a Pokédex on iOS — Part 6: Offline persistence with SwiftData
Part 6 of the PokeTracker series: we implement the @Model models, the data service, and the cache-first strategy so the app works without a network connection.
An app that only works with an internet connection isn’t a good app. In this post we add local persistence with SwiftData so PokeTracker can display data even when there’s no network.
SwiftData is the modern replacement for Core Data, introduced in iOS 17. The most visible difference is that instead of a visual model editor and .xcdatamodeld files, models are defined in pure Swift using macros.
The three @Model models
We need to store three types of information:
- The list of Pokémon we’ve already downloaded (list cache)
- The detail of each Pokémon the user has opened
- The user’s favorite Pokémon
CachedPokemon
Create Features/Pokemon/Data/Persistence/CachedPokemon.swift:
import Foundation
import SwiftData
@Model
final class CachedPokemon {
@Attribute(.unique) var id: Int
var name: String
var spriteURLString: String?
var lastUpdated: Date
var order: Int
init(id: Int, name: String, spriteURLString: String? = nil, order: Int = 0) {
self.id = id
self.name = name
self.spriteURLString = spriteURLString
self.lastUpdated = Date()
self.order = order
}
var spriteURL: URL? {
guard let urlString = spriteURLString else { return nil }
return URL(string: urlString)
}
func toDomain() -> Pokemon {
Pokemon(id: id, name: name, spriteURL: spriteURL)
}
}
extension CachedPokemon {
convenience init(from pokemon: Pokemon, order: Int) {
self.init(
id: pokemon.id,
name: pokemon.name,
spriteURLString: pokemon.spriteURL?.absoluteString,
order: order
)
}
}
@Model tells SwiftData that this class is persistent. @Attribute(.unique) on id ensures there will never be two entries with the same ID — if you try to insert a Pokémon that already exists, SwiftData rejects it.
order exists to preserve the pagination order. SwiftData doesn’t guarantee that records come out in insertion order, so we need to store it explicitly.
toDomain() converts the persistence model to the domain model. This maintains the separation: the domain layer doesn’t import SwiftData, and the data layer doesn’t expose @Model models upward.
SwiftData doesn’t accept URL directly as a storable type, so we save the URL as a String and convert it in the computed property.
FavoritePokemon
Create Features/Pokemon/Data/Persistence/FavoritePokemon.swift:
import Foundation
import SwiftData
@Model
final class FavoritePokemon {
@Attribute(.unique) var id: Int
var name: String
var spriteURLString: String?
var addedAt: Date?
init(id: Int, name: String, spriteURLString: String? = nil) {
self.id = id
self.name = name
self.spriteURLString = spriteURLString
self.addedAt = Date()
}
var spriteURL: URL? {
guard let urlString = spriteURLString else { return nil }
return URL(string: urlString)
}
func toDomain() -> Pokemon {
Pokemon(id: id, name: name, spriteURL: spriteURL)
}
}
extension FavoritePokemon {
convenience init(from pokemon: Pokemon) {
self.init(id: pokemon.id, name: pokemon.name,
spriteURLString: pokemon.spriteURL?.absoluteString)
}
}
CachedPokemonDetail
Create Features/Pokemon/Data/Persistence/CachedPokemonDetail.swift. The detail is more complex because we need to store types, stats, and abilities:
import Foundation
import SwiftData
@Model
final class CachedPokemonDetail {
@Attribute(.unique) var id: Int
var name: String
var height: Int
var weight: Int
var baseExperience: Int
var spriteURLString: String?
var descriptionText: String?
var genus: String?
var typesData: Data?
var statsData: Data?
var abilitiesData: Data?
var lastUpdated: Date
init(id: Int, name: String, height: Int, weight: Int, baseExperience: Int,
spriteURLString: String? = nil, descriptionText: String? = nil,
genus: String? = nil, typesData: Data? = nil,
statsData: Data? = nil, abilitiesData: Data? = nil) {
self.id = id
self.name = name
self.height = height
self.weight = weight
self.baseExperience = baseExperience
self.spriteURLString = spriteURLString
self.descriptionText = descriptionText
self.genus = genus
self.typesData = typesData
self.statsData = statsData
self.abilitiesData = abilitiesData
self.lastUpdated = Date()
}
func toDomain() -> PokemonDetail? {
guard let typesData,
let typeNames = try? JSONDecoder().decode([String].self, from: typesData)
else { return nil }
let types = typeNames.map { PokemonType(name: $0) }
let stats: [PokemonStat] = (try? JSONDecoder().decode(
[[String: Int]].self, from: statsData ?? Data()
))?.compactMap { dict in
guard let name = dict["name"].flatMap({ _ in nil as String? }),
let value = dict["value"] else { return nil }
return PokemonStat(name: String(value), baseStat: value)
} ?? []
return PokemonDetail(
id: id, name: name, height: height, weight: weight,
baseExperience: baseExperience,
types: types, stats: stats, abilities: [], moves: [],
spriteURL: spriteURLString.flatMap { URL(string: $0) },
description: descriptionText, genus: genus
)
}
}
extension CachedPokemonDetail {
convenience init(from detail: PokemonDetail) {
let typesData = try? JSONEncoder().encode(detail.types.map { $0.name })
self.init(
id: detail.id, name: detail.name,
height: detail.height, weight: detail.weight,
baseExperience: detail.baseExperience,
spriteURLString: detail.spriteURL?.absoluteString,
descriptionText: detail.description,
genus: detail.genus,
typesData: typesData
)
}
}
Types, stats, and abilities are serialized as Data using JSONEncoder. SwiftData has no native support for arrays of custom types — the solution is to encode them manually.
PokemonDataService
The service centralizes all read and write operations on SwiftData. Create Features/Pokemon/Data/Persistence/PokemonDataService.swift:
import Foundation
import SwiftData
@MainActor
final class PokemonDataService {
private let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
// MARK: - Pokémon list
func getCachedPokemons() throws -> [Pokemon] {
let descriptor = FetchDescriptor<CachedPokemon>(
sortBy: [SortDescriptor(\.order)]
)
return try modelContext.fetch(descriptor).map { $0.toDomain() }
}
func cachePokemons(_ pokemons: [Pokemon], startingOrder: Int) throws {
for (index, pokemon) in pokemons.enumerated() {
let order = startingOrder + index
let pokemonId = pokemon.id
let descriptor = FetchDescriptor<CachedPokemon>(
predicate: #Predicate { $0.id == pokemonId }
)
if let existing = try modelContext.fetch(descriptor).first {
existing.name = pokemon.name
existing.spriteURLString = pokemon.spriteURL?.absoluteString
existing.order = order
existing.lastUpdated = Date()
} else {
modelContext.insert(CachedPokemon(from: pokemon, order: order))
}
}
try modelContext.save()
}
func clearCachedPokemons() throws {
try modelContext.delete(model: CachedPokemon.self)
try modelContext.save()
}
// MARK: - Favorites
func getFavorites() throws -> [Pokemon] {
let descriptor = FetchDescriptor<FavoritePokemon>(
sortBy: [SortDescriptor(\.addedAt, order: .reverse)]
)
return try modelContext.fetch(descriptor).map { $0.toDomain() }
}
func isFavorite(id: Int) throws -> Bool {
let descriptor = FetchDescriptor<FavoritePokemon>(
predicate: #Predicate { $0.id == id }
)
return try modelContext.fetch(descriptor).first != nil
}
func toggleFavorite(_ pokemon: Pokemon) throws -> Bool {
if try isFavorite(id: pokemon.id) {
let id = pokemon.id
let descriptor = FetchDescriptor<FavoritePokemon>(
predicate: #Predicate { $0.id == id }
)
if let existing = try modelContext.fetch(descriptor).first {
modelContext.delete(existing)
try modelContext.save()
}
return false
} else {
modelContext.insert(FavoritePokemon(from: pokemon))
try modelContext.save()
return true
}
}
}
@MainActor on the class guarantees that all operations run on the main thread. ModelContext is not Sendable — it can’t be used from background threads. With @MainActor the compiler enforces this automatically.
FetchDescriptor with #Predicate is SwiftData’s way of doing filtered queries. The #Predicate macro verifies at compile time that the fields exist and that the types are correct — if you misspell a property name, the compiler fails.
Cache-first strategy
The ViewModel will use PokemonDataService with this logic:
- On startup, load the cached data from SwiftData to display something immediately.
- Make the network call in parallel.
- When the response arrives, update SwiftData and refresh the UI.
- If the network fails, the cached data is still there — the user sees the last downloaded version.
We’ll cover this in Post 8 when we build the ViewModel.
What’s next
In Post 7 we build the two-level image cache system: memory and disk.