Image for transforming the relationship between designers and developers
Image for transforming the relationship between designers and developers
Image for transforming the relationship between designers and developers

Mar 6, 2023

Mar 6, 2023

Mar 6, 2023

10 mins

10 mins

10 mins

Swift, Xcode

Swift, Xcode

Swift, Xcode

Synchronizing Data Fetching from Multiple Sources with Combine

Synchronizing Data Fetching from Multiple Sources with Combine

Synchronizing Data Fetching from Multiple Sources with Combine

Welcome to our tutorial on using the Combine framework to efficiently sync fetching data from multiple sources in SwiftUI! In this tutorial, we will explore how to use Combine to load data from different sources, such as APIs, and combine them into a cohesive whole. We will also show how to provide a completion callback when all data has been loaded, allowing you to track the progress of your data loading and take appropriate actions.


By the end of this tutorial, you will have a good understanding how to use Combine to load data from multiple sources in your SwiftUI applications.


Getting started with the Combine framework for data loading in SwiftUI


In the context of SwiftUI, the Combine framework can be used to load data from a variety of sources and provide a completion callback when all data has been loaded. This ensures all data is available before rendering the UI. Using Combine for data loading has several advantages, such as managing multiple streams of data, handling errors and cancellations, and providing a completion callback.

Data loading is an important aspect of building efficient and responsive SwiftUI applications. By using the Combine framework, we can avoid common pitfalls such as blocking the main thread or overloading the network. Instead, we can focus on building robust and performant user interfaces.

Welcome to our tutorial on using the Combine framework to efficiently sync fetching data from multiple sources in SwiftUI! In this tutorial, we will explore how to use Combine to load data from different sources, such as APIs, and combine them into a cohesive whole. We will also show how to provide a completion callback when all data has been loaded, allowing you to track the progress of your data loading and take appropriate actions.


By the end of this tutorial, you will have a good understanding how to use Combine to load data from multiple sources in your SwiftUI applications.


Getting started with the Combine framework for data loading in SwiftUI


In the context of SwiftUI, the Combine framework can be used to load data from a variety of sources and provide a completion callback when all data has been loaded. This ensures all data is available before rendering the UI. Using Combine for data loading has several advantages, such as managing multiple streams of data, handling errors and cancellations, and providing a completion callback.

Data loading is an important aspect of building efficient and responsive SwiftUI applications. By using the Combine framework, we can avoid common pitfalls such as blocking the main thread or overloading the network. Instead, we can focus on building robust and performant user interfaces.

Welcome to our tutorial on using the Combine framework to efficiently sync fetching data from multiple sources in SwiftUI! In this tutorial, we will explore how to use Combine to load data from different sources, such as APIs, and combine them into a cohesive whole. We will also show how to provide a completion callback when all data has been loaded, allowing you to track the progress of your data loading and take appropriate actions.


By the end of this tutorial, you will have a good understanding how to use Combine to load data from multiple sources in your SwiftUI applications.


Getting started with the Combine framework for data loading in SwiftUI


In the context of SwiftUI, the Combine framework can be used to load data from a variety of sources and provide a completion callback when all data has been loaded. This ensures all data is available before rendering the UI. Using Combine for data loading has several advantages, such as managing multiple streams of data, handling errors and cancellations, and providing a completion callback.

Data loading is an important aspect of building efficient and responsive SwiftUI applications. By using the Combine framework, we can avoid common pitfalls such as blocking the main thread or overloading the network. Instead, we can focus on building robust and performant user interfaces.

At Tapforce, we prioritize a seamless user experience in our mobile app development. To reduce wait times and create a more efficient app experience, we load and prepare all necessary data before the home screen is displayed. This approach ensures optimal performance without sacrificing functionality or usability.

Loading data from multiple sources is a common task for developers. While Combine provides an efficient way to manage asynchronous data streams, it also presents some challenges. One of the biggest challenges is ensuring that data is loaded correctly and efficiently.

A common mistake is loading one batch of data, waiting for it to be loaded, and then moving on to the next data source. This approach can be time-consuming, especially if the data sources are related to each other and require additional calculations. Additionally, errors during the data loading process can cause the entire operation to crash, leaving the user with incomplete data.

To avoid these issues, developers must ensure that they are properly synchronizing data fetching from multiple sources. They should also consider error handling and implementing a backup plan in case of data loading failures.


