Building a SwiftUI CRUD with Combine: Integrating with APIs

|

In this example, we will use Alamofire, a powerful and popular Swift library for networking tasks such as making API requests. To use Alamofire in your project, you’ll need to add it via Swift Package Manager (SPM), which is the built-in dependency manager for Swift.

How to Add Alamofire via SPM

  1. Open your project in Xcode.
  2. Go to the File menu and select Add Packages.
  3. In the search bar, type the Alamofire GitHub repository URL:
    • https://github.com/Alamofire/Alamofire
  4. Choose the appropriate version rule (e.g., Up to Next Major Version) and click Add Package.
  5. Once added, Alamofire will be available in your project. You can import it at the top of any file where you need it:
    • import Alamofire

By integrating Alamofire, we’ll simplify the process of making network requests to our API, including tasks like handling JSON responses and error management.

Go to your target and add the Alamofire dependencies:

The API project

You can find an article on our website that provides a detailed guide on how to create an API in Node.js using Azure Functions. The article covers everything from setting up your Azure Functions environment to implementing CRUD operations in your API, making it an excellent resource for developers looking to build scalable serverless applications.

The Custom Views

In this example, custom views were used in the forms. If you still don’t know how to create one, you can learn more by reading my article below:

The NetworkManager

We’ll create a helper class called NetworkManager designed to simplify network requests using Alamofire. It provides methods for performing HTTP requests (GET, POST, PUT, DELETE) and decodes JSON responses into Swift types conforming to Decodable. This class is reusable, centralized, and abstracts away the complexity of Alamofire configurations.


Key Features

  1. Singleton Pattern:
    • shared instance ensures a single, globally accessible object.
    • private init() prevents creating additional instances.
  2. Generic Request Handling:
    • The private request method handles all HTTP methods, choosing the appropriate parameter encoding based on the HTTP method (e.g., URLEncoding for GET and DELETE, JSONEncoding for POST and PUT).
  3. Convenience Methods:
    • Public methods (getRequest, postRequest, putRequest, deleteRequest) provide an easy-to-use interface for common HTTP methods.
  4. Error Handling:
    • Uses Alamofire’s validate() to ensure server responses are valid.
    • Returns a Result type with either the decoded data or an AFError.

Usage Example

Performing a GET request to fetch products:

NetworkManager.shared.getRequest(url: "https://api.example.com/products") { (result: Result<[Product], AFError>) in
    switch result {
    case .success(let products):
        print("Fetched products:", products)
    case .failure(let error):
        print("Error:", error.localizedDescription)
    }
}

This class makes network requests cleaner and easier to maintain while leveraging Alamofire’s power.

import Alamofire

class NetworkManager {
    
    static let shared = NetworkManager()
    private init() {}

    private func request<T: Decodable>(_ url: String, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders? = nil, completion: @escaping (Result<T, AFError>) -> Void) {
        let encoding: ParameterEncoding = method == .get || method == .delete ? URLEncoding.default : JSONEncoding.default
        
        AF.request(url, method: method, parameters: parameters, encoding: encoding, headers: headers)
            .validate()
            .responseDecodable(of: T.self) { response in
                completion(response.result)
            }
    }
    
    func getRequest<T: Decodable>(url: String, parameters: [String: Any]? = nil, headers: HTTPHeaders? = nil, completion: @escaping (Result<T, AFError>) -> Void) {
        request(url, method: .get, parameters: parameters, headers: headers, completion: completion)
    }
    
    func postRequest<T: Decodable>(url: String, parameters: [String: Any]? = nil, headers: HTTPHeaders? = nil, completion: @escaping (Result<T, AFError>) -> Void) {
        request(url, method: .post, parameters: parameters, headers: headers, completion: completion)
    }
    
    func putRequest<T: Decodable>(url: String, parameters: [String: Any]? = nil, headers: HTTPHeaders? = nil, completion: @escaping (Result<T, AFError>) -> Void) {
        request(url, method: .put, parameters: parameters, headers: headers, completion: completion)
    }
    
    func deleteRequest<T: Decodable>(url: String, parameters: [String: Any]? = nil, headers: HTTPHeaders? = nil, completion: @escaping (Result<T, AFError>) -> Void) {
        request(url, method: .delete, parameters: parameters, headers: headers, completion: completion)
    }
}

The ProductDTO

The ProductDTO struct must be created to serve as the data transfer object for the product. It defines the structure and types of data that represent a product, including properties like id (a unique identifier), name (the product’s name), and price (the product’s price). By conforming to the Codable protocol, this struct ensures that the product data can be seamlessly encoded into or decoded from JSON or other formats, making it essential for API communication and data persistence. Creating this struct provides a clear and reusable representation of a product throughout your application.

