A simple Pokedex app written in SwiftUI that implements the PokeAPI, using Swift Concurrency, MVVM architecture and pagination
PokedexUI is a modern example app built with SwiftUI by Viktor Gidlöf.
It integrates with the PokeAPI to fetch and display Pokémon data using a clean, reactive architecture using async / await
and Swift Concurrency.
PokedexUI implements a Protocol-Oriented MVVM architecture with Clean Architecture principles. It features generic data fetching, SwiftData persistence, and reactive UI updates using Swift’s @Observable
macro.
The root PokedexView
is a generic view that accepts protocol-conforming ViewModels, enabling dependency injection and testability:
struct PokedexView<
PokedexViewModel: PokedexViewModelProtocol,
ItemListViewModel: ItemListViewModelProtocol,
SearchViewModel: SearchViewModelProtocol
>: View {
@State var viewModel: PokedexViewModel
let itemListViewModel: ItemListViewModel
let searchViewModel: SearchViewModel
var body: some View {
TabView(selection: $viewModel.selectedTab) {
Tab(Tabs.pokedex.title, systemImage: viewModel.grid.icon, value: Tabs.pokedex) {
PokedexContent(viewModel: $viewModel)
}
// Additional tabs...
}
.applyPokedexConfiguration(viewModel: viewModel)
}
}
ViewModels conform to protocols, enabling flexible implementations and easier testing:
protocol PokedexViewModelProtocol {
var pokemon: [PokemonViewModel] { get set }
var isLoading: Bool { get set }
var selectedTab: Tabs { get set }
var grid: GridLayout { get set }
func requestPokemon() async
func sort(by type: SortType)
}
The DataFetcher
protocol provides a unified pattern for storage-first data loading:
protocol DataFetcher {
associatedtype StoredData
associatedtype APIData
associatedtype ViewModel
func fetchStoredData() async throws -> [StoredData]
func fetchAPIData() async throws -> [APIData]
func storeData(_ data: [StoredData]) async throws
func transformToViewModel(_ data: StoredData) -> ViewModel
func transformForStorage(_ data: APIData) -> StoredData
}
extension DataFetcher {
func fetchDataFromStorageOrAPI() async -> [ViewModel] {
// Storage-first approach with API fallback
guard let localData = await fetchStoredDataSafely(), !localData.isEmpty else {
return await fetchDataFromAPI()
}
return localData.map(transformToViewModel)
}
}
The PokedexViewModel
implements both protocols:
@Observable
final class PokedexViewModel: PokedexViewModelProtocol, DataFetcher {
private let pokemonService: PokemonServiceProtocol
private let storageReader: DataStorageReader
var pokemon: [PokemonViewModel] = []
var isLoading: Bool = false
func requestPokemon() async {
guard !isLoading else { return }
pokemon = await withLoadingState {
await fetchDataFromStorageOrAPI()
}
}
}
DataStorageReader
provides a generic actor-based interface for SwiftData operations:
@ModelActor
actor DataStorageReader {
func store<M: PersistentModel>(_ models: [M]) throws {
models.forEach { modelContext.insert($0) }
try modelContext.save()
}
func fetch<M: PersistentModel>(
sortBy: SortDescriptor<M>
) throws -> [M] {
let descriptor = FetchDescriptor<M>(sortBy: [sortBy])
return try modelContext.fetch(descriptor)
}
}
A high-performance, protocol-driven search implementation with sophisticated multi-term filtering and real-time results.
The search system follows the same unified DataFetcher
pattern, ensuring consistent data loading and offline capabilities:
@Observable
final class SearchViewModel: SearchViewModelProtocol, DataFetcher {
var pokemon: [PokemonViewModel] = []
var filtered: [PokemonViewModel] = []
var query: String = ""
func loadData() async {
pokemon = await fetchDataFromStorageOrAPI() // Uses unified data fetching
}
}
func updateFilteredPokemon() {
let queryTerms = query
.split(whereSeparator: \.isWhitespace) // Split on whitespace
.map { $0.normalize } // Diacritic-insensitive
.filter { !$0.isEmpty }
filtered = pokemon.filter { pokemonVM in
let name = pokemonVM.name.normalize
let types = pokemonVM.types.components(separatedBy: ",").map { $0.normalize }
return queryTerms.allSatisfy { term in
name.contains(term) || types.contains(where: { $0.contains(term) })
}
}
}
The search algorithm ensures all terms must match for precise results while supporting partial name matching and type combinations.
Asynchronous image loading with intelligent caching:
actor SpriteLoader {
func loadSprite(from urlString: String) async -> UIImage? {
// Check cache first, then network with automatic caching
}
}
PokedexUI uses the HTTP framework Networking for all the API calls to the PokeAPI. You can read more about that here. It can be installed through Swift Package Manager:
dependencies: [
.package(url: "https://github.com/brillcp/Networking.git", .upToNextMajor(from: "0.9.3"))
]