Swift Combine (Part 2 of 3) — A Practical Hands-On
Welcome to the second part of my Swift Combine series. In this chapter, you’ll be exposed to practical examples and learn how to make network requests using Combine within the isolated context of an Xcode playground.
Xcode playground offers a convenient environment for advancing and experimenting rapidly, with instant results visible in Xcode’s Console.
Table of Contents
· Publishers
· Subscribers
∟ Subscribing with sink
∟ Subscribing with assign
∟ Republishing with assign(to:)
∟ Cancellable
· Operators
∟ Transform
∟ Filter
∟ Combine
· Subjects
∟ PassthroughSubject
∟ CurrentValueSubject
· Make Network Request
Publishers
Recalling from Chapter 1, Publishers are the declarative part of Combine’s API that can emit values over time to one or more subscribers, either synchronously or asynchronously. We can create publishers from scratch or create them using built-in frameworks. Some examples of built-in publishers in Combine include:
Just
: Emits a single value and then completes.Sequence
: Emits a sequence of values from an array or a range.Empty
: Immediately finishes without emitting any values or errors.
Let’s begin by creating all of those. Open the playground and add the following code:
In this code, you:
- Create a helper function
example(of:)
to encapsulate each example in a playground throughout the series. - Create a
just Publisher
, which emits the whole array of integers as one event. - Create a
Sequence Publisher
, which emits the values[1, 2, 3, 4, 5]
. - Create an
Empty Publisher
, which immediately finishes without emitting any values or errors.
There we have it. Our first Just
, Sequence
, and Empty
Publishers.
Subscribers
Now that we know how to create publishers, the next step is to have something subscribe to our publishers and start listening. Enter subscribers, the counterparts to publishers, who are responsible for receiving and reacting to data emitted by publishers. There are two built-in operators you can use to subscribe to publishers:
sink(receiveCompletion:receiveValue:)
: Executes closures each time it receives a new value or when it receives a completion signal.assign(to:on:)
: Writes each newly received value to a property identified by a key path on a given instance.
Subscribing with sink
First, we will use the sink
method. The sink
method is handy for basic subscriptions
, returning a type of AnyCancellable
. This allows you to manage the memory and lifecycle of subscriptions
to publishers
.
Create a new example and add the following code to demonstrate subscribing with the sink
method:
With this code, you:
- Create
Publishers
. - Create a
Subscription
to eachPublisher
by calling theSink
method and print a message for each received event.
Subscribing with assign
In addition to sink
, the assign(to:on:)
method enables you to assign the received value to an object’s user interface or KVO-compliant property.
Add the following code to see how this works:
From the top, you:
- Define a
class
with aproperty
that has aDidSet
property observer that prints the new value. - Create an
instance
of thatclass
. - Create a
Sequence Publisher
from anarray of strings
. Subscribe
to thePublisher
, assigning each value received to the value property of theObject
.
Republishing with assign(to:)
There is a variation of the assign
method that allows you to republish values emitted by a publisher through property marked with the @Published
property wrapper.
Add this code to your playground:
With this code, you:
- Define and instantiate a
class
that includes aproperty
annotated with the@Published
property wrapper, which generates a publisher for its values. - Generate a publisher for numbers and assign each emitted value to the value
Publisher
of theObject
. Note the use of&
to indicate an inout reference to the property.
The assign(to:)
operator doesn’t return an AnyCancellable
token because it internally manages the lifecycle and automatically cancels the subscription when the @Published
property is deinitialized. (You will learn about the Cancellable
shortly)
You might wonder about the use of assign(to: &$word)
compared to simply using assign(to:on:)
. Consider the following example, which demonstrates the potential issue of creating a strong reference cycle when using assign(to:on:)
and storing the resulting AnyCancellable
.
By replacing it with assign(to: &$word)
, this problem is effectively prevented.
In this corrected code, the use of assign(to: &$word)
directly references the Publisher
associated with the word property, ensuring proper memory management and avoiding the potential strong reference cycle that could occur with assign(to: \.word, on: self)
.
Cancellable
Subscriptions, once established, persistently receive values from the publisher, which is known as unlimited demand, until it either completes, encounters an error, or is cancelled. To conclude a subscriber’s connection with a publisher and prevent unintended side effects, canceling the subscription is recommended. This action not only frees up resources but also halts any ongoing activities. There are two ways to cancel subscriptions: manual cancellation & automatic cancellation.
If you Option-click on a subscriber
, you’ll see that it returns an instance of AnyCancellable
as a cancellation token. This token allows you to manually cancel the subscription
when it is no longer needed. The AnyCancellable
type conforms to the Cancellable
protocol, which requires the cancel()
method precisely for that purpose.
Here is an example code for manually cancelling a subscription
:
With this code, you:
- Create a
subscription
by callingsink
on thePublisher
. - Cancel the
subscription
by calling thecancel()
method.
If you do not explicitly call cancel()
on a subscription
, Combine automatically manages the cancellation process. This occurs when the publisher
completed or normal memory management causes a stored subscription to deinitialize.
It is also possible to automate the cancellation process by having an [AnyCancellable]
collection property on your type and putting as many subscriptions
inside it as you want. They will all be automatically cancelled and released when the property is deallocated.
Here is an example code for automatically cancelling a subscription
:
With this code, you:
- Define a
class
with a set ofAnyCancellable
to manage thesubscriptions
, which automatically cancels them when theclass's
instance is deallocated. - Create
subscriptions
.
Operators
Operators are methods that perform operations on values coming from a publisher. Each operator creates a new publisher, takes events from upstream, performs manipulations, and then sends the manipulated events downstream to consumers. There are three essential categories of operators in Combine:
- Transform: Modify the values emitted by a publisher, including:
collect(int)
,map(_:)
,tryMap(_:)
,flatMap(maxPublishers:_:)
,replaceNil(with:)
,replaceEmpty(with:)
, andscan(_:_)
. - Filter: Select the values emitted by a publisher based on certain conditions, including:
filter(_:)
,compactMap(_:)
,first(where:)
,last(where:)
,drop(untilOutputFrom:)
andprefix(untilOutputFrom:)
. - Combine: Merge values from multiple publishers or handle errors, including:
prepend(publisher:)
,append(publisher:)
,switchToLatest()
,merge(with:)
,combineLatest()
, andzip()
.
To help understand how each operator works, we provide a basic usage example for each operator and some illustrations from the CombineMarbles. Shoutout to Robert Palmer for creating interactive marble diagrams of Swift Combine publishers.
Transform
collect(int)
: Collects emitted values into an array and sends the array as a single value, effectively chopping the upstream into batches.
map(_:)
: Transforms each value the publisher emits to a new type using the provided closure.
tryMap(_:)
: Similar to map
, but can handle throwing errors from the transformation closure.
flatMap(maxPublishers:_:)
: Flatten’s multiple upstream publishers into a single downstream publisher.
replaceNil(with:)
: Receive optional values and replace nils with the values you specify.
ReplaceEmpty(with:)
: Replace or insert a value if a publisher
completes without emitting a value.
Scan(_:_:)
: Applies a closure to each element, accumulating and emitting the results sequentially. In the marble diagram, it starts with a stored value of 0. For each incoming value, it adds it to the stored value and then emits the result.
Filter
filter(_:)
: Filters values based on a provided closure, allowing only those that satisfy the condition to pass through.
compactMap(_:)
: Transforms each element by applying a closure that returns an optional.
first(where:)
: Find and emit only the first value matching the provided predicate.
last(where:)
: Find and emit only the last value matching the provided predicate.
drop(untilOutputFrom:)
: ignores values until the second publisher emits.
prefix(untilOutputFrom:)
: skips values until the second publisher emits. (You will learn about the PassthroughSubject
shortly)
Combine
prepend(publisher:)
: add values emitted by a second publisher before the original publisher’s values.
append(publisher:)
: add values emitted by a second publisher to the end of the original publisher.
switchToLatest()
: Dynamically switches to the most recent inner publisher
emitted by the outer publisher
, cancelling any ongoing inner publishers
. Ideal for scenarios involving rapidly triggered network requests, ensuring only the latest one is processed.
merge(with:)
: Merges the values emitted by multiple publishers into a single stream, interleaving the values in the order they are received.
combineLatest()
: Combines the latest values from multiple publishers
, creating a tuple
with diverse value types each time any of the publishers
emits a new value.
zip()
: Combines values emitted by multiple publishers
into a tuple
, but only when each publisher
has emitted a new value at the same time.
Subjects
You may be curious about PassthroughSubject
, a frequently used type in my examples. It belongs to the special category of publishers known as Subjects, playing a distinctive role in Combine by offering a two-way communication channel — they act as subscribers and publishers. This characteristic sets them apart from regular publishers, enabling external entities to inject values into the stream.
Unlike traditional publishers, which typically generate and emit values based on their internal logic, subjects empower external sources to inject values into the stream by calling their send(_:)
method. This characteristic makes subjects particularly versatile, serving as conduits for integrating imperative code or external systems with the reactive nature of Combine.
In Combine, there are two main types of subjects: PassthroughSubject
and CurrentValueSubject
. Let’s delve into these concepts with a practical example:
PassthroughSubject
Allows values to be published to subscribers
, with subscribers
receiving only values published after they have subscribed.
With this code, you:
- Creates an instance of a
PassthroughSubject
of typeString
andNever
. This will publish integers and never publish an error. - Create a
subscription
to the subject andprint
values received from it. - Send the value “Hello” into the subject, triggering the
subscription
toprint
“Hello” in the output.
CurrentValueSubject
Similar to PassthroughSubject
, which allows values to be published to subscribers
. However, it requires an initial value to be specified upon creation and will also maintain and publish the current value.
With this code, you:
- Create a
CurrentValueSubject
of typeInt
andNever
, with an initial value of 1. - Create a
subscription
to the subject andprint
values received from it. - Send two new values to the
CurrentValueSubject
. - Print out the
subject’s
current value. UnlikePassThroughSubject
, you can ask the current value of aCurrentValueSubject
at any time. - Assign a new value to its value property. It is a one-way method to send a new value and is exclusive to
CurrentValueSubject
. - Create a new
subscription
to theCurrentValueSubject
. It receives both the current and subsequent values published after they subscribe.
Make Network Request
Let’s delve into the last section, where we use Combine to make a network request, creating a dynamic search feature. The objective is to enable users to input queries, trigger the retrieval of users and posts from the JSONPlaceholder API, filter the posts based on the titles from the users’ queries, and merge user details associated with each post.
Here’s a breakdown of what we will do:
- Define three structs:
User
,Post
, andPostDetail
. These structures represent user details, post details, and detailed post information, including basic user information. - Create two network request functions: fetchUsers() and fetchPosts(). These are responsible for fetching users and posts from the JSONPlaceholder API using the
URLSession.shared.dataTaskPublisher
. - Create a
PassthroughSubject
to capture user input for queries, a set ofAnyCancellable
, and anarray
to store filtered posts. - Implement the
debounce()
andremoveDuplicates()
operators to ensure that the network request is triggered only after a momentary pause and to eliminate duplicate requests, preventing unnecessary API calls during rapid typing. - Use the
combineLatest()
operator to combine the debounced user input with the results of fetching users and posts, ensuring that the code waits for both fetch requests to complete before emitting a new value. - Employ the
map()
operator to filter posts based on the enteredquery
, transforming the result into an array ofPostDetail
objects. These objects encapsulate not only the post details but also relevant user information. - Utilize the
sink
operator to observe the completion of the subscription and handle the received values by printing relevant information throughout the process. - Simulate user input by dispatching various queries with distinct delays to observe how the code dynamically reacts to changing inputs.
Create a new example and add the following code:
With this code, you:
- Define Swift model structures (
User
,Post
,PostDetail
). - Setup
PassthroughSubject
for user input, a set ofAnyCancellable
, and anarray
to store filtered posts. - Implement network request functions (
fetchUsers()
,fetchPosts()
) to retrieve user and post data from the network. - Use
debounce()
to introduce a momentary pause in user input, preventing unnecessary network requests during rapid typing. - Use
combineLatest()
to combine the latest user input with fetch requests for users and posts. - Transform the tuple of (
query
,users
,posts
) into a filtered and mapped array ofPostDetail
instances based on the query, ensuring that only relevant posts are included. - Handle completion events, printing updates to the UI, or any errors encountered.
- Receive filtered posts and prints.
- Use
send()
to simulate user input with different queries and observe the behaviour of the dynamic search functionality.
Congrats! You’ve been exposed to practical examples and learned how to make network requests using Combine. In the last chapter, you’ll integrate the network request into a real-case SwiftUI application.
If you found this article helpful, kindly share it with your friends and follow my profile for future updates. Your support is much appreciated!
You can access the repository for this series below:
PS: I’m always welcome for pizza 🍕🍕🍕.