Setting up a basic data loading pipeline


This example demonstrates how to set up a basic data loading pipeline using the Combine framework in a SwiftUI application. The data we will work with is a list of cryptocurrency prices retrieved from a publicly available API.

To start, we define a view model class that handles the data loading and management. This class uses the URLSession publisher to create a publisher that emits data from a network request to the API. We then subscribe to the publisher using the .sink operator and provide a closure that updates the cryptoPrices property with the received data and sets the loadingCompleted flag to true to indicate that the data has been loaded. To ensure that updates to the cryptoPrices and loadingCompleted properties occur on the main thread, we use the receive(on:) operator.



import SwiftUI
import Combine

/// CryptoPrice struct that conforms to the Decodable and Identifiable protocols
struct CryptoPrice: Decodable, Identifiable {
    let id: String
    let name: String
    let symbol: String
    let quotes: Quote

    /// Quote struct that conforms to the Decodable protocol
    struct Quote: Decodable {
        let USD: USD

        /// USD struct that conforms to the Decodable protocol
        struct USD: Decodable {
            let price: Double
        }
    }
}

/// CryptoViewModel class that conforms to the ObservableObject protocol
class CryptoViewModel: ObservableObject {
    /// Published properties that can be observed for changes
    @Published var cryptoPrices: [CryptoPrice] = []
    @Published var loadingCompleted = false
    @Published var error: Error?
    
    /// Set of cancellable objects that can be used to cancel network requests
    private var cancellables = Set<AnyCancellable>()
    
    /// Function to fetch crypto prices from API
    func fetchCryptoPrices() {
        loadingCompleted = false
        
        /// URL for the API endpoint
        let url = URL(string: "https://api.coinpaprika.com/v1/tickers?limit=100")!

        /// Use dataTaskPublisher to fetch data from the API
        URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            /// Decode the data using JSONDecoder
            .decode(type: [CryptoPrice].self, decoder: JSONDecoder())
            /// Receive on main thread
            .receive(on: DispatchQueue.main)
            /// Sink to handle the received data and completion event
            .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    self.error = error
                case .finished:
                    self.loadingCompleted = true
                }
            }, receiveValue: { self.cryptoPrices = $0 })
            .store(in: &cancellables) /// Store the cancellable object in the set
    }
}

/// Define the main view
struct ContentView: View {
    /// Create an observed object of CryptoViewModel
    @ObservedObject var cryptoViewModel = CryptoViewModel()

    var body: some View {
        VStack {
            /// Check if there was an error loading the data
            if cryptoViewModel.error != nil {
                /// Show an error message
                Text("An error occurred: \(cryptoViewModel.error!.localizedDescription)")
            } else if !cryptoViewModel.loadingCompleted {
                /// Show a loading indicator
                VStack {
                    ProgressView()
                        .padding(8)
                    Text("Loading...")
                }
            } else {
                /// Show the list of crypto prices
              List(cryptoViewModel.cryptoPrices) { cryptoPrice in
               Text("\(cryptoPrice.name) (\(cryptoPrice.symbol)) - $\(cryptoPrice.quotes.USD.price)")
                  }
            }
        }.onAppear { /// when the view appears, fetch the crypto prices
            self.cryptoViewModel.fetchCryptoPrices()
        }
    }
}



In summary, the ContentView struct in our SwiftUI application displays different content to the user based on the state of the data loading process. When the view appears, it calls the fetchCryptoPrices method of the CryptoViewModel instance to initiate the data loading process.

If an error occurs during the data loading process, an error message is displayed. If the data is still being loaded, a loading indicator is displayed. If the data has been successfully loaded, a list of cryptocurrency prices is displayed.

This approach enables the ContentView view to show the appropriate content to the user as the data is being loaded and processed.


Fetch Data from Multiple Sources


Before diving into using Combine frameworks to load data from multiple sources as one stream, let's first take a look at how to retrieve data from multiple APIs in a simple way. In this example, we will be using two separate APIs to retrieve data: one for cryptocurrency prices and one for stock quotes.



import SwiftUI
import Combine

/// CryptoPrice struct that conforms to the Decodable and Identifiable protocols
struct CryptoPrice: Decodable, Identifiable {
    let id: String
    let name: String
    let symbol: String
    let quotes: Quote

