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
- Open your project in Xcode.
- Go to the File menu and select Add Packages.
- In the search bar, type the Alamofire GitHub repository URL:
https://github.com/Alamofire/Alamofire
- Choose the appropriate version rule (e.g., Up to Next Major Version) and click Add Package.
- 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
- Singleton Pattern:
shared
instance ensures a single, globally accessible object.private init()
prevents creating additional instances.
- 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).
- The private
- Convenience Methods:
- Public methods (
getRequest
,postRequest
,putRequest
,deleteRequest
) provide an easy-to-use interface for common HTTP methods.
- Public methods (
- Error Handling:
- Uses Alamofire’s
validate()
to ensure server responses are valid. - Returns a
Result
type with either the decoded data or anAFError
.
- Uses Alamofire’s
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
- Environment Dismiss:
- Utilizes
@Environment(\.dismiss)
to close the form once the product is successfully saved.
- Utilizes
- ViewModel Binding:
- The
ProductFormView
uses@ObservedObject
to bind theProductFormViewModel
, enabling real-time updates to the form fields (name
andprice
) and validation (submitEnabled
).
- The
- Data Pre-Fill:
- The
product
property (of typeProductDTO
) is optional and used to pre-fill the form when editing an existing product. TheviewModel.setup(product:)
method initializes the form fields accordingly.
- The
- Components:
FormTextField
: Custom text fields forname
andprice
, bound to the view model.FormButton
: A save button that triggers thesaveProduct
method, calling the API to save the product.LoadingView
: Displays a loading indicator when the form is processing a save request.
- Navigation Title:
- Dynamically updates the form title (e.g., “Create Product” or “Update Product”) using
$viewModel.title
.
- Dynamically updates the form title (e.g., “Create Product” or “Update Product”) using
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
- Data Management:
- The view model stores product details (
id
,name
,price
) and manages whether the form is for creating or updating a product.
- The view model stores product details (
- 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
andprice
: 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.
- Uses
- Validation:
- The
setupValidation()
method ensures that the form fields meet specific criteria (name
is not empty, andprice
is a valid number) before enabling the save button.
- The
- API Communication:
- Handles API calls for inserting or updating a product using
Alamofire
via theNetworkManager
. - Uses asynchronous methods (
async/await
) to manage background operations cleanly and efficiently.
- Handles API calls for inserting or updating a product using
- 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.”
- The
- Error Handling:
- The
processResult
method ensures proper handling of API responses. On failure, it stops the loading indicator and throws the error.
- The
Why Create ProductFormViewModel
?
- Separation of Concerns:
- Keeps UI logic (
ProductFormView
) separate from business logic, making the codebase modular and easier to maintain.
- Keeps UI logic (
- 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 andasync/await
for clean, modern asynchronous code.
- Uses
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
- Product List Display:
- Displays a list of products using SwiftUI’s
List
and dynamically generates rows based on theproducts
array from theviewModel
. - Each row includes a button for navigation to the product form, allowing editing of the selected product.
- Displays a list of products using SwiftUI’s
- Navigation and State Management:
- Uses
NavigationStack
for navigation and manages transitions to theProductFormView
. - The
@State
propertiesselectedProduct
andnavigateToForm
control navigation to the form:selectedProduct
stores the currently selected product.navigateToForm
triggers navigation when set totrue
.
- Uses
- 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.
- Includes a plus button in the toolbar to navigate to the
- Product Deletion:
- Integrates the
onDelete
method to remove products from the list, delegating the logic toProductListViewModel
.
- Integrates the
- Loading Indicator:
- Displays a
LoadingView
overlay whenviewModel.isLoading
istrue
, providing feedback during API interactions. - The main view content blurs while loading, creating a better user experience.
- Displays a
- Dynamic Navigation:
- Uses
navigationDestination(isPresented:destination:)
to navigate to theProductFormView
, passing the selected product (if any) for editing or anil
value for creating a new product.
- Uses
- Integration with
ProductListViewModel
:- Calls
viewModel.loadProducts()
when the view appears to fetch the list of products from an API or database.
- Calls
Why Create ProductListView
?
- User-Friendly Interface:
- Provides an intuitive UI for managing products with clear actions for adding, editing, and deleting items.
- Integration with ViewModel:
- Ensures a clean separation of concerns by using
ProductListViewModel
for data management and API handling.
- Ensures a clean separation of concerns by using
- Reusability and Scalability:
- The modular design allows
ProductListView
to serve as a scalable component for applications with product management features.
- The modular design allows
- Modern SwiftUI Features:
- Uses
NavigationStack
for navigation,toolbar
for actions, and state-driven navigation with@State
for a responsive experience.
- Uses
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
- Products Data Management:
- The
products
array stores the list ofProductDTO
objects fetched from the API. - It is marked as
@Published
so any changes automatically update the UI inProductListView
.
- The
- 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.
- The
- Fetching Products:
- The
loadProducts()
method fetches the list of products from the API using theNetworkManager
. - Updates the
products
array with the fetched data on success. - Sets
isLoading
tofalse
once the operation completes, whether successful or not.
- The
- 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.
- The
- Integration with
NetworkManager
:- Relies on the shared
NetworkManager
for API requests (GET and DELETE). - Uses
Alamofire
to handle network communication and parse results.
- Relies on the shared
Why Create ProductListViewModel
?
- Centralized Logic:
- Encapsulates all logic related to fetching and managing products, separating it from the UI layer (
ProductListView
).
- Encapsulates all logic related to fetching and managing products, separating it from the UI layer (
- Real-Time Updates:
- Leverages
@Published
properties to ensure the UI reacts to changes in the data or loading state automatically.
- Leverages
- Error Handling:
- Logs errors from API requests and attempts to recover by reloading the product list in case of failures during deletion.
- Scalability:
- Provides a scalable structure for adding more product-related operations (e.g., updating or searching products) in the future.
- API Integration:
- Simplifies communication with the backend by delegating API calls to
NetworkManager
.
- Simplifies communication with the backend by delegating API calls to
How It Works in the App
- Loading Products:
- When
ProductListView
appears,loadProducts()
is called. - The
isLoading
flag is set totrue
, and the UI shows a loading spinner. - Once the data is fetched, the spinner disappears, and the list is updated.
- When
- 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.
- When the user deletes a product in the list,
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