Swift Combine (Part 2 of 3) — A Practical Hands-On

Fernando Putra
11 min readDec 20, 2023

--

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.

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:

  1. Create a helper function example(of:) to encapsulate each example in a playground throughout the series.
  2. Create a just Publisher, which emits the whole array of integers as one event.
  3. Create a Sequence Publisher, which emits the values [1, 2, 3, 4, 5].
  4. 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:

  1. Create Publishers.
  2. Create a Subscription to each Publisher 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:

  1. Define a class with a property that has a DidSet property observer that prints the new value.
  2. Create an instance of that class.
  3. Create a Sequence Publisher from an array of strings.
  4. Subscribe to the Publisher, assigning each value received to the value property of the Object.

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:

  1. Define and instantiate a class that includes a property annotated with the @Published property wrapper, which generates a publisher for its values.
  2. Generate a publisher for numbers and assign each emitted value to the value Publisher of the Object. 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:

  1. Create a subscription by calling sink on the Publisher.
  2. Cancel the subscription by calling the cancel() 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:

  1. Define a class with a set of AnyCancellable to manage the subscriptions, which automatically cancels them when the class's instance is deallocated.
  2. 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:

  1. Transform: Modify the values emitted by a publisher, including: collect(int), map(_:), tryMap(_:), flatMap(maxPublishers:_:), replaceNil(with:), replaceEmpty(with:), and scan(_:_).
  2. Filter: Select the values emitted by a publisher based on certain conditions, including: filter(_:), compactMap(_:), first(where:), last(where:), drop(untilOutputFrom:) and prefix(untilOutputFrom:).
  3. Combine: Merge values from multiple publishers or handle errors, including: prepend(publisher:), append(publisher:), switchToLatest(), merge(with:), combineLatest(), and zip().

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:

  1. Creates an instance of a PassthroughSubject of type String and Never. This will publish integers and never publish an error.
  2. Create a subscription to the subject and print values received from it.
  3. Send the value “Hello” into the subject, triggering the subscription to print “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:

  1. Create a CurrentValueSubject of type Int and Never, with an initial value of 1.
  2. Create a subscription to the subject and print values received from it.
  3. Send two new values to the CurrentValueSubject.
  4. Print out the subject’s current value. Unlike PassThroughSubject, you can ask the current value of a CurrentValueSubject at any time.
  5. Assign a new value to its value property. It is a one-way method to send a new value and is exclusive to CurrentValueSubject.
  6. Create a new subscription to the CurrentValueSubject. 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:

  1. Define three structs: User, Post, and PostDetail. These structures represent user details, post details, and detailed post information, including basic user information.
  2. 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.
  3. Create a PassthroughSubject to capture user input for queries, a set of AnyCancellable, and an array to store filtered posts.
  4. Implement the debounce() and removeDuplicates() 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.
  5. 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.
  6. Employ the map() operator to filter posts based on the entered query, transforming the result into an array of PostDetail objects. These objects encapsulate not only the post details but also relevant user information.
  7. Utilize the sink operator to observe the completion of the subscription and handle the received values by printing relevant information throughout the process.
  8. 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:

  1. Define Swift model structures (User, Post, PostDetail).
  2. Setup PassthroughSubject for user input, a set of AnyCancellable, and an array to store filtered posts.
  3. Implement network request functions (fetchUsers(), fetchPosts()) to retrieve user and post data from the network.
  4. Use debounce() to introduce a momentary pause in user input, preventing unnecessary network requests during rapid typing.
  5. Use combineLatest() to combine the latest user input with fetch requests for users and posts.
  6. Transform the tuple of (query, users, posts) into a filtered and mapped array of PostDetail instances based on the query, ensuring that only relevant posts are included.
  7. Handle completion events, printing updates to the UI, or any errors encountered.
  8. Receive filtered posts and prints.
  9. 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 🍕🍕🍕.

--

--