Mastering Combine: A Comprehensive Guide for iOS Developers

ElAmir Mansour
14 min readSep 22, 2023

--

Let’s break down Combine concept and get into examples directly which i love to explain with.

Combine is a powerful toolkit that has revolutionized the way iOS developers handle events and asynchronous tasks in their apps. With Combine, you can bid farewell to the complexity of delegate callbacks and cumbersome completion handler closures. Instead, you’ll embark on a journey of streamlined event processing, achieved through a series of elegant and expressive actions known as “Combine operators.”

In this comprehensive guide, we will explore the core concepts of Combine through real-world examples, breaking down each fundamental element of this remarkable framework. Whether you’re new to Combine or seeking to deepen your understanding, you’ll find a wealth of knowledge and practical insights within these pages.

Combine Framework Overview

Publishers and Subscribers: The Sender-Receiver Duo

At the heart of Combine are two essential components: Publishers and Subscribers. Think of Publishers as sources of data or events; they are entities that send out notifications, akin to when a user types in a text field. Subscribers, on the other hand, are the eager recipients, finely tuned to listen to events sent by Publishers. Subscribers come in three flavors: Input (for receiving data), Output (for producing data), and Failure (for handling errors).

Example: Imagine creating a Publisher for a button tap event and a Subscriber that reacts to this event. We’ll dive into this example to illustrate these concepts.

AnyPublisher: The Type-Erased Workhorse

In our journey, we’ll also explore the AnyPublisher, a versatile type-erased publisher that excels at hiding the specific types of a publisher’s output and failure. It offers a uniform interface for handling various types of publishers and simplifies your code.

Real-World Example: Picture an audio streaming app that plays music and podcasts. AnyPublisher allows us to provide a consistent interface for handling audio playback events, regardless of the media type.

Cancellable: Taming the Subscriptions

Combine introduces the Cancellable protocol, a trusty companion that can gracefully cancel subscriptions to a publisher. We’ll see how this mechanism plays a vital role in managing resources efficiently.

Real-World Example: In a weather app, users can subscribe to weather alerts. When users decide to stop receiving alerts, Cancellable objects come to the rescue by canceling their subscriptions.

subscribe(subscriber:) and subscribe(S:): Building Connections

Let’s uncover the mechanics of creating connections between Publishers and Subscribers using the subscribe(subscriber:) and subscribe(S:) methods. These are the bridge builders that allow data to flow from sender to receiver.

Real-World Example: In a news app, users can subscribe to breaking news notifications. The magic happens when we establish a connection between the news publisher and the user’s notification system, enabling real-time delivery of breaking news.

Convenience Publishers — Future: Predictable Asynchrony

Introducing Convenience Publishers, with a focus on the Future publisher. This specialized publisher emits a single value and completes when that value is available, offering a structured approach to asynchronous operations.

Real-World Example: Imagine building an e-commerce app equipped with a product availability checker. Users search for products, and a Future publisher informs them once the data is available.

Before we start , some basics :

Connecting a Publisher to a Subscriber

To receive notifications from a text field using Combine, you can access the default instance of NotificationCenter and use its publisher(for:object:) method. This method takes a notification name and source object as parameters and returns a publisher that produces notification elements.

let pub = NotificationCenter.default
.publisher(for: NSControl.textDidChangeNotification, object: filterField)

You use a Subscriber to receive elements from the publisher. The subscriber defines an associated type, Input, to declare the type it receives. The publisher defines Output and Failure types to indicate what it produces and the kind of error it handles.

Built-in Subscribers

Combine provides two built-in subscribers:

  1. sink(receiveCompletion:receiveValue:): This subscriber takes two closures. The first closure executes when it receives Subscribers.Completion, indicating whether the publisher finished normally or failed with an error. The second closure executes when it receives an element from the publisher.
  2. assign(to:on:): This subscriber assigns every element it receives to a property of a given object, using a key path to indicate the property.

Changing the Output Type with Operators

Combine offers sequence-modifying operators like map(_:), flatMap(maxPublishers:_:), and reduce(_:_:) to customize event delivery. For example, you can use the map(_:) operator to change the output type of the publisher to match your requirements.