    /// Quote struct that conforms to the Decodable protocol
    struct Quote: Decodable {
        let USD: USD

        /// USD struct that conforms to the Decodable protocol
        struct USD: Decodable {
            let price: Double
        }
    }
}

/// CryptoViewModel class that conforms to the ObservableObject protocol
class CryptoViewModel: ObservableObject {
    /// Published properties that can be observed for changes
    @Published var cryptoPrices: [CryptoPrice] = []
    @Published var loadingCompleted = false
    @Published var error: Error?

    /// Set of cancellable objects that can be used to cancel network requests
    private var cancellables = Set<AnyCancellable>()

    /// Function to fetch crypto prices from API
    func fetchCryptoPrices() {
        loadingCompleted = false

        /// URL for the API endpoint
        let url = URL(string: "https://api.coinpaprika.com/v1/tickers?limit=100")!

        /// Use dataTaskPublisher to fetch data from the API
        URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            /// Decode the data using JSONDecoder
            .decode(type: [CryptoPrice].self, decoder: JSONDecoder())
            /// Receive on main thread
            .receive(on: DispatchQueue.main)
            /// Sink to handle the received data and completion event
            .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    self.error = error
                case .finished:
                    self.loadingCompleted = true
                }
            }, receiveValue: { self.cryptoPrices = $0 })
            .store(in: &cancellables) /// Store the cancellable object in the set
    }
}

/// Define a structure to hold the response from the API for stock data
struct StockResponse: Decodable {
    let quoteResponse: QuoteResponse
    
    /// Define another structure to hold the quotes from the API response
    struct QuoteResponse: Decodable {
        let result: [Stock]
    }
}

/// Define a structure to hold the properties of a single stock
struct Stock: Decodable, Identifiable {
    let symbol: String
    let longName: String
    let regularMarketPrice: Double
    var id: String { symbol }
}

/// StockViewModel class that conforms to the ObservableObject protocol
class StockViewModel: ObservableObject {
    /// Published properties that can be observed for changes
    @Published var stocks: [Stock] = []
    @Published var loadingCompleted = false
    @Published var error: Error?

    /// Set of cancellable objects that can be used to cancel network requests
    private var cancellables = Set<AnyCancellable>()

    /// Function to fetch stock data from API
    func fetchStocks() {
        loadingCompleted = false

        /// URL for the API endpoint
        let url = URL(string: "https://query1.finance.yahoo.com/v7/finance/quote?symbols=AAPL,GOOG,MSFT,TSLA,UBER,LYFT")!

        /// Use dataTaskPublisher to fetch data from the API
        URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            /// Decode the data using JSONDecoder
            .decode(type: StockResponse.self, decoder: JSONDecoder())
            /// Map the result to get the array of stocks
            .map { $0.quoteResponse.result }
            /// Receive on main thread
            .receive(on: DispatchQueue.main)
            /// Sink to handle the received data and completion event
            .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    self.error = error
                case .finished:
                    self.loadingCompleted = true
                }
            }, receiveValue: { self.stocks = $0 })
            .store(in: &cancellables) /// Store the cancellable object in the set
    }
}

/// Define an enumeration to represent the two options for displaying data: stocks or crypto
enum Selection {
    case stocks
    case crypto
}

/// Define the main view
struct ContentView: View {
    /// Create two observed objects, one for stock data and one for crypto data
    @ObservedObject var stockViewModel = StockViewModel()
    @ObservedObject var cryptoViewModel = CryptoViewModel()
    
    /// Create a state variable to hold the user's selection of either stocks or crypto
    @State private var selection: Selection = .stocks
    
