PokedexUI

A simple Pokedex app written in SwiftUI that implements the PokeAPI, using Swift Concurrency, MVVM architecture and pagination

78
2
Swift

icon

swift
release
platforms
spm
license
stars

PokedexUI

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.

pd1 pd2

Architecture 🏛

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.

Key Architectural Benefits

  • 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

SOLID Compliance Score: 0.92 / 1.0

  • 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

View Layer 📱

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)
    }
}

ViewModel Layer 🧾

Protocol-Oriented Design

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)
}

Generic Data Fetching

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)
    }
}

Concrete Implementation

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()
        }
    }
}

Data Layer 📦

SwiftData Persistence

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)
    }
}

Intelligent Search System 🔍

A high-performance, protocol-driven search implementation with sophisticated multi-term filtering and real-time results.

Search Architecture

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
    }
}

Advanced Filtering Algorithm

Multi-Term Processing & Matching

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) })
        }
    }
}

Key Features

  • 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.

Sprite Loading & Caching 🎨

Asynchronous image loading with intelligent caching:

actor SpriteLoader {
    func loadSprite(from urlString: String) async -> UIImage? {
        // Check cache first, then network with automatic caching
    }
}

Dependencies 🔗

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"))
]

Requirements ❗️

  • Xcode 15+
  • iOS 17+ (for @Observable and SwiftData)
  • Swift 5.9+