let sub = NotificationCenter.default
.publisher(for: NSControl.textDidChangeNotification, object: filterField)
.map( { ($0.object as! NSTextField).stringValue } )

Customizing Publishers with Operators

You can extend a Publisher instance with operators that perform actions you’d otherwise need to code manually. For instance, you can use the filter(_:) operator to ignore input under certain conditions, the debounce(for:scheduler:options:) operator to delay emissions, and the receive(on:options:) method to deliver callbacks on the main thread.

let sub = NotificationCenter.default
.publisher(for: NSControl.textDidChangeNotification, object: filterField)
.map( { ($0.object as! NSTextField).stringValue } )
.filter( { $0.unicodeScalars.allSatisfy({CharacterSet.alphanumerics.contains($0)}) } )
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.receive(on: RunLoop.main)
.assign(to: \MyViewModel.filterString, on: myViewModel)

Canceling Publishing when Desired

A publisher emits elements until it completes or fails. You can cancel a subscription if you no longer want to receive data. The subscribers created by sink(receiveCompletion:receiveValue:) and assign(to:on:) implement the Cancellable protocol, providing a cancel() method.

sub?.cancel()

If you create a custom Subscriber, you should implement the Cancellable protocol and forward the cancel() call to the stored subscription when you want to cancel publishing.

Now let’s get into real examples .

1.Publisher and Subscriber “ sender-receiver” :

  • Publisher: Think of a publisher as the source of events or data. It’s something that sends out notifications, like when a user types in a text field.
  • Subscriber: A subscriber is like a receiver. It’s set up to listen to the events sent by the publisher. Subscribers have three types associated with them: Input (the type of data it receives), Output (the type of data it produces), and Failure (the type of error it handles).

Example :

Let’s consider a simple example where you want to create a publisher for a button tap event and have a subscriber that reacts to this event.

Create a Publisher (Publisher):

In this example, we’ll create a publisher using the NotificationCenter to observe a custom notification that simulates a button tap.

import Combine

// Create a custom notification name
let customNotification = Notification.Name("CustomButtonTap")

// Create a publisher that emits a value when the custom notification is posted
let buttonTapPublisher = NotificationCenter.default
.publisher(for: customNotification)

In this code, we’ve defined a custom notification named "CustomButtonTap" and created a publisher that emits values when this notification is posted.

Create a Subscriber (Subscriber):

Now, let’s create a subscriber that reacts to the button tap event. We’ll use the sink operator to print a message when the event occurs.

// Create a subscriber using the sink operator
let buttonTapSubscriber = buttonTapPublisher.sink { _ in
print("Button tapped!")
}

Here, we’ve created a subscriber that listens to the buttonTapPublisher and prints "Button tapped!" whenever a value (in this case, the notification) is received.

Emulate a Button Tap Event:

To simulate a button tap event and trigger the publisher, you can post the custom notification.

// Emulate a button tap event by posting the custom notification
NotificationCenter.default.post(name: customNotification, object: nil)

When you run this code, it will trigger the custom notification, and the subscriber will react by printing “Button tapped!”

In this example, we explored the fundamental concepts of publishers and subscribers in the Combine framework. We created a simple scenario where we wanted to react to a button tap event using Combine.

Create a Publisher (Publisher):

  • We defined a custom notification name, "CustomButtonTap".
  • We created a publisher using NotificationCenter.default.publisher(for:) that emits a value when this custom notification is posted.

Create a Subscriber (Subscriber):

  • We created a subscriber using the sink operator, which listens to the buttonTapPublisher.
  • The subscriber reacted to the event by printing “Button tapped!” whenever the custom notification was received.

Emulate a Button Tap Event:

  • To simulate a button tap event, we posted the custom notification using NotificationCenter.default.post(name:object:).

Conclusion:

This example demonstrated the core concept of Combine, which is the interaction between publishers and subscribers. Publishers emit values, and subscribers react to those values. In this case, the publisher was responsible for capturing a button tap event (simulated through a custom notification), and the subscriber reacted by executing a simple print statement.

