It’s time to start catching up with Combine!
If you’ve been with me and remember the initial breakdown using SwiftUI, here’s what I wanted to do:
- Make a List
- Make a Row for that List
- Create a view model
We still haven’t decided on what’s the best approach to SwiftUI’s declarative way of building UI architecture-wise, but it seems to be leaning heavily towards MVVM, or at least some variant of it.
Since it seems pretty natural to use, we’re going with that.
Creating the ViewModel
We need to start off with several things in mind:
- How to represent the model in the view
- How to update the model when text changes
- How to update the view for model changes
First things first, the view needs to read values from the state of the view model. The model will be updating its state when it receives events from the URLSession.DataTaskPublisher
.
If you’re familiar with RxSwift, you’ll know that you can achieve that by binding streams of data to UI elements.
In the case of SwiftUI, it’ll actually re-render the component that needs to be updated. So, when building a list of rows and that list updates, the UI will end up adjusting itself to accommodate for the changes.
With all that in mind, let’s make a model which represents a single GitHub repository.
Repository model
struct Repository: Identifiable, Decodable {
let id: Int
let name: String
let fullName: String
let description: String?
let stargazersCount: Int
}
A simple struct with some attributes that conforms to Decodable
. Those of you with a keen eye might’ve noticed something new—and that’s a protocol called Identifiable
.
The only requirement of the protocol is that the object has an id
, which is then used by Equatable
under the hood. That’s great! But… where is it identified? SwiftUI uses it for its List
element which does diffing, insertion and deletion for us.
How cool is that?! No more headaches with index paths. ?
Repository view model
The single most important thing to do is to conform to the BindableObject
protocol. The object uses that to notify the framework when it receives changes. Changes are then sent to the view, which then updates the layout.
Note: If you’ve used RxSwift, AnyPublisher in essence is the well known asObservable() operator.
final class RepositoryViewModel: BindableObject {
// BindableObject conformance
let didChange: AnyPublisher<Void, Never>
// Data model
var repositories: [Repository]
init(repositories: [Repository] = []) {
self.repositories = repositories
}
}
To satisfy the protocol, we need the didChange
property, which is typed as AnyPublisher<Void, Never>
. It’s pretty cool that we don’t need a concrete type, the framework only expects us to signal when there are updates in the pipeline!
Since we’re also binding to the UI, errors need to be handled before passing values to the publisher. Hence the second type, which is represented as Never
.
View Model integration
I’ll concentrate only on the ListView
and the RepositoryRowView
, since the implementation is exactly the same across all of them.
- ListView
struct ListView : View {
@ObjectBinding var viewModel: RepositoryViewModel
@State var text = ""
var body: some View {
NavigationView {
TextField($text, placeholder: Text("Search..."))
.textFieldStyle(.roundedBorder)
.padding([.leading, .trailing], 20)
List(viewModel.repositories) { repository in
RepositoryRowView(repository: repository)
}
.listStyle(.grouped)
.environment(\.defaultMinListRowHeight, 50)
.navigationBarTitle(.init("GitHub search"))
}
.colorScheme(.dark)
}
}
#if DEBUG
struct ListView_Previews : PreviewProvider {
static var previews: some View {
ListView(viewModel: .init(repositories: RepositoriesMock()))
}
}
#endif
ListView
requires a few simple changes. Instead of a fixed range that we passed to the List
initially, we’re now passing the repositories
property of the model. If Repository
is not conforming to the Identifiable
protocol, a convoluted error might pop up in this moment, so double check that in case of any weirdness.
Note: RepositoriesMock is a simple array of hardcoded repositories, which is passed for preview purposes. This enables you to see your data in the handy preview window on the right of the editor.
All that’s left to do now is to add the Repository
to the rest of the subviews.
- RepositoryRowView
struct RepositoryRowView : View {
let repository: Repository
var body: some View {
HStack(alignment: .center) {
DetailsContainerView(repository: repository)
Spacer()
StarsContainerView(repository: repository)
}
}
}
struct RepositoryRowView_Previews : PreviewProvider {
static var previews: some View {
return RepositoryRowView(repository: RepositoriesMock[0])
}
}
Note: When you add a new view by hand, a preview won’t be generated out-of-the-box. To get it to work, simply add struct RepositoryRowView_Previews which needs to implement the PreviewProvider.
Pretty simple, right?
The exact same thing needs to be done for both the DetailsContainerView
and the StarsContainerView
, so I won’t dive into that.
Wrapping up the View Model
We’ve already started by adding a publisher that’s responsible of notifying the view about updates.
What about the changes of the actual data source, that is, the repositories array inside of the view model? Thankfully, URLSession
provides a publisher just for that!
We also need to pass any text changes from the view to the view model. It then needs to initiate an API call and provide information about the update.
Taking a look at the ListView
again, we can see the @State
property wrapper that’s used to track text changes.
struct ListView : View {
...
@State var text = ""
var body: some View {
...
TextField($text, placeholder: Text("Search..."))
.textFieldStyle(.roundedBorder)
.padding([.leading, .trailing], 20)
...
}
}
Note: The $ symbol means that we’re mutating the state instead of passing it by value, i.e. we’re making it mutable or reference-alike.
Since we have no use of that property in the view anymore, we can move it into the model! We can use it to track changes internally and simply notify the view when we’re done.
Let’s tweak both the model and the list. We’ll start with the list since it’s simpler:
struct ListView : View {
@ObjectBinding var viewModel: RepositoryViewModel
var body: some View {
...
TextField(viewModel[\.text], placeholder: Text("Search..."))
.textFieldStyle(.roundedBorder)
.padding([.leading, .trailing], 20)
...
}
}
The @State var text
was replaced by binding the text field to a keypath which points to the text
property of the view model. Going further, let’s adjust the model now:
final class RepositoryViewModel: BindableObject {
// BindableObject conformance
let didChange: AnyPublisher<Void, Never>
// Public properties
var repositories: [Repository] = []
var text: String = "" {
didSet { _textDidChange.send(text) }
}
// Private properties
private let _textDidChange = PassthroughSubject<String, Never>()
private var _cancellable: Cancellable?
init(repositories: [Repository] = []) {
self.repositories = repositories
}
}
This concludes all the properties necessary to make the view model work! Not so bad, right? Concentrating on said properties, we can see the following:
var text: String
to which we bind text changes from the view, which forwards its value to the_textDidChange
subjectlet _textDidChange: PassthroughSubject<String, Never>
which is a private property used to notify the view model that there were some changes in the viewvar _cancellable: Cancellable?
which we can use to cancel any ongoing work
To elaborate, for each character input in the textfield, our internal subject will get notified through the setter of the text
variable. This then initiates an API call.
That API call is going to return a cancellable
which we store and call on deinit
to clean up the resources when needed.
Woah, wait, what’s a subject
now? Well, since I talked about publishers already—I can paraphrase that a Subject in its most simplest term is a publisher into which you’re able to write values.
Remember, publishers are read-only and are strictly used to pass data.
On the other hand, a subject can be used as a bridge between reactive and non-reactive code, since it’s able to receive data in its stream. We’ll use that to publish text changes through the stream which will result in an API call returning the Repositories
.
That’s a lot to chew through, but bear with me and let’s just pour those words into code:
final class RepositoryViewModel: BindableObject {
// BindableObject conformance
let didChange: AnyPublisher<Void, Never>
// Public properties
var repositories: [Repository] = []
var text: String = "" {
didSet { _textDidChange.send(text) }
}
// Private properties
private let _textDidChange = PassthroughSubject<String, Never>()
private var _cancellable: Cancellable?
// Init
init(repositories: [Repository] = []) {
self.repositories = repositories
let search = _textDidChange
.filter { !$0.isEmpty }
.debounce(for: .seconds(0.3), scheduler: DispatchQueue.main)
.flatMapLatest { RepositoryViewModel._getRepositories(using: $0, existingRepositories: repositories) }
.share()
didChange = search
.map { _ in () }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
_cancellable = search
.tryMap { try $0.get() }
.catch { _ in Publishers.Just(repositories) }
.assign(to: \.repositories, on: self)
}
/// Deinit
deinit {
_cancellable?.cancel()
}
}
If this is a bit intimidating, fret not—I’ll go over the operators to make it a bit easier to grasp.
What’s a flatMapLatest?
I’ve already mentioned how we’re pushing all text changes into the _textDidChange
subject. These get sent into the flatMapLatest
operator. If you’re using the first beta of Combine, you won’t be able to find it—you need to make it.
Essentially, flatMapLatest
can be derived into map
and switchToLatest
operators. But why do we need it?
Well, map
is the same old map
provided in Swift, it maps an input to a desired output. We need that to map the search String
into an API call returning Repositories
. Okay… but what about the switchToLatest
operator?
Imagine that you’re typing in a textfield—typing is quite fast. For each character you type, an API call will be made. If we did that we’d absolutely trash the server with all that traffic.
This is where switchToLatest
operator comes in handy. It automatically cancels any previously sent events and continues on with the latest one! That way, we only take whatever text we have for the last character coming into the stream.
Making the operator is pretty straightforward:
extension Publisher {
func flatMapLatest<T: Publisher>(_ transform: @escaping (Self.Output) -> T) -> AnyPublisher<T.Output, T.Failure> where T.Failure == Self.Failure {
return map(transform).switchToLatest().eraseToAnyPublisher()
}
}
Let’s (de)bounce forward
You might’ve also noticed the debounce
operator.
Along with debounce
, throttle
is also one of the operators that saves lives when using RxSwift, so it’s only natural that it found its place in Combine.
So what’s so magical about it? Putting it simply, it does not let any events in the stream pass the point where debounce is placed, as long as the time between each of those events is shorter than the provided time interval.
Huh… it doesn’t sound that simple? Think of it as a valve that is automatically shut if a stream of liquid is bigger than what the pipe can support.
So in the case where a user types fast, any character that comes before the period of 0.3
seconds has elapsed, will end up being dropped. Once the user stops typing or slows down, events will pass freely for further processing.
This makes it perfect for text inputs because it guards against unintentional DDOS attacks and potential wrath of your sysadmins.
Note: If you aren’t using Combine beta 2, you’ll end up having quite some trouble setting up debounce since the only supported scheduler in the first beta is the ImmediateScheduler, which is no good in this case.
Share your resources!
Last, but definitely not the least important operator used is share
.
If share
wasn’t used then each subscription would re-trigger an API call, or putting it simply, resources wouldn’t be shared.
Using it means that the part of the stream before the operator will get reused across all of its subscriptions, instead of getting invoked again. This avoids triggering further API calls on any of our maps
, flatMaps
, sinks
, etc.
Addressing the model updates
didChange = search
.map { _ in () }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
Remember when I mentioned that we need to notify the view on view model value changes? We’re doing it here.
Once the search event arrives (i.e. the API call finished and the data has been decoded), we’ll notify the view. Since we also don’t care about the type here, the event is mapped into Void
.
It is extremely important that any events sent towards the UI are on the Main thread. Failing to do so will result in undefined behaviour, glitches or a crash. Since we’re doing that using the didChange
property, we need to move that stream to the main thread.
Thankfully, Apple provided a method to do that with Combine in beta 2. It’s done by simply calling the receive(on:)
operator in the exact moment when you want to switch.
Do note that any code that executes below that will run on the thread you’ve assigned it on.
Is it time to finally update our repositories?!
Why yes, it is! And we can do that by using the assign
operator. If you’ve used RxSwift you already guessed that this is basically its bind
counterpart.
I’ll also make use of Swift 5’s new Result
type and its .get()
convenience method. This enables unwrapping an enum’s associated value if it exists, without requiring any conditional checks or switch statements.
However, there’s caveat that it throws if there’s no value. Thankfully, Combine has our backs—we can simply use tryMap
.
Swift will raise a red light though, since all errors need to be handled before using assign
.
Note: If you’re using sink you’re not required to handle the error. Specifically, you’re able to handle that in a block which it provides.
To remedy that, we’ll need a catch
operator.
It’s important to use catch
when passing data to the UI or when you want to keep your stream alive and well. Unhandled errors will terminate the stream – remember, we can only get one subscription and one error event per stream.
The closure returns an error and expects a publisher back. That gives us the choice either to handle the error and return a new publisher that’ll keep the stream alive, or simply let the error through.
Since we don’t care about the error in case, returning existing repositories is fine.
_cancellable = search
.tryMap { try $0.get() }
.catch { _ in Publishers.Just(repositories) }
.assign(to: \.repositories, on: self)
Main difference compared to RxSwift is that the returned value from assign
needs to be stored into _cancellable
instead of being stored into a DisposeBag
.
Overall usage is exactly the same, the only caveat being that we need to think about it ourselves during deinit and call cancel.
On the other hand, the default behaviour of DisposeBag
can also be achieved.
If you want to do that, the returned stream needs to be wrapped into AnyCancellable
, which requires a closure upon init
. That closure is called upon deinit of the class that’s holding the AnyCancellable
object.
In any case, depending on the approach, just be wary to call cancel if needed.
And another thing—if you’re wondering, you can do this by using an approach similar to the .subscribe(onNext:)
available in RxSwift. Talking Combine though, you’ll want to use the sink
operator:
_cancellable = search
.sink(receiveValue: { [unowned self] result in
switch result {
case .success(let repositories):
self.repositories = repositories
case .failure(let error):
print(error)
}
})
And that’s it! Take a deep, relaxing breath if you’ve made it this far, we’re on the home stretch now.
What’s left to do is to cover the _getRepositories(using:existingRepositories:)
method that you might have noticed all the way up there. That’s the method that’s making the API call and getting the data.
private static func _getRepositories(using query: String, existingRepositories: [Repository] = []) -> AnyPublisher<Result<[Repository], Error>, Never> {
guard let request = _makeRequest(using: query) else { return Publishers.Empty().eraseToAnyPublisher() }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return URLSession.shared
.dataTaskPublisher(for: request)
.map { $0.data }
.decode(type: Response<Repository>.self, decoder: decoder)
.map { .success($0.items.sorted(by: { $0.stargazersCount > $1.stargazersCount })) }
.catch { _ in Publishers.Just(.success(existingRepositories)) }
.eraseToAnyPublisher()
}
Simple networking stuff. Creating an URLRequest
and an appropriate JSONDecoder
, which is then used after the dataTaskPublisher
retrieves the data needed for decoding.
Speaking of decoding, a really great addition is the decode
operator. It enables decoding on the fly, directly on the stream by simply passing in the decoder. This avoids us having to write all of the boilerplate decoding logic, which is always nice.
Be mindful though, since decoding can also throw
.
Lastly, the publisher is type erased so that we lose all those wrapping publisher types (the same thing already seen with SwiftUI) and simply return an AnyPublisher<Result>
.
In case that it’s needed, I’m also attaching the URLRequest
creation part:
private static func _makeRequest(using query: String) -> URLRequest? {
guard var components = URLComponents(string: "https://api.github.com/search/repositories") else { return nil }
components.queryItems = [URLQueryItem(name: "q", value: query)]
guard let url = components.url else { return nil }
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
return request
}
And that’s all folks
With this in place, we’re done! Searching now works and repositories are listed and sorted depending on their stargazers count.
I’m excited to see how we’ll be able to improve the code with further updates. Beta 2 has already provided some Foundation awesomeness which made things quite a bit easier.
Compared to the initial beta, we’ve gotten the dataTaskPublisher
on the URLSession
which we’d otherwise have to implement ourselves.
Most importantly, we’ve gotten other schedulers outside of the initially supplied ImmediateScheduler
which wasn’t something we could use. That opened up the possibility to use debounce
to throttle the events!
Next time, we’ll be making a details view that’ll show us some basic information about the repository and its author.
Stay tuned!