struct ProductDTO: Codable {
    var id: String
    var name: String
    var price: Float
}

The Product Form

The ProductFormView must be created to provide a user interface for creating or editing a product. It integrates with the ProductFormViewModel to manage the product’s state and handle form validation, data binding, and API requests.

import SwiftUI

struct ProductFormView: View {
    
    @Environment(\.dismiss) var dismiss
    @ObservedObject var viewModel = ProductFormViewModel()
    @State var product: ProductDTO?
    
    var body: some View {
        ZStack {
            ScrollView {
                VStack {
                    FormTextField(placeholder: "Name", text: $viewModel.name)
                    FormTextField(placeholder: "Price", text: $viewModel.price)
                    FormButton(text: "Save", isEnabled: $viewModel.submitEnabled) {
                        Task {
                            await saveProduct()
                        }
                    }
                }
                .padding()
            }
            .onAppear {
                viewModel.setup(product: product)
            }
            .navigationTitle($viewModel.title)
            .blur(radius: viewModel.isLoading ? 3 : 0)
            
            if viewModel.isLoading {
                LoadingView()
            }
        }
        
    }
    
    private func saveProduct() async {
        do {
            try await viewModel.save()
            dismiss()
        } catch {
            print("Error saving")
        }
    }
}

#Preview {
    ProductFormView()
}

Key Features of ProductFormView

  1. Environment Dismiss:
    • Utilizes @Environment(\.dismiss) to close the form once the product is successfully saved.
  2. ViewModel Binding:
    • The ProductFormView uses @ObservedObject to bind the ProductFormViewModel, enabling real-time updates to the form fields (name and price) and validation (submitEnabled).
  3. Data Pre-Fill:
    • The product property (of type ProductDTO) is optional and used to pre-fill the form when editing an existing product. The viewModel.setup(product:) method initializes the form fields accordingly.
  4. Components:
    • FormTextField: Custom text fields for name and price, bound to the view model.
    • FormButton: A save button that triggers the saveProduct method, calling the API to save the product.
    • LoadingView: Displays a loading indicator when the form is processing a save request.
  5. Navigation Title:
    • Dynamically updates the form title (e.g., “Create Product” or “Update Product”) using $viewModel.title.

Why Create ProductFormView?

This view provides a clean, reusable interface for managing product data in your app. It ensures separation of concerns by delegating logic to the ProductFormViewModel and focusing solely on UI and user interaction. It’s an essential component for applications requiring user input for managing products.

The ProductFormViewModel

The ProductFormViewModel class must be created to handle the business logic and state management for the ProductFormView. It acts as the bridge between the UI and the API, ensuring that data is validated, processed, and sent to the server while keeping the view updated.

import SwiftUI
import Combine
import Alamofire

class ProductFormViewModel: ObservableObject {
    
    private var isEditing = false
    private var id: String = UUID().uuidString
    
    @Published var title: String = "Create Product"
    @Published var name: String = ""
    @Published var price: String = ""
    @Published var submitEnabled: Bool = false
    @Published var isLoading: Bool = false
    
    func setup(product: ProductDTO?) {
        if let product = product {
            id = product.id
            name = product.name
            price = "\(product.price)"
            
            title = "Update Product"
            
            isEditing = true
        }
        
        setupValidation()
    }
    
    func setupValidation() {
        Publishers.CombineLatest($name, $price)
            .map { name, price in
                return !name.isEmpty && price.isValidDouble()
            }
            .assign(to: &$submitEnabled)
    }
    
    func save() async throws {
        DispatchQueue.main.async {
            self.isLoading = true
        }
        
        let parameters: [String: Any] = [
            "id": id,
            "name": name,
            "price": Double(price) ?? 0.0
        ]
        
        if !isEditing {
            try await insertNewProduct(parameters)
        } else {
            try await updateProduct(parameters)
        }
    }
    
    func insertNewProduct(_ parameters: [String: Any]) async throws {
        try await withCheckedThrowingContinuation { continuation in
            NetworkManager.shared
                .postRequest(url: Constants.productUrl, 
                             parameters: parameters) { (result: Result<ProductResponseDTO, AFError>) in
                    self.processResult(continuation, result)
                }
        }
    }
    
    func updateProduct(_ parameters: [String: Any]) async throws {
        try await withCheckedThrowingContinuation { continuation in
            NetworkManager.shared
                .putRequest(url: "\(Constants.productUrl)?id=\(id)", 
                            parameters: parameters) { (result: Result<ProductResponseDTO, AFError>) in
                    self.processResult(continuation, result)
                }
        }
    }
    