While this example was straightforward, Combine’s true power shines in more complex scenarios where you can manipulate, transform, and combine data streams from various sources, all while ensuring a reactive and efficient data flow in your iOS apps. Understanding publishers and subscribers is the first step toward harnessing the capabilities of the Combine framework in your iOS development journey.

2.Operators in Combine:

Operators in Combine are functions or methods that allow you to modify, transform, filter, or combine data emitted by publishers. They enable you to manipulate data streams in a declarative and functional manner. Combine offers a wide range of operators to suit various data processing needs.

Let’s have 2 Examples , an easy and a real world one :.

Example 1 “Example: Using the map Operator” :

In this example, we’ll use the map operator to transform the data emitted by a publisher. Specifically, we'll create a publisher that emits numbers and use map to square each number.

import Combine

// Create a simple publisher that emits numbers from 1 to 5
let numbersPublisher = (1...5).publisher

// Use the map operator to square each number
let squaredNumbersPublisher = numbersPublisher.map { number in
return number * number
}

// Create a subscriber to receive and print squared numbers
let subscriber = squaredNumbersPublisher.sink { squaredNumber in
print("Squared: \(squaredNumber)")
}

Explanation:

Creating a Publisher (numbersPublisher):

  • We created a simple publisher numbersPublisher using a range that emits numbers from 1 to 5.

Using the map Operator (squaredNumbersPublisher):

  • We applied the map operator to numbersPublisher to create a new publisher called squaredNumbersPublisher.
  • The map operator takes a closure that defines how to transform each element emitted by the original publisher.
  • In our case, the closure squares each number.

Creating a Subscriber (subscriber):

  • We created a subscriber to listen to the values emitted by squaredNumbersPublisher.
  • When a squared number is emitted, the subscriber prints it.

Execution:

  • As the publisher emits numbers from 1 to 5, the map operator squares each number, and the subscriber prints the squared values.

Example 2 “Real-World Example: Formatting and Displaying Data” :

Imagine you are developing a weather app, and you receive weather data from an API as raw numbers (e.g., temperatures in Celsius). Before displaying this data to users, you need to format it appropriately (e.g., convert temperatures to Fahrenheit) and update the user interface. You can achieve this with Combine and the map operator.

Create a Publisher (Fetching Weather Data):

  • You have a publisher that fetches weather data from an API and emits it as raw numbers (e.g., temperatures in Celsius).
let weatherDataPublisher = URLSession.shared.dataTaskPublisher(for: weatherAPIURL)
.map(\.data)
.decode(type: WeatherResponse.self, decoder: JSONDecoder())
.map(\.temperatureInCelsius)

Use the map Operator to Format Data:

  • You use the map operator to format the temperature data from Celsius to Fahrenheit.
let formattedWeatherDataPublisher = weatherDataPublisher.map { celsiusTemperature in
let fahrenheitTemperature = (celsiusTemperature * 9/5) + 32
return fahrenheitTemperature
}

Create a Subscriber (Updating UI):

  • You create a subscriber to update the user interface with the formatted temperature data.
let temperatureLabel = UILabel()

let subscriber = formattedWeatherDataPublisher
.receive(on: DispatchQueue.main) // Ensure UI updates on the main thread
.sink { fahrenheitTemperature in
temperatureLabel.text = "\(fahrenheitTemperature)°F"
}

let’s break it down :

  • The weatherDataPublisher fetches weather data, decodes it, and extracts the temperature in Celsius from the API response.
  • The formattedWeatherDataPublisher applies the map operator to convert Celsius temperatures to Fahrenheit.
  • The subscriber updates the UI by displaying the formatted temperature in Fahrenheit. It ensures that the UI updates on the main thread for a smooth user experience.

Publishers and Publisher Protocol:

Real-World Example: Stock Market Updates

Imagine a stock market app that needs to provide real-time stock price updates to its users. The stock market data source serves as a publisher, continuously emitting stock price updates to subscribers (app users) in real-time.

import Combine

struct Stock {
let symbol: String
let price: Double
}

// Simulated stock price publisher
let stockPricePublisher = PassthroughSubject<Stock, Never>()

// Subscriber (user interface) listening to stock price updates
let cancellable = stockPricePublisher
.sink { stock in
print("Stock \(stock.symbol) price: \(stock.price)")
}

