πβ Values over Time
In this deep dive, weβll explore how to tackle real-time, asynchronous challenges in iOS development by leveraging three powerful frameworks: Combine, RxSwift, and Swift Concurrency. Each of these solutions offers unique tools and patterns to manage values as they evolve over time, making it easier to build responsive, data-driven applications.
Weβll cover a range of common scenarios, from handling real-time notifications to managing dependencies between tasks. For each problem, weβll present implementations using each framework, highlighting key differences and when to choose one approach over another. By the end, youβll have a comprehensive understanding of how to approach these types of challenges in a way that best suits your projectβs needs.
Hereβs a quick reference to the documentation for each framework:
- π Combine Documentation β Appleβs reactive programming framework, introduced in iOS 13, designed to handle asynchronous events with a declarative Swift API.
- π RxSwift Documentation β A popular, community-driven framework that brings Reactive Extensions to Swift, allowing for powerful, flexible reactive programming.
- π Swift Concurrency Documentation β A native Swift feature for handling asynchronous tasks using
async/await, alongside powerful tools likeAsyncSequenceandAsyncStream.
Each approach is designed to handle asynchronous events and changing values over time, but they differ in their syntax, capabilities, and ideal use cases. As we go through each task, youβll gain a practical understanding of how each framework can be applied to solve real-world iOS problems effectively.
Task 1: Network Requests with Retry and Error Handlingβ
Fetch a list of items from a REST API with a retry mechanism, attempting up to 3 times if the initial requests fail. Display the fetched data or, if the retries fail, show an error message to the user.
Solution 1: Combineβ
In this Combine solution:
- Retry Mechanism: The
retry(3)operator retries the request up to 3 times upon failure. - Error Handling: If all attempts fail,
sink(receiveCompletion:)captures the error and updateserrorMessage. - Data Handling: Successfully fetched items are assigned to
items, which is observed by the UI using@Published.
import Combine
import Foundation
struct Item: Codable {
let id: Int
let name: String
}
class ViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var errorMessage: String?
private var cancellables = Set<AnyCancellable>()
func fetchItems() {
let url = URL(string: "https://api.example.com/items")!
URLSession.shared.dataTaskPublisher(for: url)
.retry(3) // Retry up to 3 times if an error occurs
.map { $0.data }
.decode(type: [Item].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
self.errorMessage = "Failed to load data: \(error.localizedDescription)"
}
}, receiveValue: { [weak self] items in
self?.items = items
})
.store(in: &cancellables)
}
}
Solution 2: RxSwiftβ
In this RxSwift solution:
- Retry Mechanism: The
retry(3)operator retries the network request up to 3 times upon failure. - Error Handling: If all retries fail,
onErrorupdateserrorMessageto notify observers. - Data Handling: Successfully decoded items are emitted to the
itemssubject, which can be subscribed to by the UI.
import RxSwift
import RxCocoa
struct Item: Codable {
let id: Int
let name: String
}
class ViewModel {
let items = PublishSubject<[Item]>()
let errorMessage = PublishSubject<String>()
private let disposeBag = DisposeBag()
func fetchItems() {
let url = URL(string: "https://api.example.com/items")!
URLSession.shared.rx.data(request: URLRequest(url: url))
.retry(3) // Retry up to 3 times if an error occurs
.map { data -> [Item] in
let decoder = JSONDecoder()
return try decoder.decode([Item].self, from: data)
}
.observe(on: MainScheduler.instance)
.subscribe(
onNext: { [weak self] items in
self?.items.onNext(items)
},
onError: { [weak self] error in
self?.errorMessage.onNext("Failed to load data: \(error.localizedDescription)")
}
)
.disposed(by: disposeBag)
}
}
Solution 3: Swift Concurrencyβ
In this Swift Concurrency solution using the Observation framework:
- Retry Mechanism: We manually implement a retry mechanism using a
whileloop, which retries up to 3 times. - Error Handling: After 3 failed attempts,
errorMessageis set to notify the UI of the failure. - Data Handling: If successful,
itemsis populated and automatically observed by SwiftUI or other components due to the@Observableproperty wrapper.
import Foundation
import Observation
struct Item: Codable {
let id: Int
let name: String
}
@Observable
class ViewModel {
var items: [Item] = []
var errorMessage: String?
func fetchItems() async {
let url = URL(string: "https://api.example.com/items")!
var attempts = 0
while attempts < 3 {
do {
let (data, _) = try await URLSession.shared.data(from: url)
items = try JSONDecoder().decode([Item].self, from: data)
return // Exit if successful
} catch {
attempts += 1
if attempts == 3 {
errorMessage = "Failed to load data: \(error.localizedDescription)"
}
}
}
}
}
Summary of Differencesβ
- Retry Mechanism:
- Combine and RxSwift offer built-in
retryoperators, simplifying retries. - Swift Concurrency requires a manual retry loop, giving full control over retries but requiring extra code.
- Combine and RxSwift offer built-in
- Error Handling:
- Combine and RxSwift manage errors through their respective
sinkandsubscribemethods. - Swift Concurrency uses a
do-catchblock and checks retry attempts to handle errors.
- Combine and RxSwift manage errors through their respective
- State Observation:
- Combine and RxSwift use
@PublishedandPublishSubject, respectively, for reactive updates. - Swift Concurrency leverages the Observation framework with
@Observable, making it a fully native Swift approach that is easy to use with SwiftUI.
- Combine and RxSwift use
Task 2: Real-Time Search with Debounceβ
Implement a real-time search that updates the results as the user types, but with a debounce to avoid making a request for every keystroke. This means waiting for a short pause (e.g., 0.5 seconds) before sending the search request.
Solution 1: Combineβ
import Combine
import Foundation
struct SearchResult: Codable {
let id: Int
let name: String
}
class SearchViewModel: ObservableObject {
@Published var query: String = ""
@Published var results: [SearchResult] = []
@Published var errorMessage: String?
private var cancellables = Set<AnyCancellable>()
init() {
$query
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) // Debounce for 0.5 seconds
.removeDuplicates()
.flatMap { query -> AnyPublisher<[SearchResult], Never> in
guard !query.isEmpty else {
return Just([]).eraseToAnyPublisher()
}
let url = URL(string: "https://api.example.com/search?query=\(query)")!
return URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: [SearchResult].self, decoder: JSONDecoder())
.catch { _ in Just([]) }
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.assign(to: \.results, on: self)
.store(in: &cancellables)
}
}
In this Combine solution:
- Debounce: The
debounceoperator delays the search request by 0.5 seconds after the last keystroke. - FlatMap and Error Handling:
flatMapallows the query to be mapped to a network request, whilecatchhandles errors by returning an empty result. - Real-Time Update: The search results are directly assigned to
results, updating the UI whenever new results are available.
Solution 2: RxSwiftβ
import RxSwift
import RxCocoa
struct SearchResult: Codable {
let id: Int
let name: String
}
class SearchViewModel {
let query = BehaviorSubject<String>(value: "")
let results = PublishSubject<[SearchResult]>()
let errorMessage = PublishSubject<String>()
private let disposeBag = DisposeBag()
init() {
query
.debounce(.milliseconds(500), scheduler: MainScheduler.instance) // Debounce for 0.5 seconds
.distinctUntilChanged()
.flatMapLatest { query -> Observable<[SearchResult]> in
guard !query.isEmpty else {
return Observable.just([])
}
let url = URL(string: "https://api.example.com/search?query=\(query)")!
return URLSession.shared.rx.data(request: URLRequest(url: url))
.map { data in
try JSONDecoder().decode([SearchResult].self, from: data)
}
.catchAndReturn([])
}
.bind(to: results)
.disposed(by: disposeBag)
}
}
In this RxSwift solution:
- Debounce: The
debounceoperator withdistinctUntilChangedprevents unnecessary requests and only triggers when the query text changes. - FlatMapLatest and Error Handling:
flatMapLatestmaps each query to a network request, andcatchAndReturn([])provides an empty result if an error occurs. - Real-Time Update: Results are bound to
results, which observers (like UI components) can reactively subscribe to.
Solution 3: Swift Concurrencyβ
import Foundation
import Observation
struct SearchResult: Codable {
let id: Int
let name: String
}
@Observable
class SearchViewModel {
var query: String = "" {
didSet { debounceSearch() }
}
var results: [SearchResult] = []
var errorMessage: String?
private var searchTask: Task<Void, Never>? = nil
func debounceSearch() {
searchTask?.cancel() // Cancel any existing task
searchTask = Task {
try await Task.sleep(nanoseconds: 500_000_000) // 0.5-second debounce
await performSearch(for: query)
}
}
private func performSearch(for query: String) async {
guard !query.isEmpty else {
results = []
return
}
let url = URL(string: "https://api.example.com/search?query=\(query)")!
do {
let (data, _) = try await URLSession.shared.data(from: url)
let decodedResults = try JSONDecoder().decode([SearchResult].self, from: data)
results = decodedResults
} catch {
errorMessage = "Failed to load search results: \(error.localizedDescription)"
}
}
}
In this Swift Concurrency solution:
- Debounce:
debounceSearch()cancels any ongoingTaskand starts a new one, adding a 0.5-second delay before callingperformSearch. - Error Handling: The
do-catchblock withinperformSearchhandles any potential network or decoding errors. - Real-Time Update: The results are directly updated in
results, which is observed by SwiftUI due to the@Observableattribute.
Summary of Differencesβ
-
Debounce Mechanism:
- Combine and RxSwift leverage built-in
debounceoperators. - Swift Concurrency requires a custom debounce function with
Task.sleep.
- Combine and RxSwift leverage built-in
-
Error Handling:
- Combine and RxSwift use
catchandcatchAndReturnto handle errors and provide fallback values. - Swift Concurrency uses a
do-catchblock for error handling within theperformSearchfunction.
- Combine and RxSwift use
-
State Observation:
- Combine and RxSwift rely on reactive properties like
@PublishedandPublishSubject. - Swift Concurrency with Observation uses
@Observable, allowing automatic UI updates.
- Combine and RxSwift rely on reactive properties like
Task 3: Form Validation and Input Handlingβ
Validate form fields in real-time and enable the submit button only when all fields are valid. Weβll assume a form with two fields: an email and a password. The email must follow a valid format, and the password must be at least 8 characters long.
Solution 1: Combineβ
import Combine
import Foundation
class FormViewModel: ObservableObject {
@Published var email: String = ""
@Published var password: String = ""
@Published var isFormValid: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
Publishers.CombineLatest($email, $password)
.map { email, password in
return self.isValidEmail(email) && self.isValidPassword(password)
}
.assign(to: &$isFormValid)
}
private func isValidEmail(_ email: String) -> Bool {
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
return NSPredicate(format: "SELF MATCHES %@", emailRegex).evaluate(with: email)
}
private func isValidPassword(_ password: String) -> Bool {
return password.count >= 8
}
}
In this Combine solution:
- CombineLatest: We use
Publishers.CombineLatestto observe changes in bothemailandpasswordfields. - Validation Logic:
mapapplies the validation logic, checking the email format and password length, and updatesisFormValidbased on both fields' validity. - Real-Time Update:
@Publishedensures thatisFormValidupdates reactively, enabling or disabling the submit button in the UI.
Solution 2: RxSwiftβ
import RxSwift
import RxCocoa
class FormViewModel {
let email = BehaviorSubject<String>(value: "")
let password = BehaviorSubject<String>(value: "")
let isFormValid: Observable<Bool>
init() {
isFormValid = Observable.combineLatest(email, password)
.map { email, password in
return self.isValidEmail(email) && self.isValidPassword(password)
}
}
private func isValidEmail(_ email: String) -> Bool {
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
return NSPredicate(format: "SELF MATCHES %@", emailRegex).evaluate(with: email)
}
private func isValidPassword(_ password: String) -> Bool {
return password.count >= 8
}
}
In this RxSwift solution:
- CombineLatest:
Observable.combineLatestobserves changes to bothemailandpassword. - Validation Logic:
mapchecks the email and password validity. TheisFormValidobservable emitstrueorfalsebased on the fields' validity. - Real-Time Update:
isFormValidcan be bound to the UI, updating the submit button's state in real-time.
Solution 3: Swift Concurrencyβ
import Foundation
import Observation
@Observable
class FormViewModel {
var email: String = "" {
didSet { validateForm() }
}
var password: String = "" {
didSet { validateForm() }
}
var isFormValid: Bool = false
private func validateForm() {
isFormValid = isValidEmail(email) && isValidPassword(password)
}
private func isValidEmail(_ email: String) -> Bool {
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
return NSPredicate(format: "SELF MATCHES %@", emailRegex).evaluate(with: email)
}
private func isValidPassword(_ password: String) -> Bool {
return password.count >= 8
}
}
In this Swift Concurrency solution using Observation:
- Real-Time Validation: The
didSetobservers onemailandpasswordtrigger thevalidateForm()function, updatingisFormValidwhenever either field changes. - Validation Logic:
isFormValidis updated based onisValidEmailandisValidPasswordresults. - Real-Time Update: Using
@Observableautomatically makesisFormValidobservable, so any UI component that observesFormViewModelwill reactively update.
Summary of Differencesβ
-
CombineLatest:
- Combine and RxSwift use
CombineLatestoperators to monitor changes inemailandpasswordand apply validation logic in amapfunction. - Swift Concurrency does not use
CombineLatestbut leveragesdidSetto trigger validation when fields change.
- Combine and RxSwift use
-
Validation Logic:
- Combine and RxSwift handle validation in their reactive pipeline with
map. - Swift Concurrency performs validation within
validateForm(), directly settingisFormValid.
- Combine and RxSwift handle validation in their reactive pipeline with
-
State Observation:
- Combine and RxSwift use
@PublishedandBehaviorSubject, respectively, for reactive updates. - Swift Concurrency uses
@Observable, making the state changes natively observable.
- Combine and RxSwift use
Task 4: Real-Time Notifications with Pollingβ
Set up a mechanism to fetch new data from the server periodically, simulating a real-time notifications feature. For this example, we'll poll an API every 10 seconds for new messages. If there are new messages, they will be appended to the list; otherwise, no updates will be made.
Solution 1: Combineβ
import Combine
import Foundation
struct Message: Codable {
let id: Int
let content: String
}
class NotificationViewModel: ObservableObject {
@Published var messages: [Message] = []
private var cancellables = Set<AnyCancellable>()
func startPolling() {
Timer.publish(every: 10, on: .main, in: .common)
.autoconnect()
.flatMap { _ -> AnyPublisher<[Message], Never> in
let url = URL(string: "https://api.example.com/messages")!
return URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: [Message].self, decoder: JSONDecoder())
.catch { _ in Just([]) }
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink { [weak self] newMessages in
self?.messages.append(contentsOf: newMessages)
}
.store(in: &cancellables)
}
}
In this Combine solution:
- Polling with Timer: We use
Timer.publishto trigger the polling every 10 seconds. - Data Fetching:
flatMapmakes a network request to fetch new messages. If the request fails, we return an empty array to avoid disrupting the stream. - Data Handling: New messages are appended to
messages, which the UI observes for real-time updates.
Solution 2: RxSwiftβ
import RxSwift
import RxCocoa
struct Message: Codable {
let id: Int
let content: String
}
class NotificationViewModel {
let messages = BehaviorSubject<[Message]>(value: [])
private let disposeBag = DisposeBag()
func startPolling() {
Observable<Int>.interval(.seconds(10), scheduler: MainScheduler.instance)
.flatMapLatest { _ -> Observable<[Message]> in
let url = URL(string: "https://api.example.com/messages")!
return URLSession.shared.rx.data(request: URLRequest(url: url))
.map { data in
try JSONDecoder().decode([Message].self, from: data)
}
.catchAndReturn([])
}
.scan([]) { currentMessages, newMessages in
return currentMessages + newMessages // Append new messages
}
.bind(to: messages)
.disposed(by: disposeBag)
}
}
In this RxSwift solution:
- Polling with Interval:
Observable.intervaltriggers every 10 seconds. - Data Fetching:
flatMapLatestperforms the network request, returning an empty array on error. - Accumulating Messages:
scanaccumulates messages by appending new ones to the current list and emitting the updated list.
Solution 3: Swift Concurrencyβ
import Foundation
import Observation
import AsyncAlgorithms
struct Message: Codable {
let id: Int
let content: String
}
@Observable
class NotificationViewModel {
var messages: [Message] = []
private var pollingTask: Task<Void, Never>? = nil
func startPolling() {
pollingTask?.cancel() // Cancel any existing polling task if already running
pollingTask = Task {
for await _ in AsyncTimerSequence.repeating(every: .seconds(10)) {
await fetchMessages()
}
}
}
func stopPolling() {
pollingTask?.cancel() // Stop polling when needed
}
private func fetchMessages() async {
let url = URL(string: "https://api.example.com/messages")!
do {
let (data, _) = try await URLSession.shared.data(from: url)
let newMessages = try JSONDecoder().decode([Message].self, from: data)
messages.append(contentsOf: newMessages)
} catch {
print("Failed to fetch messages: \(error.localizedDescription)")
}
}
}
In this Swift Concurrency solution with AsyncAlgorithms:
- Polling with AsyncTimerSequence: We use
AsyncTimerSequence.repeatingto poll every 10 seconds asynchronously, creating an elegant, asynchronous loop without blocking the main thread. - Task Cancelation:
pollingTask?.cancel()allows us to start and stop polling as needed, controlling the taskβs lifecycle. - Data Fetching:
fetchMessages()performs the network request, appending any new messages tomessages.
Summary of Differencesβ
-
Polling Mechanism:
- Combine uses
Timer.publishfor periodic polling. - RxSwift uses
Observable.interval. - Swift Concurrency with AsyncAlgorithms utilizes
AsyncTimerSequence.repeatingfor asynchronous polling, which integrates well with the concurrency model.
- Combine uses
-
Task Management:
- Combine and RxSwift donβt directly manage tasks, instead relying on publishers or observables for continuous polling.
- Swift Concurrency manages a
Task(pollingTask) explicitly, allowing for start/stop control throughcancel().
-
Data Accumulation:
- Combine and RxSwift append new messages using
sinkandscan, respectively. - Swift Concurrency appends new messages directly in
fetchMessages, updatingmessagesfor real-time UI binding.
- Combine and RxSwift append new messages using
Task 5: Handling Complex User Interactions with Sequential Button Tapsβ
Implement a sequence of interactions where three buttons need to be tapped in the correct order (Button A, Button B, Button C) to unlock a special feature. If the user taps out of order, the sequence resets. This task highlights managing ordered events and handling sequences of user actions reactively.
Solution 1: Combineβ
import Combine
import Foundation
class InteractionViewModel: ObservableObject {
@Published var isFeatureUnlocked: Bool = false
private var cancellables = Set<AnyCancellable>()
// Enum for button sequence
enum Button: String {
case A, B, C
}
// Subject to track button taps
private let buttonTapSubject = PassthroughSubject<Button, Never>()
init() {
buttonTapSubject
.scan([]) { (sequence: [Button], newButton: Button) in
// Reset sequence if tapped out of order
let expectedSequence: [Button] = [.A, .B, .C]
if sequence + [newButton] == Array(expectedSequence.prefix(sequence.count + 1)) {
return sequence + [newButton]
} else {
return [newButton] == [.A] ? [newButton] : []
}
}
.map { sequence in
return sequence == [.A, .B, .C]
}
.assign(to: &$isFeatureUnlocked)
}
func tapButton(_ button: Button) {
buttonTapSubject.send(button)
}
}
In this Combine solution:
- Button Sequence Tracking: We use
PassthroughSubjectto emit button taps as events. - Sequence Logic with Scan:
scanaccumulates button taps into a sequence, resetting if the order is incorrect or starting over if Button A is tapped out of sequence. - Unlock Feature: When the correct sequence
[A, B, C]is completed,isFeatureUnlockedis set totrue, which the UI observes.
Solution 2: RxSwiftβ
import RxSwift
import RxCocoa
class InteractionViewModel {
let isFeatureUnlocked = BehaviorSubject<Bool>(value: false)
// Enum for button sequence
enum Button: String {
case A, B, C
}
// PublishSubject to track button taps
private let buttonTapSubject = PublishSubject<Button>()
private let disposeBag = DisposeBag()
init() {
buttonTapSubject
.scan([]) { (sequence: [Button], newButton: Button) in
// Reset sequence if tapped out of order
let expectedSequence: [Button] = [.A, .B, .C]
if sequence + [newButton] == Array(expectedSequence.prefix(sequence.count + 1)) {
return sequence + [newButton]
} else {
return [newButton] == [.A] ? [newButton] : []
}
}
.map { sequence in
return sequence == [.A, .B, .C]
}
.bind(to: isFeatureUnlocked)
.disposed(by: disposeBag)
}
func tapButton(_ button: Button) {
buttonTapSubject.onNext(button)
}
}
In this RxSwift solution:
- Button Sequence Tracking:
PublishSubjectis used to emit each button tap. - Sequence Logic with Scan:
scanmaintains the sequence, resetting if tapped out of order. If Button A is tapped after an invalid tap, the sequence restarts from Button A. - Unlock Feature: When the sequence
[A, B, C]is achieved,isFeatureUnlockedis updated, which the UI can observe reactively.
Solution 3: Swift Concurrencyβ
import Foundation
import Observation
import AsyncAlgorithms
@Observable
class InteractionViewModel {
var isFeatureUnlocked: Bool = false
private var buttonTapStream = AsyncChannel<Button>()
// Enum for button sequence
enum Button: String {
case A, B, C
}
init() {
Task {
await observeButtonTaps()
}
}
func tapButton(_ button: Button) {
Task {
await buttonTapStream.send(button)
}
}
private func observeButtonTaps() async {
let expectedSequence: [Button] = [.A, .B, .C]
var sequence: [Button] = []
for await button in buttonTapStream {
sequence.append(button)
if sequence == Array(expectedSequence.prefix(sequence.count)) {
if sequence == expectedSequence {
isFeatureUnlocked = true
sequence = [] // Reset the sequence after unlocking
}
} else {
sequence = button == .A ? [button] : [] // Reset or start over if Button A is tapped
}
}
}
}
In this Swift Concurrency solution with AsyncAlgorithms:
- AsyncChannel for Event Stream:
AsyncChannelallows us to capture button taps as a continuous stream. - Sequence Logic:
observeButtonTapsruns in an async loop, tracking the sequence. If a button is tapped out of order, it resets the sequence unless it starts with Button A. - Unlock Feature: Once the correct sequence
[A, B, C]is detected,isFeatureUnlockedis set totrue, notifying the UI.
Summary of Differencesβ
-
Event Stream:
- Combine and RxSwift use
PassthroughSubjectandPublishSubject, respectively, to emit button taps as events. - Swift Concurrency with AsyncAlgorithms uses
AsyncChannelto capture button taps as an asynchronous sequence.
- Combine and RxSwift use
-
Sequence Logic:
- Combine and RxSwift use
scanto maintain and check the sequence order. - Swift Concurrency manages the sequence manually in
observeButtonTaps, providing explicit control over sequence validation.
- Combine and RxSwift use
-
Feature Unlocking:
- All three frameworks update
isFeatureUnlockedwhen the correct sequence[A, B, C]is completed, enabling the UI to reactively respond.
- All three frameworks update
Task 6: Animation Triggers and UI State Changesβ
Implement a loading indicator that appears while data is being fetched and disappears once the data load completes. Additionally, weβll animate the indicatorβs appearance and disappearance to create a smooth transition.
Solution 1: Combineβ
import Combine
import Foundation
class AnimationViewModel: ObservableObject {
@Published var isLoading: Bool = false
@Published var data: [String] = []
private var cancellables = Set<AnyCancellable>()
func fetchData() {
isLoading = true
let url = URL(string: "https://api.example.com/data")!
URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: [String].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
print("Error fetching data: \(error)")
}
}, receiveValue: { [weak self] data in
self?.data = data
})
.store(in: &cancellables)
}
}
In this Combine solution:
- Loading State:
isLoadingis set totrueat the start of the fetch andfalseupon completion. - Animation Trigger:
isLoadingcan be observed by the UI to animate the loading indicator in/out based on its value. - Data Fetching: The network request updates
datawhen complete, and any errors are printed to the console.
Solution 2: RxSwiftβ
import RxSwift
import RxCocoa
class AnimationViewModel {
let isLoading = BehaviorSubject<Bool>(value: false)
let data = BehaviorSubject<[String]>(value: [])
private let disposeBag = DisposeBag()
func fetchData() {
isLoading.onNext(true)
let url = URL(string: "https://api.example.com/data")!
URLSession.shared.rx.data(request: URLRequest(url: url))
.map { data in
try JSONDecoder().decode([String].self, from: data)
}
.observe(on: MainScheduler.instance)
.subscribe(
onNext: { [weak self] data in
self?.data.onNext(data)
self?.isLoading.onNext(false)
},
onError: { [weak self] error in
print("Error fetching data: \(error)")
self?.isLoading.onNext(false)
}
)
.disposed(by: disposeBag)
}
}
In this RxSwift solution:
- Loading State:
isLoadingemitstrueat the start andfalsewhen complete, signaling the UI to animate the loading indicator in/out. - Data Fetching: The network request updates
dataupon successful completion, or logs an error if it fails. - Animation Trigger: The UI can subscribe to
isLoadingand trigger animations based on its values.
Solution 3: Swift Concurrencyβ
import Foundation
import Observation
@Observable
class AnimationViewModel {
var isLoading: Bool = false
var data: [String] = []
func fetchData() async {
isLoading = true
let url = URL(string: "https://api.example.com/data")!
do {
let (data, _) = try await URLSession.shared.data(from: url)
self.data = try JSONDecoder().decode([String].self, from: data)
} catch {
print("Error fetching data: \(error)")
}
isLoading = false
}
}
In this Swift Concurrency solution using Observation:
- Loading State:
isLoadingis set totrueat the start andfalseafter the request completes, updating the UI to animate in/out the loading indicator. - Data Fetching: The asynchronous network request assigns the decoded data to
data. - Animation Trigger: Since
isLoadingis observable, UI components can animate based on its state changes.
Summary of Differencesβ
-
Loading State Management:
- Combine and RxSwift use
@PublishedandBehaviorSubject, respectively, to track the loading state. - Swift Concurrency with Observation directly modifies the
isLoadingproperty, making it observable for UI updates.
- Combine and RxSwift use
-
Data Fetching:
- Combine and RxSwift use reactive operators (
map,decode,observe) to handle data fetch and update the UI reactively. - Swift Concurrency performs data fetching with
async/await, directly assigning the result todata.
- Combine and RxSwift use reactive operators (
-
Animation Trigger:
- All solutions enable the UI to observe
isLoadingand use it to trigger animations. However, Combine and RxSwift use reactive streams, while Swift Concurrency leverages property observation with@Observable.
- All solutions enable the UI to observe
Task 7: Polling and Periodic Updatesβ
Set up a mechanism to periodically fetch data from an API (e.g., every 15 seconds). This will allow us to display up-to-date information without requiring the user to manually refresh.
Solution 1: Combineβ
import Combine
import Foundation
class PollingViewModel: ObservableObject {
@Published var data: [String] = []
private var cancellables = Set<AnyCancellable>()
func startPolling() {
Timer.publish(every: 15, on: .main, in: .common)
.autoconnect()
.flatMap { _ -> AnyPublisher<[String], Never> in
let url = URL(string: "https://api.example.com/data")!
return URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: [String].self, decoder: JSONDecoder())
.catch { _ in Just([]) }
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink { [weak self] newData in
self?.data = newData
}
.store(in: &cancellables)
}
func stopPolling() {
cancellables.removeAll()
}
}
In this Combine solution:
- Periodic Polling:
Timer.publishtriggers every 15 seconds to initiate a data fetch. - Data Fetching:
flatMapperforms the network request, and any errors are handled by returning an empty array. - Polling Control: Calling
stopPolling()cancels all publishers, effectively stopping the periodic updates.
Solution 2: RxSwiftβ
import RxSwift
import RxCocoa
class PollingViewModel {
let data = BehaviorSubject<[String]>(value: [])
private let disposeBag = DisposeBag()
private let interval = Observable<Int>.interval(.seconds(15), scheduler: MainScheduler.instance)
func startPolling() {
interval
.flatMapLatest { _ -> Observable<[String]> in
let url = URL(string: "https://api.example.com/data")!
return URLSession.shared.rx.data(request: URLRequest(url: url))
.map { data in
try JSONDecoder().decode([String].self, from: data)
}
.catchAndReturn([])
}
.bind(to: data)
.disposed(by: disposeBag)
}
}
In this RxSwift solution:
- Periodic Polling:
Observable.intervalemits an event every 15 seconds, triggering the data fetch. - Data Fetching:
flatMapLatestperforms the network request, handling errors withcatchAndReturn([])to return an empty array in case of failure. - Data Binding: The result is bound to
data, which the UI can observe for real-time updates.
Solution 3: Swift Concurrencyβ
import Foundation
import Observation
import AsyncAlgorithms
@Observable
class PollingViewModel {
var data: [String] = []
private var pollingTask: Task<Void, Never>? = nil
func startPolling() {
pollingTask?.cancel() // Cancel any existing polling task if already running
pollingTask = Task {
for await _ in AsyncTimerSequence.repeating(every: .seconds(15)) {
await fetchData()
}
}
}
func stopPolling() {
pollingTask?.cancel() // Stop polling when needed
}
private func fetchData() async {
let url = URL(string: "https://api.example.com/data")!
do {
let (data, _) = try await URLSession.shared.data(from: url)
self.data = try JSONDecoder().decode([String].self, from: data)
} catch {
print("Error fetching data: \(error)")
}
}
}
In this Swift Concurrency solution with AsyncAlgorithms:
- Periodic Polling: We use
AsyncTimerSequence.repeatingto create a repeating async sequence, polling every 15 seconds without blocking the main thread. - Task Control:
pollingTaskis used to manage the taskβs lifecycle, allowing for start/stop control withstartPollingandstopPolling. - Data Fetching:
fetchData()performs the network request, decoding the data and updatingdatawith the results.
Summary of Differencesβ
-
Periodic Polling:
- Combine uses
Timer.publishfor timed events. - RxSwift uses
Observable.interval. - Swift Concurrency with AsyncAlgorithms uses
AsyncTimerSequence.repeating, seamlessly integrating periodic polling into the concurrency model.
- Combine uses
-
Task Management:
- Combine and RxSwift stop polling by canceling the stream of events.
- Swift Concurrency uses
pollingTaskto explicitly start and stop polling, allowing for more controlled task cancellation.
-
Error Handling:
- Combine and RxSwift use
catchoperators to handle errors gracefully by returning an empty result. - Swift Concurrency handles errors within a
do-catchblock infetchData(), logging any issues to the console.
- Combine and RxSwift use
Task 8: Data Synchronization and Offline Supportβ
Implement a system to save data locally when the device is offline, and sync it to a remote server once the internet connection is restored. For this example, weβll focus on a to-do list app where new items are saved locally if the network is unavailable and uploaded to the server once the connection is re-established.
Solution 1: Combineβ
import Combine
import Foundation
struct TodoItem: Codable, Identifiable {
let id: UUID
let title: String
}
class SyncViewModel: ObservableObject {
@Published var todos: [TodoItem] = []
private var cancellables = Set<AnyCancellable>()
private let networkMonitor = NetworkMonitor()
func addTodo(_ title: String) {
let newTodo = TodoItem(id: UUID(), title: title)
todos.append(newTodo)
if networkMonitor.isConnected {
syncTodos()
} else {
saveLocally(newTodo)
}
}
private func saveLocally(_ item: TodoItem) {
// Save item to local storage (e.g., UserDefaults, CoreData, or file)
print("Saved locally: \(item.title)")
}
private func syncTodos() {
// Fetch unsynced items from local storage and upload them
let url = URL(string: "https://api.example.com/todos")!
Just(todos)
.setFailureType(to: URLError.self)
.flatMap { todos in
URLSession.shared.dataTaskPublisher(for: url)
.tryMap { data, _ in
return try JSONDecoder().decode([TodoItem].self, from: data)
}
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
print("Sync failed: \(error)")
}
}, receiveValue: { [weak self] updatedTodos in
self?.todos = updatedTodos
print("Synced successfully")
})
.store(in: &cancellables)
}
}
In this Combine solution:
- Network Monitoring:
networkMonitor.isConnectedchecks network availability. - Local Storage:
saveLocallyis a placeholder for saving data locally when offline. - Synchronization:
syncTodosuploads unsynced items to the server when the connection is restored, and the response updatestodos.
Solution 2: RxSwiftβ
import RxSwift
import RxCocoa
struct TodoItem: Codable, Identifiable {
let id: UUID
let title: String
}
class SyncViewModel {
let todos = BehaviorSubject<[TodoItem]>(value: [])
private let disposeBag = DisposeBag()
private let networkMonitor = NetworkMonitor()
func addTodo(_ title: String) {
var currentTodos = try! todos.value()
let newTodo = TodoItem(id: UUID(), title: title)
currentTodos.append(newTodo)
todos.onNext(currentTodos)
if networkMonitor.isConnected {
syncTodos()
} else {
saveLocally(newTodo)
}
}
private func saveLocally(_ item: TodoItem) {
// Save item to local storage (e.g., UserDefaults, CoreData, or file)
print("Saved locally: \(item.title)")
}
private func syncTodos() {
let url = URL(string: "https://api.example.com/todos")!
todos
.take(1) // Take the latest todo list once
.flatMap { todos -> Observable<[TodoItem]> in
return URLSession.shared.rx.data(request: URLRequest(url: url))
.map { data in
try JSONDecoder().decode([TodoItem].self, from: data)
}
.catchAndReturn([])
}
.subscribe(onNext: { [weak self] updatedTodos in
self?.todos.onNext(updatedTodos)
print("Synced successfully")
}, onError: { error in
print("Sync failed: \(error)")
})
.disposed(by: disposeBag)
}
}
In this RxSwift solution:
- Network Monitoring: We use
networkMonitor.isConnectedto check if the device is online. - Local Storage:
saveLocallysaves data locally when offline. - Synchronization:
syncTodosfetches and uploads unsynced items to the server, updatingtodosupon success or handling errors if syncing fails.
Solution 3: Swift Concurrencyβ
import Foundation
import Observation
import AsyncAlgorithms
struct TodoItem: Codable, Identifiable {
let id: UUID
let title: String
}
@Observable
class SyncViewModel {
var todos: [TodoItem] = []
private var syncTask: Task<Void, Never>? = nil
private let networkMonitor = NetworkMonitor()
func addTodo(_ title: String) {
let newTodo = TodoItem(id: UUID(), title: title)
todos.append(newTodo)
if networkMonitor.isConnected {
startSyncing()
} else {
saveLocally(newTodo)
}
}
private func saveLocally(_ item: TodoItem) {
// Save item to local storage (e.g., UserDefaults, CoreData, or file)
print("Saved locally: \(item.title)")
}
private func startSyncing() {
syncTask?.cancel() // Cancel any existing sync task
syncTask = Task {
for await isConnected in networkMonitor.networkStatusStream() {
if isConnected {
await syncTodos()
}
}
}
}
private func syncTodos() async {
let url = URL(string: "https://api.example.com/todos")!
do {
let (data, _) = try await URLSession.shared.data(from: url)
let syncedTodos = try JSONDecoder().decode([TodoItem].self, from: data)
todos = syncedTodos
print("Synced successfully")
} catch {
print("Sync failed: \(error)")
}
}
}
In this Swift Concurrency solution with AsyncAlgorithms:
- Network Monitoring:
networkMonitor.networkStatusStream()is an async sequence emitting network status changes, triggeringsyncTodoswhen the connection is restored. - Local Storage:
saveLocallyis used to store data locally if the device is offline. - Synchronization:
syncTodosperforms an asynchronous sync with the server when online, updatingtodosor logging an error on failure.
Summary of Differencesβ
-
Network Monitoring:
- Combine and RxSwift rely on checking
networkMonitor.isConnectedbefore attempting synchronization. - Swift Concurrency uses
AsyncAlgorithmsto continuously listen for network status changes vianetworkStatusStream().
- Combine and RxSwift rely on checking
-
Data Storage and Syncing:
- Combine and RxSwift perform synchronization when connectivity is restored by calling
syncTodosdirectly. - Swift Concurrency uses an asynchronous loop with
syncTask, syncing data when the network status changes.
- Combine and RxSwift perform synchronization when connectivity is restored by calling
-
Error Handling:
- Combine and RxSwift handle errors in their reactive pipelines with
catchandcatchAndReturn. - Swift Concurrency handles errors within an async
do-catchblock insyncTodos.
- Combine and RxSwift handle errors in their reactive pipelines with
Task 9: Managing Dependencies Between Multiple Asynchronous Tasksβ
Implement a mechanism to fetch data from two different APIs, process both sets of data, and display the combined result. Both tasks should run concurrently, but we need to wait until both are complete before displaying the final result.
Solution 1: Combineβ
import Combine
import Foundation
class DependencyViewModel: ObservableObject {
@Published var combinedData: [String] = []
private var cancellables = Set<AnyCancellable>()
func fetchData() {
let url1 = URL(string: "https://api.example.com/data1")!
let url2 = URL(string: "https://api.example.com/data2")!
let publisher1 = URLSession.shared.dataTaskPublisher(for: url1)
.map { $0.data }
.decode(type: [String].self, decoder: JSONDecoder())
.catch { _ in Just([]) }
let publisher2 = URLSession.shared.dataTaskPublisher(for: url2)
.map { $0.data }
.decode(type: [String].self, decoder: JSONDecoder())
.catch { _ in Just([]) }
Publishers.Zip(publisher1, publisher2)
.map { data1, data2 in
return data1 + data2 // Combine both results
}
.receive(on: DispatchQueue.main)
.sink { [weak self] combinedData in
self?.combinedData = combinedData
}
.store(in: &cancellables)
}
}
In this Combine solution:
- Concurrent Tasks:
publisher1andpublisher2fetch data concurrently. - Combine Results:
Publishers.Zipwaits for both publishers to complete, then combines their results into a single array. - Error Handling: Each publisher uses
catchto handle errors and provides an empty array if a request fails.
Solution 2: RxSwiftβ
import RxSwift
import RxCocoa
class DependencyViewModel {
let combinedData = BehaviorSubject<[String]>(value: [])
private let disposeBag = DisposeBag()
func fetchData() {
let url1 = URL(string: "https://api.example.com/data1")!
let url2 = URL(string: "https://api.example.com/data2")!
let request1 = URLSession.shared.rx.data(request: URLRequest(url: url1))
.map { data in
try JSONDecoder().decode([String].self, from: data)
}
.catchAndReturn([])
let request2 = URLSession.shared.rx.data(request: URLRequest(url: url2))
.map { data in
try JSONDecoder().decode([String].self, from: data)
}
.catchAndReturn([])
Observable.zip(request1, request2)
.map { data1, data2 in
return data1 + data2 // Combine both results
}
.bind(to: combinedData)
.disposed(by: disposeBag)
}
}
In this RxSwift solution:
- Concurrent Tasks:
request1andrequest2are two observables that fetch data concurrently. - Combine Results:
Observable.zipwaits until both observables complete, then combines their results into a single array. - Error Handling: Each observable uses
catchAndReturnto provide an empty array if a request fails.
Solution 3: Swift Concurrencyβ
import Foundation
import Observation
@Observable
class DependencyViewModel {
var combinedData: [String] = []
func fetchData() async {
let url1 = URL(string: "https://api.example.com/data1")!
let url2 = URL(string: "https://api.example.com/data2")!
do {
async let data1 = fetchData(from: url1)
async let data2 = fetchData(from: url2)
let combinedData = try await data1 + data2
self.combinedData = combinedData
} catch {
print("Error fetching data: \(error)")
}
}
private func fetchData(from url: URL) async throws -> [String] {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([String].self, from: data)
}
}
In this Swift Concurrency solution:
- Concurrent Tasks with async let:
async letrunsfetchData(from:)concurrently forurl1andurl2. - Combine Results:
try await data1 + data2waits until both tasks complete and combines their results. - Error Handling: Errors are handled in the
do-catchblock, which prints an error message if any task fails.
Summary of Differencesβ
-
Concurrent Execution:
- Combine uses separate publishers for each request and combines them with
Publishers.Zip. - RxSwift uses two observables and combines them with
Observable.zip. - Swift Concurrency uses
async letto execute both fetch tasks concurrently, providing a straightforward concurrency model.
- Combine uses separate publishers for each request and combines them with
-
Combining Results:
- Combine and RxSwift use
Zipoperators to combine results when both requests complete. - Swift Concurrency directly combines results with
try await, keeping the syntax clear and concise.
- Combine and RxSwift use
-
Error Handling:
- Combine and RxSwift use
catchoperators to handle errors and provide fallback values. - Swift Concurrency uses a
do-catchblock, handling errors for both tasks within the same structure.
- Combine and RxSwift use
Task 10: Real-Time Notifications and Alertsβ
Set up a real-time notification system to listen for events from a server and display an alert when new data is received. For simplicity, weβll simulate server-sent events with a timer that emits a new "notification" every few seconds.
Solution 1: Combineβ
import Combine
import Foundation
class NotificationViewModel: ObservableObject {
@Published var latestNotification: String = ""
private var cancellables = Set<AnyCancellable>()
func startListeningForNotifications() {
Timer.publish(every: 5, on: .main, in: .common)
.autoconnect()
.map { _ in
return "New notification received at \(Date())"
}
.sink { [weak self] notification in
self?.latestNotification = notification
print(notification)
}
.store(in: &cancellables)
}
func stopListeningForNotifications() {
cancellables.removeAll()
}
}
In this Combine solution:
- Simulated Notifications: A
Timer.publishemits a new notification string every 5 seconds. - Notification Handling: The
sinkoperator updateslatestNotificationwith each emitted event, which the UI can observe to display alerts. - Start/Stop Listening: Calling
stopListeningForNotifications()removes all subscriptions, stopping notifications.
Solution 2: RxSwiftβ
import RxSwift
import RxCocoa
class NotificationViewModel {
let latestNotification = BehaviorSubject<String>(value: "")
private let disposeBag = DisposeBag()
func startListeningForNotifications() {
Observable<Int>.interval(.seconds(5), scheduler: MainScheduler.instance)
.map { _ in
return "New notification received at \(Date())"
}
.subscribe(onNext: { [weak self] notification in
self?.latestNotification.onNext(notification)
print(notification)
})
.disposed(by: disposeBag)
}
func stopListeningForNotifications() {
disposeBag = DisposeBag() // Resetting the disposeBag will clear all subscriptions
}
}
In this RxSwift solution:
- Simulated Notifications:
Observable.intervalemits a new notification string every 5 seconds. - Notification Handling: Each emitted notification updates
latestNotification, which observers (such as UI components) can subscribe to for real-time alerts. - Start/Stop Listening: By reinitializing
disposeBag, all subscriptions are disposed of, effectively stopping notifications.
Solution 3: Swift Concurrencyβ
import Foundation
import Observation
import AsyncAlgorithms
@Observable
class NotificationViewModel {
var latestNotification: String = ""
private var notificationTask: Task<Void, Never>? = nil
func startListeningForNotifications() {
notificationTask?.cancel() // Cancel any existing task if already running
notificationTask = Task {
for await notification in notificationStream() {
latestNotification = notification
print(notification)
}
}
}
func stopListeningForNotifications() {
notificationTask?.cancel() // Stop the task to stop listening for notifications
}
private func notificationStream() -> AsyncStream<String> {
AsyncStream { continuation in
Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
let notification = "New notification received at \(Date())"
continuation.yield(notification)
}
}
}
}
In this Swift Concurrency solution with AsyncStream:
- Simulated Notifications:
notificationStream()creates anAsyncStreamthat yields a new notification every 5 seconds. - Notification Handling:
startListeningForNotifications()runs an async loop that assigns each emitted notification tolatestNotification, which the UI can observe. - Start/Stop Listening:
notificationTaskmanages the task lifecycle, allowing control over when notifications start and stop.
Summary of Differencesβ
-
Simulating Notifications:
- Combine uses
Timer.publishto emit events on a fixed interval. - RxSwift uses
Observable.intervalto create a similar periodic emission of events. - Swift Concurrency with AsyncStream generates notifications within an asynchronous sequence, leveraging a timer to yield a notification every few seconds.
- Combine uses
-
Notification Management:
- Combine and RxSwift both rely on publishers/observables to emit notifications, updating the UI reactively.
- Swift Concurrency creates an asynchronous stream to listen for notifications, using a
Taskto manage the streamβs lifecycle.
-
Start/Stop Listening:
- Combine and RxSwift cancel notifications by removing subscribers or resetting the dispose bag.
- Swift Concurrency uses task cancellation (
notificationTask?.cancel()) to stop listening to the notification stream.