    private func processResult(_ continuation: CheckedContinuation<(), any Error>, 
                               _ result: Result<ProductResponseDTO, AFError>) {
        switch result {
        case .success:
            continuation.resume()
        case .failure(let error):
            continuation.resume(throwing: error)
            self.isLoading = false
        }
    }
}

Purpose of ProductFormViewModel

  1. Data Management:
    • The view model stores product details (id, name, price) and manages whether the form is for creating or updating a product.
  2. State Control:
    • Uses @Published properties to notify the view (ProductFormView) of changes:
      • title: Controls the navigation title (e.g., “Create Product” or “Update Product”).
      • name and price: Bound to the form fields for user input.
      • submitEnabled: Validates form fields to enable or disable the save button.
      • isLoading: Indicates when a save operation is in progress.
  3. Validation:
    • The setupValidation() method ensures that the form fields meet specific criteria (name is not empty, and price is a valid number) before enabling the save button.
  4. API Communication:
    • Handles API calls for inserting or updating a product using Alamofire via the NetworkManager.
    • Uses asynchronous methods (async/await) to manage background operations cleanly and efficiently.
  5. Dynamic Form Setup:
    • The setup(product:) method configures the form when editing an existing product by pre-filling its details and switching the title to “Update Product.”
  6. Error Handling:
    • The processResult method ensures proper handling of API responses. On failure, it stops the loading indicator and throws the error.

Why Create ProductFormViewModel?

  • Separation of Concerns:
    • Keeps UI logic (ProductFormView) separate from business logic, making the codebase modular and easier to maintain.
  • Real-Time Updates:
    • Ensures that the UI reacts to changes in state, such as enabling/disabling the save button or showing a loading spinner.
  • Reusable Logic:
    • Centralizes validation and API handling, making it reusable across different views or components.
  • Modern Swift Features:
    • Uses Combine for form validation and async/await for clean, modern asynchronous code.

By creating ProductFormViewModel, you ensure a structured, maintainable, and efficient way to manage the product form’s data and logic. This approach enhances scalability and simplifies debugging in complex applications.

The ProductListView

The ProductListView must be created to provide a user interface for managing a list of products. It allows users to view, add, edit, or delete products, integrating seamlessly with the ProductListViewModel to handle data and API interactions.

import SwiftUI
import Combine

struct ProductListView: View {
    
    @ObservedObject var viewModel = ProductListViewModel()
    @State private var selectedProduct: ProductDTO?
    @State private var navigateToForm = false
    
    var body: some View {
        NavigationStack {
            ZStack {
                VStack {
                    List {
                        ForEach(viewModel.products) { item in
                            Button {
                                selectedProduct = item
                                navigateToForm = true
                            } label: {
                                Text(item.name)
                            }
                        }
                        .onDelete(perform: { indexSet in
                            viewModel.delete(indexSet)
                        })
                    }
                    .navigationTitle("Product list")
                    .toolbar {
                        ToolbarItem(placement: .navigationBarLeading) {
                            EditButton()
                        }
                        
                        ToolbarItem(placement: .navigationBarTrailing) {
                            Button(action: {
                                selectedProduct = nil
                                navigateToForm = true
                            }) {
                                Image(systemName: "plus")
                            }
                        }
                    }
                }
                .blur(radius: viewModel.isLoading ? 3 : 0)
                .navigationDestination(isPresented: $navigateToForm) {
                    ProductFormView(product: selectedProduct)
                }
                
                if viewModel.isLoading {
                    LoadingView()
                }
            }
            .onAppear {
                viewModel.loadProducts()
            }
        }
    }
}

#Preview {
    ProductListView()
}

Key Features of ProductListView

  1. Product List Display:
    • Displays a list of products using SwiftUI’s List and dynamically generates rows based on the products array from the viewModel.
    • Each row includes a button for navigation to the product form, allowing editing of the selected product.
  2. Navigation and State Management:
    • Uses NavigationStack for navigation and manages transitions to the ProductFormView.
    • The @State properties selectedProduct and navigateToForm control navigation to the form:
      • selectedProduct stores the currently selected product.
      • navigateToForm triggers navigation when set to true.
  3. Toolbar Actions:
    • Includes a plus button in the toolbar to navigate to the ProductFormView for creating a new product.
    • Adds an edit button for enabling list editing, allowing users to delete products.
  4. Product Deletion:
    • Integrates the onDelete method to remove products from the list, delegating the logic to ProductListViewModel.
  5. Loading Indicator:
    • Displays a LoadingView overlay when viewModel.isLoading is true, providing feedback during API interactions.
    • The main view content blurs while loading, creating a better user experience.
  6. Dynamic Navigation:
    • Uses navigationDestination(isPresented:destination:) to navigate to the ProductFormView, passing the selected product (if any) for editing or a nil value for creating a new product.
  7. Integration with ProductListViewModel:
    • Calls viewModel.loadProducts() when the view appears to fetch the list of products from an API or database.