// Emulate stock price updates
stockPricePublisher.send(Stock(symbol: "AAPL", price: 150.0))
stockPricePublisher.send(Stock(symbol: "GOOGL", price: 2700.0))

AnyPublisher:

Real-World Example: Audio Streaming App

Consider an audio streaming app that plays different types of media files, including music and podcasts. The app can use AnyPublisher to provide a consistent interface for handling audio playback events, regardless of the media type being played.

import Combine

enum MediaType {
case music
case podcast
}

struct MediaPlaybackEvent {
let type: MediaType
let title: String
}

// Simulated media playback publisher
let mediaPlaybackPublisher = PassthroughSubject<MediaPlaybackEvent, Never>().eraseToAnyPublisher()

// Subscriber (audio player) handling media playback events
let cancellable = mediaPlaybackPublisher
.sink { event in
switch event.type {
case .music:
print("Playing music: \(event.title)")
case .podcast:
print("Playing podcast: \(event.title)")
}
}

// Emulate media playback events
mediaPlaybackPublisher.send(MediaPlaybackEvent(type: .music, title: "Song 1"))
mediaPlaybackPublisher.send(MediaPlaybackEvent(type: .podcast, title: "Podcast Episode 1"))

Cancellable:

Real-World Example: Weather App

In a weather app, users can subscribe to receive weather alerts. When a user decides to stop receiving alerts for a particular location or weather condition, the app uses Cancellable objects to cancel the corresponding subscriptions.

import Combine

struct WeatherAlert {
let location: String
let alertMessage: String
}

// Simulated weather alerts publisher
let weatherAlertPublisher = PassthroughSubject<WeatherAlert, Never>()

// Subscriber (user) subscribing to weather alerts
let cancellable = weatherAlertPublisher
.sink { alert in
print("Received weather alert for \(alert.location): \(alert.alertMessage)")
}

// Emulate weather alerts
let userSubscription = weatherAlertPublisher.send(WeatherAlert(location: "City A", alertMessage: "Severe Storm Warning"))

// User unsubscribes
userSubscription.cancel() // Cancel the subscription

subscribe(subscriber:) and subscribe(S:):

Real-World Example: News App

In a news app, users can subscribe to receive breaking news notifications. When a user subscribes, the app establishes a connection between the news publisher and the user’s notification system, enabling real-time delivery of breaking news.

import Combine

struct BreakingNews {
let headline: String
let content: String
}

// Simulated breaking news publisher
let breakingNewsPublisher = PassthroughSubject<BreakingNews, Never>()

// Subscriber (news notification system) subscribing to breaking news
let cancellable = breakingNewsPublisher
.sink { news in
print("Breaking News: \(news.headline)\n\(news.content)")
}

// Emulate breaking news subscription
let userSubscription = breakingNewsPublisher.sink(receiveCompletion: { _ in }) { _ in }

// User unsubscribes
userSubscription.cancel() // Cancel the subscription

Convenience Publishers — Future:

Real-World Example: E-commerce Product Availability Checker

Suppose you are building an e-commerce app with a product availability checker. When a user searches for a product, the app can use a Future publisher to fetch the product’s availability status and notify the user once the data is available.

import Combine

struct Product {
let name: String
let isAvailable: Bool
}

// Simulated product availability checker
func checkProductAvailability(productName: String) -> Future<Product, Error> {
return Future { promise in
// Simulated asynchronous product availability check
DispatchQueue.global().async {
if let product = getProductAvailability(productName) {
promise(.success(product))
} else {
promise(.failure(ProductError.productNotFound))
}
}
}
}

// Subscriber (user interface) handling product availability
let cancellable = checkProductAvailability(productName: "ExampleProduct")
.sink(receiveCompletion: { result in
switch result {
case .finished:
break // Future completed
case .failure(let error):
print("Error: \(error)")
}
}, receiveValue: { product in
print("Product: \(product.name), Available: \(product.isAvailable)")
})

// Simulate product availability check
func getProductAvailability(_ productName: String) -> Product? {
// Simulated product data
let products = [
Product(name: "ExampleProduct", isAvailable: true),
// Add more products here
]
return products.first { $0.name == productName }
}