    var body: some View {
        VStack {
            /// Check if there was an error loading the data
            if stockViewModel.error != nil || cryptoViewModel.error != nil {
                /// Show an error message
                Text("An error occurred while loading data from API")
            } else if !stockViewModel.loadingCompleted || !cryptoViewModel.loadingCompleted {
                /// Show a loading indicator
                VStack {
                    ProgressView()
                        .padding(8)
                    Text("Loading...")
                }
            } else {
                /// Display a picker to let the user select either stocks or crypto
                Picker("Options", selection: $selection) {
                    Text("Stocks").tag(Selection.stocks)
                    Text("Crypto").tag(Selection.crypto)
                }
                .pickerStyle(SegmentedPickerStyle())
                .padding()
                
                /// Show the list of either stocks or crypto depending on the user's selection
                if selection == .crypto {
                    List(cryptoViewModel.cryptoPrices) { cryptoPrice in
                        Text("\(cryptoPrice.name) (\(cryptoPrice.symbol)) - $\(cryptoPrice.quotes.USD.price)")
                    }
                } else {
                    List(stockViewModel.stocks) { stock in
                        Text("\(stock.symbol) - \(stock.longName) - $\(stock.regularMarketPrice)")
                    }
                }
            }
        }
        .onAppear {
            /// Fetch the data for both stocks and crypto
            self.stockViewModel.fetchStocks()
            self.cryptoViewModel.fetchCryptoPrices()
        }
    }
}



In this example, we defined two view models: the CryptoViewModel and the StockViewModel. Each handles data loading and management for cryptocurrency prices and stock prices, respectively. Both view models use the URLSession publisher to create a publisher that emits data from a network request to a publicly available API.

When the ContentView view appears, it calls the fetchCryptoPrices and fetchStocks methods of the CryptoViewModel and StockViewModel instances, respectively. Both methods initiate the data loading process for their respective data sources.

However, this implementation has a limitation: we must check the loading status and error state for each data source separately. This can become cumbersome as the number of data sources increases. In the next example, we'll explore how the Combine framework can simplify this process.


Using Combine's zip operator to merge data from multiple sources


This example demonstrates how to use the Combine framework's zip operator to retrieve data from two different sources (a cryptocurrency prices API and a stock quotes API) and combine them into a single stream of data. To do so, we first update our view model classes (CryptoViewModel and StockViewModel) to return a publisher of the data. Then, we create a new DataManager class that uses the zip operator to combine the data from the two view models into a single stream.

Once we have our data stream set up, we can use the sink operator to subscribe to it and receive updates whenever new data becomes available. In this case, we update the loadingCompleted flag to indicate that the data has been loaded and update the data property with the received data.



import SwiftUI
import Combine

/// CryptoPrice struct that conforms to the Decodable and Identifiable protocols
struct CryptoPrice: Decodable, Identifiable {
    let id: String
    let name: String
    let symbol: String
    let quotes: Quote

    /// Quote struct that conforms to the Decodable protocol
    struct Quote: Decodable {
        let USD: USD

        /// USD struct that conforms to the Decodable protocol
        struct USD: Decodable {
            let price: Double
        }
    }
}

/// CryptoViewModel class that conforms to the ObservableObject protocol
class CryptoViewModel: ObservableObject {
    /// Published property that can be observed for changes
    @Published var cryptoPrices: [CryptoPrice] = []