Why Create ProductListView?

  1. User-Friendly Interface:
    • Provides an intuitive UI for managing products with clear actions for adding, editing, and deleting items.
  2. Integration with ViewModel:
    • Ensures a clean separation of concerns by using ProductListViewModel for data management and API handling.
  3. Reusability and Scalability:
    • The modular design allows ProductListView to serve as a scalable component for applications with product management features.
  4. Modern SwiftUI Features:
    • Uses NavigationStack for navigation, toolbar for actions, and state-driven navigation with @State for a responsive experience.

By creating ProductListView, you provide a powerful yet simple interface for managing products, integrating modern SwiftUI patterns and principles. This view is essential for applications where managing a list of items efficiently is a core requirement.

The ProductListViewModel

The ProductListViewModel must be created to handle the business logic and data management for the ProductListView. It is responsible for interacting with the API, fetching the list of products, and performing operations like deletion while ensuring the UI remains updated.

import SwiftUI
import Combine
import Alamofire

class ProductListViewModel: ObservableObject {
    
    @Published var products: [ProductDTO] = []
    @Published var isLoading: Bool = true
    
    func loadProducts() {
        self.isLoading = true
        NetworkManager.shared
            .getRequest(url: Constants.productUrl) { (result: Result<ProductListResponseDTO, AFError>) in
            switch result {
            case .success(let response):
                self.products = response.data
            case .failure(let error):
                print("Error:", error.localizedDescription)
            }
            self.isLoading = false
        }
    }
    
    func delete(_ indexSet: IndexSet) {
        self.isLoading = true
        
        for productIndex in indexSet {
            let id = products[productIndex].id
            NetworkManager.shared
                .deleteRequest(url: "\(Constants.productUrl)?id=\(id)") { (result: Result<ProductResponseDTO, AFError>) in
                switch result {
                case .failure(let error):
                    print("Error:", error.localizedDescription)
                    self.loadProducts()
                default:
                    break
                }
                    
                self.isLoading = false
            }
            
            products.remove(at: productIndex)
        }
    }
}

Key Features of ProductListViewModel

  1. Products Data Management:
    • The products array stores the list of ProductDTO objects fetched from the API.
    • It is marked as @Published so any changes automatically update the UI in ProductListView.
  2. Loading State Management:
    • The isLoading property indicates whether a network operation is in progress.
    • It is also marked as @Published to show or hide a loading indicator in the view.
  3. Fetching Products:
    • The loadProducts() method fetches the list of products from the API using the NetworkManager.
    • Updates the products array with the fetched data on success.
    • Sets isLoading to false once the operation completes, whether successful or not.
  4. Deleting Products:
    • The delete(_:) method handles the deletion of products.
    • For each index in the IndexSet, it:
      • Retrieves the product ID.
      • Sends a DELETE request to the API to remove the product.
      • Removes the product from the local products array to keep the UI in sync.
    • Calls loadProducts() in case of an error to refresh the list.
  5. Integration with NetworkManager:
    • Relies on the shared NetworkManager for API requests (GET and DELETE).
    • Uses Alamofire to handle network communication and parse results.

Why Create ProductListViewModel?

  1. Centralized Logic:
    • Encapsulates all logic related to fetching and managing products, separating it from the UI layer (ProductListView).
  2. Real-Time Updates:
    • Leverages @Published properties to ensure the UI reacts to changes in the data or loading state automatically.
  3. Error Handling:
    • Logs errors from API requests and attempts to recover by reloading the product list in case of failures during deletion.
  4. Scalability:
    • Provides a scalable structure for adding more product-related operations (e.g., updating or searching products) in the future.
  5. API Integration:
    • Simplifies communication with the backend by delegating API calls to NetworkManager.

How It Works in the App

  • Loading Products:
    • When ProductListView appears, loadProducts() is called.
    • The isLoading flag is set to true, and the UI shows a loading spinner.
    • Once the data is fetched, the spinner disappears, and the list is updated.
  • Deleting Products:
    • When the user deletes a product in the list, delete(_:) is called.
    • The product is removed from the list and the API is notified.
    • If an error occurs, the list is refreshed to maintain consistency.

By creating ProductListViewModel, you provide a clean, reusable, and efficient way to manage the product list and its related operations, ensuring the app remains responsive and consistent with modern SwiftUI patterns.

The Source Code Repository

Check out the source code for this sample CRUD application on my GitHub repository.

https://github.com/san0suke/swift-ui-combine2/tree/main/SwiftUICombine/SwiftUICombine/CRUDSample