enum ProductError: Error {
case productNotFound
}

Mapping Elements:

Real-World Example: Language Translation App

In a language translation app, you can use the map operator to convert text from one language to another. As the user types or pastes text, the app emits the translated text using the map operator.

import Combine

// Simulated translation service
func translateText(_ text: String, to language: String) -> Future<String, Error> {
return Future { promise in
// Simulated asynchronous translation
DispatchQueue.global().async {
let translatedText = performTranslation(text, to: language)
promise(.success(translatedText))
}
}
}

// Simulated translation logic
func performTranslation(_ text: String, to language: String) -> String {
// Simulated translation logic here
return "[Translated to \(language)]: \(text)"
}

// Subscriber (user interface) handling translated text
let cancellable = translateText("Hello, World!", to: "French")
.sink(receiveCompletion: { result in
switch result {
case .finished:
break // Future completed
case .failure(let error):
print("Translation Error: \(error)")
}
}, receiveValue: { translatedText in
print("Translated Text: \(translatedText)")
})

// Simulate text translation
// ...

Connectable Publishers and ConnectablePublisher:

Real-World Example: Live Sports Streaming App

Consider a live sports streaming app. The app uses a connectable publisher to start broadcasting a live sports event to all subscribers simultaneously when the event begins.

import Combine

// Simulated live sports event
struct SportsEvent {
let eventName: String
let eventStream: AnyPublisher<String, Never>
}

// Connectable publisher for live sports event
let sportsEventPublisher = PassthroughSubject<String, Never>().makeConnectable()

// Subscriber (user interface) handling live event updates
let eventSubscriber = sportsEventPublisher
.sink(receiveCompletion: { _ in }) { eventUpdate in
print("Live Event Update: \(eventUpdate)")
}

// Simulate the start of a live sports event
func startLiveEvent(eventName: String) {
// Connect the publisher and start emitting updates
let sportsEvent = SportsEvent(eventName: eventName, eventStream: sportsEventPublisher.eraseToAnyPublisher())

sportsEvent.eventStream
.autoconnect()
.sink { _ in }
.store(in: &sportsEventCancellables)

// Simulated live event updates
for i in 1...10 {
sportsEventPublisher.send("Update \(i) for \(eventName)")
}
}

// Simulate the start of a live sports event
startLiveEvent(eventName: "Football Match")

setFailureType(to:):

Real-World Example: File Download Manager

In a file download manager, you might want to customize error handling. You can use setFailureType(to:) to convert specific download errors into a more user-friendly error type before delivering them to subscribers.

import Combine

// Simulated file download manager
struct DownloadError: Error {
let description: String
}

// Publisher for file downloads
let fileDownloadPublisher = PassthroughSubject<Data, DownloadError>()

// Subscriber (user interface) handling file downloads
let downloadSubscriber = fileDownloadPublisher
.sink(receiveCompletion: { result in
switch result {
case .finished:
break // Download completed
case .failure(let error):
print("Download Error: \(error.description)")
}
}, receiveValue: { data in
print("File downloaded, size: \(data.count) bytes")
})

// Simulate a file download with a custom error
let customError = DownloadError(description: "File not found.")
fileDownloadPublisher.send(completion: .failure(customError))

Filtering Elements:

Real-World Example: Messaging App

In a messaging app, you can use the filter operator to only display messages from a user’s contacts while ignoring spam messages.

import Combine

// Simulated message data
struct Message {
let sender: String
let content: String
}

// Publisher for incoming messages
let messagePublisher = PassthroughSubject<Message, Never>()

// Subscriber (user interface) displaying messages from contacts
let contactMessagesSubscriber = messagePublisher
.filter { message in
// Simulated list of user contacts
let userContacts = ["Alice", "Bob", "Charlie"]
return userContacts.contains(message.sender)
}
.sink(receiveValue: { message in
print("New Message: \(message.content) from \(message.sender)")
})

// Simulate incoming messages
messagePublisher.send(Message(sender: "Alice", content: "Hello, World!"))
messagePublisher.send(Message(sender: "Spammer", content: "Buy this!"))
messagePublisher.send(Message(sender: "Bob", content: "How's it going?"))

