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.
- ✅ Protocol-Oriented: Enables dependency injection and easy testing
- ✅ Generic Data Flow: Unified pattern for all data sources
- ✅ Storage-First: Offline-capable with automatic sync
- ✅ Actor-Based Concurrency: Thread-safe data operations
- ✅ Clean Separation: Clear boundaries between layers
- ✅ Type Safety: Compile-time guarantees via generics
- ✅ Reactive UI: Automatic updates via @Observable
- Single Responsibility: Each component has a focused purpose
- Open/Closed: Extensible via protocols without modification
- Liskov Substitution: Protocol conformance ensures substitutability
- Interface Segregation: Focused, cohesive protocols
- Dependency Inversion: Depends on abstractions, not concretions
The root PokedexView
is a generic view that accepts protocol-conforming ViewModels, enabling dependency injection and testability:
struct PokedexView<
PokedexViewModel: PokedexViewModelProtocol,
ItemListViewModel: ItemListViewModelProtocol,
>: View {
@State var viewModel: PokedexViewModel
let itemListViewModel: ItemListViewModel
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 }
var isLoading: Bool { get }
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) })
}
}
}
- ✅ Real-time Filtering: Results update instantly as you type
- ✅ Multi-term Support: "fire dragon" finds Pokémon matching both terms
- ✅ Type-aware Search: Find by type (e.g., "water", "electric") or name
- ✅ Diacritic Insensitive: Handles accented characters automatically
- ✅ Storage Integration: Searches local SwiftData with API fallback
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"))
]
- Xcode 15+
- iOS 17+ (for @Observable and SwiftData)
- Swift 5.9+