15.
In Practice: Combine & SwiftUI
Written by Marin Todorov
SwiftUI is Apple’s new paradigm for building app UIs declaratively. It’s a big departure from the existing UIKit and AppKit frameworks. It offers a very lean and easy to read and write syntax for building user interfaces.
The SwiftUI syntax clearly represents the view hierarchy you’d like to build:
HStack(spacing: 10) {
Text("My photo")
Image("myphoto.png")
.padding(20)
.resizable()
}
You can easily visually parse the hierarchy. The HStack view — a horizontal stack — contains two child views: A Text view and an Image view.
Each of the views might have a number of parameters. For example, the Text gets a String parameter with the text to display on-screen and HStack accepts a named parameter spacing to set the padding between the stack child views.
Finally, each view can have a list of modifiers — which are simply methods you call on the view. In the example above, you use the view modifier padding(20) to add 20 points of padding around the image. Additionally, you also use resizable() to enable resizing of the image content. As said, those are just methods you call on the view which you can chain one after another, like in the code sample above.
Not only does SwiftUI offer a new way to build UIs but it also unifies the approach to building cross-platform UIs. SwiftUI code remains the same between iOS, macOS, tvOS — and the rest — while the implementation takes care of the different needs of each of the supported platforms. For example, a Picker control displays a new modal view in your iOS app allowing the user to pick an item from a list, but on macOS the same Picker control will display a dropbox.
A quick code example of a data form could be something like this:
VStack {
TextField("Name", text: $name)
TextField("Proffesion", text: $profession)
Picker("Type", selection: $type) {
Text("Freelance")
Text("Hourly")
Text("Employee")
}
}
This code will create two separate views on iOS. The Type picker control will be a button taking the user to a separate screen with a list of options like so:
On macOS, however, SwiftUI will consider the abundant UI screen space on the mac and create a single form with a drop-down menu instead:
When using UIKit and AppKit, you need to constantly micromanage your data model and your views to keep them in sync. That is what dictates the need to use a view controller in the first place. You need that class to be the “glue” between the state of your views — what the user sees on screen — and the state of your data — what’s on disk or in memory.
When using SwiftUI, on the other hand, you need to adopt a new approach towards building user interfaces. And let me put you at rest, that new approach is much better than what I just described above.
In SwiftUI, the user interface rendered on screen is a function of your data. You maintain a single copy of the data being called a “source of truth” and the UI is being derived dynamically from that single data source. This way, your UI is always up-to-date with the state of your app. Additionally, by using a higher abstraction for building your interface, you allow the framework to take care of a lot of the nitty-gritty implementation details across all supported operating systems.
Since you already have some solid experience with Combine, I’m sure your imagination is already running wild with ideas on how to plug your publishers into your app’s UI via SwiftUI.
Hello, SwiftUI!
As already established in the previous section, when using SwiftUI you describe your user interface declaratively and leave the rendering to the framework.
Each of the views you declare for your UI — text labels, images, shapes, etc. — conform to the View protocol. The only requirement of View is a property called body.
Any time you change your data model, SwiftUI asks each of your views for their current body representation. This might be changing according to your latest data model changes. Then, the framework builds the view hierarchy to render on-screen by calculating only the views affected by changes in your model, resulting in a highly optimized and effective drawing mechanism.
In effect, SwiftUI makes UI “snapshots” triggered by any changes of your data model like so:
With this new way to manage your UI, you need to stop thinking of how to update the user interface. Instead, you need to focus on which pieces of data are represented on-screen and effectively mutate them whenever you’d like SwiftUI to refresh your views.
In this chapter, you will work through a number of tasks that cover both interoperations between Combine and SwiftUI along with some of the SwiftUI basics.
Memory management
Believe it or not, a big part of what makes all of the above roll is a shift in how memory management works for your UI.
SwiftUI introduces a new concept with the help of some cool new syntax which allows you to, instead of duplicating your state in both your data model and your UI, make your UI a function of your model’s state. This allows you to keep your data in a single place called “source of truth.”
No data duplication
Let’s look at an example of what that means. When working with UIKit/AppKit you’d, in broad strokes, have your code separated between a data model, some kind of controller and a view:
Those three types can have several similar features. They include data storage, they can be mutated, they can be reference types and more.
Let’s say you want to display the current weather on-screen. For the purpose of this example, let’s say the model type is a struct called Weather and the current conditions are stored in a text property called conditions. To display that information to the user, you need to create an instance of another type, namely UILabel, and copy the value of conditions into the text property of the label.
Now, you have two copies of the value you work with. One is located in your model type and the other is stored in the UILabel, just for the purpose of displaying it on-screen:
There is no connection or binding between text and conditions. You simply need to copy the String value everywhere you need it.
Now you’ve added a dependency to your UI. The freshness of the information on-screen depends on Weather.conditions. It’s your responsibility to update the label’s text property manually with a new copy of Weather.conditions whenever the conditions property changes.
SwiftUI removes the need for duplicating your data for the purpose of showing it on-screen. Being able to offload data storage out of your UI allows you to effectively manage the data in a single place in your model and never have your app’s users see stale information on-screen.
Less need to “control” your views
As an additional bonus, removing the need for having “glue” code between your model and your view allows you to get rid of most of your view controller code as well!
In this chapter, you will learn:
- Briefly about the basics of SwiftUI syntax for building declarative UIs.
- How to declare various types of UI inputs and connect them to their “sources of truth.”
- How to use Combine to build data models and pipe the data into SwiftUI.
Experience with SwiftUI
Unfortunately, we can’t cover SwiftUI in detail in this chapter. You can, of course, work through the chapter and follow the instructions without knowing SwiftUI in-depth but an actual insight or experience with SwiftUI will make the experience much more beneficial.
That being said, if what you build in this chapter seems exciting and you’d like to learn more about SwiftUI, consider SwiftUI by Tutorials (https://bit.ly/2L5wLLi) to get an in-depth tour.
Additionally, iOS Animations by Tutorials (https://bit.ly/2MaW6UB) features two full-length chapters focusing on creating animations with SwiftUI.
And now, for our feature presentation: Combine with SwiftUI!
Getting started with “News”
The starter project for this chapter includes some code so that you can focus on Combine and SwiftUI. That said, the actual UI layout has already been, well, laid out. The syntax layout itself is out of the scope of this chapter.
The project also includes some folders where you will find the following:
- App contains the app and scene delegates.
- Network includes the completed Hacker News API from last chapter.
-
Model is where you will find simple model types like
Story,FilterKeywordandSettings. Additionally, this is whereReaderViewModelresides, which is the model type that the main newsreader view uses. - View contains the app views and, inside View/Helpers, you will find some simple reusable components like buttons, badges, etc.
- Finally, in Util there is a helper type that allows you to easily read and write JSON files to/from disk.
The completed project will display a list of Hacker News stories and allow the user to manage a keyword filter:
A first taste of managing view state
Build and run the starter project and you will see an empty table on screen and a single bar button titled “Settings”:
This is where you start. To get a taste of how interacting with the UI via changes to your data works, you’ll make the Settings button present SettingsView when tapped.
Open View/ReaderView.swift which contains the ReaderView view displaying the main app interface. Driving the UI via data changes means that you will not be calling any methods directly or setting any data on UI controls.
The type already includes a property called presentingSettingsSheet which is a simple Boolean value. Changing this value will either present or dismiss the settings view. Scroll down through the source code and find the comment // Set presentingSettingsSheet to true here.
This comment is located in the Settings button callback so that’s the perfect place to present the Settings view. Replace the comment with:
self.presentingSettingsSheet = true
As soon as you add this line, you will see the following error:
And indeed self is immutable because the view’s body is a dynamic property and, therefore, cannot mutate ReaderView.
Let’s talk quickly one more time about memory management. SwiftUI offers a number of built-in property wrappers to help you indicate that given properties are part of your state and any changes to those properties should trigger a new UI “snapshot.”
Let’s see what that means in practice. Adjust the plain old presentingSettingsSheet property so it looks as follows:
@State var presentingSettingsSheet = false
The @State property wrapper:
- Moves the property storage out of the view, so modifying
presentingSettingsSheetdoes not mutateself. - Marks the property as local storage. In other words, it denotes the piece of data is owned by the view.
- Adds a publisher, somewhat like
@Publisheddoes, toReaderViewcalled$presentingSettingsSheetwhich you can use to subscribe to the property or to bind it to UI controls or other views.
Once you add @State to presentingSettingsSheet, the error will clear as the compiler knows that you can modify this particular property from a non-mutating context.
Finally, to make use of presentingSettingsSheet, you need to declare how the new state affects the UI. In this case, you will add a sheet(...) view modifier to the view hierarchy and bind $presentingSettingsSheet to the sheet. Whenever you change presentingSettingsSheet, SwiftUI will take the current value and either present or dismiss your view, based on the boolean value.
Find the comment // Present the Settings sheet here and replace it with:
.sheet(isPresented: self.$presentingSettingsSheet, content: {
SettingsView()
})
The sheet(isPresented:content:) modifier takes a Bool publisher and a view to render whenever the presentation publisher emits true.
Build and run the project. Tap Settings and your new presentation will display the target view:
Note how you can see the ReaderView’s top edge below SettingsView as the sheet(...) modifier uses the new sheet presentation style in iOS 13.
If you set presentingSettingsSheet to false, that will dismiss SettingsView. But, for now, let’s leave the code as it is. Currently, your app benefits from the default swipe-down gesture which dismisses presented views automatically.
Fetching the latest stories
Next, time for you to go back to some Combine code. In this section, you will Combine-ify the existing ReaderViewModel and connect it to the API networking type.
Open Model/ReaderViewModel.swift. At the top, insert:
import Combine
This code, naturally, will allow you to use Combine types in ReaderViewModel.swift. Now, add a new subscriptions property to ReaderViewModel to store all of your subscriptions:
private var subscriptions = Set<AnyCancellable>()
With all that solid prep work, now it’s time to create a new method and engage the network API. Add the following empty method to ReaderViewModel:
func fetchStories() {
}
In this method, you will subscribe to API.stories() and store the server response in the model type. You should be familiar with this method from the previous chapter.
Add the following inside fetchStories():
api
.stories()
.receive(on: DispatchQueue.main)
You use the receive(on:) operator to receive any output on the main queue. Arguably, you could leave the thread management to the consumer of the API. However, since in ReaderViewModel‘s case that’s certainly ReaderView, you optimize right here and switch to the main queue to prepare for committing changes to the UI.
Next, you will use a sink(...) subscriber to store the stories and any emitted errors in the model. Append:
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
self.error = error
}
}, receiveValue: { stories in
self.allStories = stories
self.error = nil
})
.store(in: &subscriptions)
First, you check if the completion was a failure. If so, you store the associated error in self.error. In case you receive values from the stories publisher, you store them in self.allStories.
This is all the logic you’re going to add to the model in this section. The fetchStories() method is now complete and you can “start-up” your model as soon as you display ReaderView on screen.
To do that, open App/SceneDelegate.swift and find the place in the code where you set ReaderView as the main view of the app’s window. The code you’re looking for is wrapped in an if, like so:
if let windowScene = scene as? UIWindowScene {
...
}
Inside the if body, at its very end, add the following code:
viewModel.fetchStories()
Right now, ReaderViewModel is not really hooked up to ReaderView so you will not see any change on-screen. However, to quickly verify that everything works as expected, do the following: Go back to Model/ReaderViewModel.swift and add a didSet handler to the allStories property:
private var allStories = [Story]() {
didSet {
print(allStories.count)
}
}
Run the app and observe the Console. You should see a reassuring output like so:
1
2
3
4
...
You can remove the didSet handler you just added in case you don’t want to see that output every time you run the app.
Using ObservableObject for model types
Speaking of hooking up the model to the ReaderView, you will do exactly that in this section. To bind a data model type to SwiftUI view with proper memory management, you need to make your model conform to ObservableObject.
The ObservableObject requires that types conform to a single requirement. They must have a publisher called objectWillChange which emits any time the type’s state is about to change.
As a huge bonus, ObservableObject provides a default implementation of objectWillChange. So, for simple use cases, you don’t even need to adjust your existing model code. When you add ObservableObject conformance to your type, the default protocol implementation will automatically emit any time any of your @Published properties emit!
That sounds easy enough, and for once, it really is!
First, import SwiftUI at the top of ReaderViewModel.swift:
import SwiftUI
Then, to add ObservableObject conformance to ReaderViewModel, alter the class definition like so:
class ReaderViewModel: ObservableObject {
If you were to implement some more esoteric behavior for your model, you could add your own objectWillChange declaration. For this chapter, though, you’ll go with the default.
Next, you need to consider which properties of the data model constitute its state. The two properties you currently update in your sink(...) subscriber are allStories and error. You will consider those state-change worthy.
Note: There is also a third property called
filter. Ignore it for the moment and you’ll come back to it later on.
Adjust allStories to include the @Published property wrapper like so:
@Published private var allStories = [Story]()
Then, do the same for error:
@Published var error: API.Error? = nil
Take a moment to enjoy the simplicity of ObservableObject. It’s a generic, all-purpose protocol which — without making any assumptions about how your own data code works — adds the ability to detect changes in any of your types. It doesn’t get any easier than this!
The final step in this section is, since ReaderViewModel now conforms to ObservableObject, to actually bind the data model to ReaderView.
Open View/ReaderView.swift and add the @ObservedObject property wrapper to the line var model: ReaderViewModel like so:
@ObservedObject var model: ReaderViewModel
You don’t inject the model when initializing the view anymore. Instead, you bind the model so that, any time its state changes, your view will receive the latest data and generate its new UI “snapshot”.
The @ObservedObject wrapper does the following:
- Removes the property storage from the view and uses a binding to the original model instead. In other words, it doesn’t duplicate the data.
- Marks the property as external storage. In other words, it denotes that the piece of data is not owned by the view.
- Like
@Publishedand@State, it adds a publisher to the property so you could subscribe to it and/or bind to it further down the view hierarchy.
By adding @ObservedObject, you’ve made model dynamic. This means it’ll get all updates while your view model fetches stories from the Hacker News server. In fact, run the app right now and you will see the view refresh as stories are fetched by the view model:
Neat! As promissed, for once, things not only look simple but they actually are.
Displaying errors
You will also display errors in the same way you display the fetched stories. At present, the view model stores any errors in its error property which you could bind to a UI alert on-screen.
Open View/ReaderView.swift and find the comment // Display errors here. Replace this comment with the following code to bind the model to an alert view:
.alert(item: self.$model.error) { error in
Alert(
title: Text("Network error"),
message: Text(error.localizedDescription),
dismissButton: .cancel()
)
}
The alert(item:) modifier controls an alert presentation on-screen. It takes a binding with an optional output called the item. Whenever that binding source emits a non-nil value, the UI presents the alert view.
The model’s error property is nil by default and will only be set to a non-nil error value whenever the model experiences an error fetching stories from the server. This is an ideal scenario for presenting an alert as it allows you to bind error directly as alert(item:) input.
To test this, open Network/API.swift and modify the baseURL property to an invalid URL, for example, https://123hacker-news.firebaseio.com/v0/.
Run the app again and you will see the error alert show up as soon as the request to the stories endpoint fails:
Before moving on and working through the next section, take a moment to revert your changes to baseURL so your app once again connects to the server successfully.
Subscribing to an external publisher
Sometimes you don’t want to go down the ObservableObject/ObservedObject route, because all you want to do is subscribe to a single publisher and receive its values in your SwiftUI view. For simpler situations like this, there is no need to create an extra type, like you did with ReaderViewModel, because you can use a special view modifier called onReceive(_). This allows you to subscribe to a publisher directly in your view code.
You can either inject the external publisher in your view by passing it to its initializer or create the publisher inside your view. Either way, you are free to decide what you’d like to do when receiving a new output value. You might ignore it, process it somehow and/or change the state of the view and trigger a new UI “snapshot.”
If you run the app right now, you will see that each of the stories has a relative time included alongside the name of the story author:
The relative time there is useful to instantly communicate the “freshness” of the story to the user. However, once rendered on-screen, the information becomes stale after a while. If the user has the app open for a long time, “1 minute ago” might be off by quite some time.
In this section, you will use a timer publisher to trigger UI updates at regular intervals so each row could recalculate and display correct times.
How the code works right now is as follows:
-
ReaderViewhas a property calledcurrentDatewhich is set once with the current date when the view is created. - Each row in the stories list includes a
PostedBy(time:user:currentDate:)view which compiles the author and time information by usingcurrentDate’s value.
To make the information on-screen “refresh” periodically, you will add a new timer publisher. Every time it emits, you will update currentDate. Additionally, as you might’ve guessed already, you will add currentDate to the view’s state so it will trigger a new UI “snapshot” as it changes.
Sounds like a walk in the park, doesn’t it?
To work with publishers, start by adding towards the top of ReaderView.swift:
import Combine
Then, add a new publisher property to ReaderView which creates a new timer publisher ready to go as soon as anyone subscribes to it:
private let timer = Timer.publish(every: 10, on: .main, in: .common)
.autoconnect()
.eraseToAnyPublisher()
As you already learned earlier in the book, Timer.publish(every:on:in:) returns a connectable publisher. This is a kind of “dormant” publisher that specifically requires subscribers to connect to it to activate it. In your case, however, you will subscribe to the timer as soon as the view generates its first “snapshot” so you don’t need any complex connectable logic. You use autoconnect() to instruct the publisher to automatically “awake” upon it being subscribed for the first time.
What’s left now is to update currentDate each time the timer emits. You will use a SwiftUI modifier called onReceive(_), which behaves much like the sink(receiveValue:) subscriber. Scroll just a tad down and find the comment // Add timer here and replace it with:
.onReceive(timer) {
self.currentDate = $0
}
The timer emits the current date and time so you just take that value and assign it to currentDate. Doing that will produce an already familiar error:
Naturally, this happens because you cannot mutate the property from a non-mutating context. Just as before, you’ll solve this predicament by adding currentDate to the view’s local storage state.
Add a @State property wrapper to the property like so:
@State var currentDate = Date()
This way, any update to currentDate will trigger a new UI “snapshot” and will force each row to recalculate the relative time of the story and update the text if necessary.
Run the app one more time and leave it open in the Simulator or your device. Make a mental note of how long ago the top story was posted, here’s what I had when I tried that:
Wait for at least one minute and you will see the visible rows update their information with the current time. The orange time badge will still show the time when the story was posted but the text below the title will update with the correct “… minutes ago” text:
Besides having the publisher a property on your view, you can also inject any publisher from your Combine model code into the view via the view’s initializer or the environment. Then, it’s only a matter of using onReceive(...) in the same way as above.
Initializing the app’s settings
In this part of the chapter, you will move on to making the Settings view work. Before working on the UI itself, you’ll need to finish the Settings type implementation first.
Open Model/Settings.swift and you’ll see that, currently, the type is pretty much bare bones. It contains a single property holding a list of FilterKeyword values.
Now, open Model/FilterKeyword.swift. FilterKeyword is a helper model type that wraps a single keyword to be used as a filter for the stories list in the main reader view. It conforms to Identifiable, which requires an id property that can be used to uniquely identify each instance, such as when you use those types in your SwiftUI code. If you peruse the API.Error and Story definitions in Network/API.swift and Model/Story.swift, respectively, you’ll see that these types also conform to Identifiable.
Let’s go on the merry-go-round one more time. You need to turn the plain, old model Settings into a modern type that can be used with your Combine and SwiftUI code.
Get started by adding at the top of Model/Settings.swift:
import Combine
Then, add a publisher to keywords by adding the @Published property wrapper to it, so it looks as follow:
@Published var keywords = [FilterKeyword]()
Now, other types can subscribe to a Settings object’s current keywords. You can also pipe in the keywords list to views that accept a binding.
Finally, to allow Settings to be observed by views or injected into the SwiftUI environment, make the type conform to ObservableObject like so:
final class Settings: ObservableObject {
There’s no need to add anything else to make the ObservableObject conformance work. The default implementation will emit any time the $keywords publisher does.
This is how, in a few easy steps, you turned Settings into a model type on steroids. Now, you can plug it into the rest of your reactive code in the app.
To bind the app’s Settings, you’ll instantiate it in your scene delegate and bind it to ReaderViewModel. Open App/SceneDelegate.swift and add alongside the existing import statements:
import Combine
Next, at the top of scene(_:willConnectTo:options:), insert:
let userSettings = Settings()
As usual, you will also need a cancelable collection to store your subscriptions. Add a property for that to SceneDelegate, right below the window property:
private var subscriptions = Set<AnyCancellable>()
Now, you can bind Settings.keywords to ReaderViewModel.filter so that the main view will not only receive the initial list of keywords but also the update list each time the user edits the list of keywords.
Still in scene(_:willConnectTo:options:), insert after the line let viewModel = ReaderViewModel():
userSettings.$keywords
.map { $0.map { $0.value } }
.assign(to: \.filter, on: viewModel)
.store(in: &subscriptions)
You subscribe to userSettings.$keywords, which outputs [FilterKeyword], and map it to [String] by getting each keyword’s value property. Then, you assign the resulting value to viewModel.filter.
Now, whenever you alter the contents of Settings.keywords, the binding to the view model will ultimately cause the generation of a new UI “snapshot” of ReaderView because the view model is part of its state.
The binding so far works. However, you still have to add the filter property to be part of ReaderViewModel‘s state. You’ll do this so that, each time you update the list of keywords, the new data will be relayed onwards to the view.
To do that, open Model/ReaderViewModel.swift and add the @Published property wrapper to filter like so:
@Published var filter = [String]()
The complete binding from Settings to the view model and onwards to the view is now complete!
This is extremely handy because, in the next section, you will connect the Settings view to the Settings model and any change the user makes to the keyword list will trigger the whole chain of bindings and subscriptions to ultimately refresh the main app view story list like so:
Editing the keywords list
In this last part of the chapter, you will look into the SwiftUI environment. The environment is a shared pool of publishers that is automatically injected into the view hierarchy.
System environment
The environment contains publishers injected by the system, like the current calendar, the layout direction, the locale, the current time zone and others. As you see, those are all values that could change over time. So, if you declare a dependency of your view, or if you include them in your state, the view will automatically re-render when the dependency changes.
To try out observing one of the system settings, open View/ReaderView.swift and add a new property to ReaderView:
@Environment(\.colorScheme) var colorScheme: ColorScheme
You use the @Environment property wrapper, which defines which key of the environment should be bound to the colorScheme property. Now, this property is part of your view’s state. Each time the system appearance mode changes between light and dark, and vice-versa, SwiftUI will re-render your view.
Additionally, you will have access to the latest color scheme in the view’s body. So, you can render it differently in light and dark modes.
Scroll down and find the line setting the color of the story link .foregroundColor(Color.blue). Replace that line with:
.foregroundColor(self.colorScheme == .light ? .blue : .orange)
Now, depending on the current value of colorScheme, the link will be either blue or orange.
Try out this new miracle of code by changing the system appearance to dark. In Xcode, open Debug ► View Debugging ► Configure Environment Overrides… or tap the Environment Overrides button at Xcode’s bottom toolbar. Then, toggle the switch next to Interface Style on.
Feel free to stay in dark appearance mode. However, I’ll switch back to light appearance for the remainder of the chapter because it will print screenshots better in the book.
Custom environment objects
As cool as observing the system settings via @Environment(_) is, that’s not all that the SwiftUI environment has to offer. You can, in fact, environment-ify your objects as well!
This is very handy. Especially when you have deeply nested view hierarchies. Inserting a model or another shared resource into the environment removes the need to dependency-inject through a multitude of views until you reach the deeply nested view that actually needs the data.
Objects you insert in a view’s environment are made available automatically to any child views of that view and all views being linked from that view as well.
This sounds like a great opportunity for sharing your user’s Settings with all views of the app so they can make use of the user’s story filter.
The spot in your app where you create your main view is the scene delegate. This is where you previously created the userSettings instance of Settings and bound its $keywords to the ReaderViewModel. Now, you will inject userSettings into the environment as well.
Open App/SceneDelegate.swift and attach the environmentObject modifier to the creation of ReaderView by replacing the following line:
let rootView = ReaderView(model: viewModel)
With:
let rootView = ReaderView(model: viewModel)
.environmentObject(userSettings)
The environmentObject modifier is a view modifier which inserts the given object in a view hierarchy’s environment. Since you already have an instance of Settings, you simply send that one off to the environment and you’re done.
Next, you need to add the environment dependency to the views where you want to use your custom object. Open View/SettingsView.swift and add a new property with the @EnvironmentObject wrapper:
@EnvironmentObject var settings: Settings
The settings property will automatically be populated with the latest user settings from the environment.
For your own objects, you do not need to specify a key path like for the system environment. @EnvironmentObject will match the property type — in this case Settings — to the objects stored in the environment and find the right one.
Now, you can use settings.keywords like any of your other view states. You can either get the value directly, subscribe to it, or bind it to other views.
To complete the SettingsView functionality, you’ll display the list of keywords and enable adding, editing and deleting keywords from the list.
Find the following line:
ForEach([FilterKeyword]()) { keyword in
And replace it with:
ForEach(settings.keywords) { keyword in
The updated code will use the filter keywords for the on-screen list. This will, however, still display an empty list as the user doesn’t have a way to add new keywords.
The starter project includes a view for adding keywords. So, you simply need to present it when the user taps the + button. The + button action is set to addKeyword() in SettingsView.
Scroll to the private addKeyword() method and add inside it:
presentingAddKeywordSheet = true
presentingAddKeywordSheet is a published property, much like the one you already worked with earlier this chapter, to present an alert. You can see the presentation declaration slightly up in the source: .sheet(isPresented: $presentingAddKeywordSheet).
If you run the app right now and navigate to the Settings view, you will see the following crash in the debugger:
This is because only views that are children to the view where you inserted the environment object and linked views are receiving Settings automatically. Views presented with a sheet(...) modifier do not get the environment objects.
This is, however, a neat opportunity to exercise binding objects onwards one more time. What you’ll do is inject the settings as an environment object manually in the code.
Switch to View/ReaderView.swift and find the spot where you present SettingsView — it’s a single line where you just create a new instance like so: SettingsView().
The same way you injected the settings into ReaderView, you can inject them here as well. Add a new property to ReaderView:
@EnvironmentObject var settings: Settings
And then, add the .environmentObject modifier directly under SettingsView():
.environmentObject(self.settings)
Now, you declared a ReaderView dependency on Settings and you passed that dependency onwards to SettingsView via the environment. In this particular case, you could’ve just passed it as a parameter to the init of SettingsView as well.
Before moving on, run the app one more time. You should be able to tap Settings and see the SettingsView pop up. This means the user settings were correctly passed down to the presented view via the environment and the runtime is not complaining anymore.
Now, switch back to View/SettingsView.swift and complete the list editing actions as initially intended.
Inside sheet(isPresented: $presentingAddKeywordSheet), a new AddKeywordView is already created for you. It’s a custom view included with the starter project, which allows the user to enter a new keyword and tap a button to add it to the list.
AddKeywordView takes a callback, which it will call when the user taps the button to add the new keyword. In the empty completion callback of AddKeywordView add:
let new = FilterKeyword(value: newKeyword.lowercased())
self.settings.keywords.append(new)
self.presentingAddKeywordSheet = false
You create a new keyword, add it to user settings, and finally dismiss the presented sheet.
Remember, adding the keyword to the list here will update the settings model object and in turn, will update the reader view model and refresh ReaderView as well. All automatically as declared in your code.
To wrap up with SettingsView, let’s add deleting and moving keywords. Find // List editing actions and replace it with:
.onMove(perform: moveKeyword)
.onDelete(perform: deleteKeyword)
This code sets moveKeyword() as the handler when the user moves one of the keywords up or down the list and deleteKeyword() as the handler when the user swipes right to delete a keyword.
In the currently empty moveKeyword(from:to:) method, add:
guard let source = source.first,
destination != settings.keywords.endIndex else { return }
settings.keywords
.swapAt(source,
source > destination ? destination : destination - 1)
And inside deleteKeyword(at:), add:
settings.keywords.remove(at: index.first!)
That’s really all you need to enable editing in your list! Build and run the app one final time and you’ll be able to fully manage the story filter including adding, moving and deleting keywords:
Note: At the time of this writing, editing mode is a little clunky. You may have to tap Edit, partially swipe to delete a row and then tap Edit again to be able to re-order items.
Additionally, when you navigate back to the story list, you will see that the settings have been propagated along with your subscriptions and bindings across the application and the list displays only stories matching your filter. The title will display the number of matching stories as well:
Challenges
This chapter includes two completely optional SwiftUI exercises that you can choose to work through. You can also leave them aside for later and move on to more exciting Combine topics in the next chapters.
Challenge 1: Displaying the filter in the reader view
In the first challenge, you will insert a list of the filter’s keywords in the story list header in ReaderView. Currently, the header always displays “Showing all stories”. Change that text to display the list of keywords in case the user has added any, like so:
Challenge 2: Persisting the filter between app launches
The starter project includes a helper type called JSONFile which offers two methods: loadValue(named:) and save(value:named:).
Use this type to:
- Save the list of keywords on disk any time the user modifies the filter by adding a
didSethandler toSettings.keywords. - Load the keywords from disk in
Settings.init().
This way, the user’s filter will persist between app launches like in real apps.
If you’re not sure about the solution to either of these challenges, or need some help, feel free to look into the finished project in the projects/challenge folder.
Key points
With SwiftUI, your UI is a function of your state. You cause your UI to render itself by committing changes to the data declared as the view’s state, among other view dependencies. You learned various ways to manage state in SwiftUI:
- Use
@Stateto add local state to a view and@ObservedObjectto add a dependency on an externalObservableObjectin your Combine code. - Use
onReceiveview modifier to subscribe an external publisher directly. - Use
@Environmentto add a dependency to one of the system-provided environment settings and@EnvironmentObjectfor your own custom environment objects.
Where to go from here?
Congratulations on getting down and dirty with SwiftUI and Combine! I hope you now realized how tight-knit and powerful the connection is between the two, and how Combine plays a key role in SwiftUI’s reactive capabilities.
Even though you should always aim to write error-free apps, the world is rarely this perfect. Which is exactly why you’ll spend the next chapter learning about how you can handle errors in Combine.