Subscription:

Real-World Example: Fitness Tracking App

In a fitness tracking app, users can subscribe to daily workout reminders. Subscriptions are established between the workout schedule publisher and individual users who want to receive reminders.

import Combine

// Simulated workout reminder data
struct WorkoutReminder {
let workoutName: String
let time: String
}

// Publisher for workout reminders
let workoutReminderPublisher = PassthroughSubject<WorkoutReminder, Never>()

// User subscription to workout reminders
let userSubscription = workoutReminderPublisher
.sink(receiveValue: { reminder in
print("New Workout Reminder: \(reminder.workoutName) at \(reminder.time)")
})

// Simulate sending workout reminders
workoutReminderPublisher.send(WorkoutReminder(workoutName: "Morning Jog", time: "7:00 AM"))
workoutReminderPublisher.send(WorkoutReminder(workoutName: "Afternoon Yoga", time: "3:00 PM"))

removeDuplicates() and removeDuplicates(by:):

Real-World Example: Social Media Feed

In a social media feed, you can use removeDuplicates(by:) to eliminate duplicate posts that may appear when multiple users share the same content.

import Combine

// Simulated social media post data
struct SocialMediaPost {
let username: String
let content: String
}

// Publisher for social media posts
let socialMediaPostPublisher = PassthroughSubject<SocialMediaPost, Never>()

// Subscriber (user interface) displaying unique social media posts
var uniquePosts: Set<SocialMediaPost> = []

let socialMediaSubscriber = socialMediaPostPublisher
.removeDuplicates(by: { $0.content == $1.content })
.sink(receiveValue: { post in
if !uniquePosts.contains(post) {
uniquePosts.insert(post)
print("New Post by \(post.username): \(post.content)")
}
})

// Simulate incoming social media posts
socialMediaPostPublisher.send(SocialMediaPost(username: "Alice", content: "Check out this article!"))
socialMediaPostPublisher.send(SocialMediaPost(username: "Bob", content: "Check out this article!"))
socialMediaPostPublisher.send(SocialMediaPost(username: "Charlie", content: "I agree!"))

Subjects and Subject Protocol:

Real-World Example: Chat Application

In a chat application, you can use a subject to allow users to manually send messages to a chat room. When a user enters a message and presses “send,” the subject emits the message to all subscribers in the chat room.

import Combine

// Chat room subject for sending messages
let chatRoomSubject = PassthroughSubject<String, Never>()

// Subscriber (user interface) receiving and displaying chat messages
let chatSubscriber = chatRoomSubject
.sink(receiveValue: { message in
print("New Chat Message: \(message)")
})

// Simulate users sending chat messages
chatRoomSubject.send("Hello, everyone!")
chatRoomSubject.send("How's it going?")
chatRoomSubject.send("Join us for the party tonight!")

Delivering Elements to Subscribers — send method:

Real-World Example: Ride-Sharing App

In a ride-sharing app, the app’s backend can use the send method to deliver real-time location updates to the driver's app as the user's ride progresses.

import Combine

// Real-time location updates subject
let locationUpdateSubject = PassthroughSubject<CLLocation, Never>()

// Subscriber (driver's app) receiving and processing location updates
let locationUpdateSubscriber = locationUpdateSubject
.sink(receiveValue: { location in
print("New Location Update: Latitude \(location.coordinate.latitude), Longitude \(location.coordinate.longitude)")
})

// Simulate real-time location updates
let locations: [CLLocation] = [
CLLocation(latitude: 37.7749, longitude: -122.4194),
CLLocation(latitude: 37.7749, longitude: -122.4194),
CLLocation(latitude: 37.7749, longitude: -122.4194),
// ... (more updates)
]

for location in locations {
locationUpdateSubject.send(location)
// Simulate sending location updates at regular intervals
Thread.sleep(forTimeInterval: 2.0)
}

--

--

ElAmir Mansour

🚀 Software Engineer & iOS Developer | Scrum Master 🕹 | Crafting Code & Content | Coffee enthusiast ☕️ | Simplifying Complexity, One Line at a Time 💻