    func fetchCryptoPrices() -> AnyPublisher<[CryptoPrice], Error> {
        let url = URL(string: "https://api.coinpaprika.com/v1/tickers?limit=20")!

        return Future<[CryptoPrice], Error> { promise in
            let task = URLSession.shared.dataTask(with: url) { data, response, error in
                if let error = error {
                    promise(.failure(error))
                } else if let data = data {
                    do {
                        let cryptoPrices = try JSONDecoder().decode([CryptoPrice].self, from: data)
                        self.cryptoPrices = cryptoPrices
                        promise(.success(cryptoPrices))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
            task.resume()
        }
        .eraseToAnyPublisher()
    }
}

/// Define a structure to hold the response from the API for stock data
struct StockResponse: Decodable {
    let quoteResponse: QuoteResponse
    
    /// Define another structure to hold the quotes from the API response
    struct QuoteResponse: Decodable {
        let result: [Stock]
    }
}

/// Define a structure to hold the properties of a single stock
struct Stock: Decodable, Identifiable {
    let symbol: String
    let longName: String
    let regularMarketPrice: Double
    var id: String { symbol }
}

/// StockViewModel class that conforms to the ObservableObject protocol
class StockViewModel: ObservableObject {
    /// Published property that can be observed for changes
    @Published var stocks: [Stock] = []
    
    /// Function to fetch crypto prices from API
    func fetchStocks() -> AnyPublisher<[Stock], Error> {
        let url = URL(string: "https://query1.finance.yahoo.com/v7/finance/quote?symbols=AAPL,GOOG,MSFT,TSLA,UBER,LYFT")!

        return Future<[Stock], Error> { promise in
            let task = URLSession.shared.dataTask(with: url) { data, response, error in
                if let error = error {
                    promise(.failure(error))
                } else if let data = data {
                    do {
                        let response = try JSONDecoder().decode(StockResponse.self, from: data)
                        self.stocks = response.quoteResponse.result
                        promise(.success(self.stocks))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
            task.resume()
        }
        .eraseToAnyPublisher()
    }
}

/// Define an enumeration to represent the two options for displaying data: stocks or crypto
enum Selection {
    case stocks
    case crypto
}

/// This class manages the loading and display of stock and crypto data.
class DataManager: ObservableObject {
    /// This object manages the loading and displaying of stock data.
    @Published var stockViewModel = StockViewModel()
    /// This object manages the loading and displaying of crypto data.
    @Published var cryptoViewModel = CryptoViewModel()

    private var cancellables = Set<AnyCancellable>()
    
    /// Whether the loading of data has completed.
    @Published var loadingCompleted = false
    /// Any error that occurred during loading.
    @Published var error: Error?

    /// Load the stock and crypto data.
    func loadData() {
        loadingCompleted = false
        
        /// Load the stock and crypto data asynchronously.
        let stockPublisher = stockViewModel.fetchStocks()
        let cryptoPublisher = cryptoViewModel.fetchCryptoPrices()

        /// Combine the stock and crypto data loading.
        Publishers.Zip(stockPublisher, cryptoPublisher)
            .receive(on: DispatchQueue.main)
            .retry(3)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    self.error = error
                case .finished:
                    self.loadingCompleted = true
                }
            }, receiveValue: { stocks, crypto in
                print("Stocks loaded:\(stocks.count)")
                print("Cryptocurrencies loaded:\(crypto.count)")
            })
            .store(in: &cancellables)
    }
}

struct ContentView: View {
    /// Create observed object for stock and crypto data
    @ObservedObject var dataManager = DataManager()
    
    /// Create a state variable to hold the user's selection of either stocks or crypto
    @State private var selection: Selection = .stocks

    var body: some View {
        VStack {
                /// Check if there was an error loading the data
                if dataManager.error != nil {
                    /// Show an error message
                    Text("An error occurred while loading data from API")
                } else if !dataManager.loadingCompleted {
                    /// Show a loading indicator
                    VStack {
                        ProgressView()
                            .padding(8)
                        Text("Loading...")
                    }
                } else {
                    /// Display a picker to let the user select either stocks or crypto
                    Picker("Options", selection: $selection) {
                        Text("Stocks").tag(Selection.stocks)
                        Text("Crypto").tag(Selection.crypto)
                    }
                    .pickerStyle(SegmentedPickerStyle())
                    .padding()

                    /// Show the list of either stocks or crypto depending on the user's selection
                    if selection == .crypto {
                        List(dataManager.cryptoViewModel.cryptoPrices) { cryptoPrice in
                            Text("\(cryptoPrice.name) (\(cryptoPrice.symbol)) - $\(cryptoPrice.quotes.USD.price)")
                        }
                    } else {
                        List(dataManager.stockViewModel.stocks) { stock in
                            Text("\(stock.symbol) - \(stock.longName) - $\(stock.regularMarketPrice)")
                        }
                    }
                }

            }
            .onAppear {
                /// Fetch the data
                self.dataManager.loadData()
            }
        }
}


Using the zip operator enables us to handle the data loading process in a more streamlined way. We no longer have to check the loading status of each data source separately. This can prevent partial updates or race conditions that could occur if the data was loaded asynchronously and displayed as soon as it was available.

The retry(#) modifier allows us to automatically retry the API request a couple times if it fails due to a network or server error. This can improve the reliability of the data loading process and reduce the likelihood of errors being displayed to the user.


Making your app faster and more efficient with caching and automatic data updates


If your database allows you to listen for changes, you can further improve your code by implementing listeners that detect changes in the data. Once the necessary data is loaded on start-up, you can continuously listen for changes and automatically update the data without requiring any manual action from the user. Caching the loaded data can also significantly improve app performance by eliminating the need to repeatedly fetch the same data, saving valuable time and resources.

This approach can greatly enhance the user experience by ensuring that the data is always up-to-date without the need for constant manual updates. Additionally, implementing listeners for automatic data updates and caching the loaded data can improve app performance overall.