Last year on the WWDC 2019 Apple has announced iOS 13, watchOS 6, macOS X Catalina and a lot more. Until now a lot has been said about all those shiny new things. A lot of people suggest the latest WWDC be an as big step forward as the one during which Swift was introduced.
To my mind, the biggest changes do affect watchOS. Let’s face it, SwiftUI is available only since iOS 13, watchOS 6 and macOS Catalina and most of the developers would like to preserve compatibility with the previous version or two at least. Moreover, SwiftUI is not quite mature yet. There are still many problems during development as well as quite poor flexibility of the new framework. This means that we won’t see many SwiftUI iOS or macOS apps in the near future.
However, SwiftUI is just brilliant for watchOS. First of all, we usually do not need much functionality on a watch. Furthermore, the level of customization of views in WatchKit is not as high as in UIKit so moving to SwiftUI is not that painful. Finally, even now (November 2019) there are very few apps in watchOS AppStore which means that there’s enough space for you to implement your ideas.
In this article, I’d like to take a quick look at two major features of new watchOS update and create a stand-alone watchOS app using SwiftUI.
Application
Apple Watch is a great device that can currently track a lot of things: the distance you walk/run/swim, heart rate, standing hours, even volume level of the environment! But what about tracking something more enjoyable than sport? What about TV series for example? No kidding, it’s sometimes big trouble for me to remember which episode was the last and as soon as I watch series on different platforms I would like to have a third-party app to track this. Moreover, sometimes it’s just vital to remember which episode was last to avoid spoilers! :)
So let’s try to create a SeriesTracker app that will help you to remember at which season and episode you’ve left a particular TV series.
Let’s start with the project configuration. One should create a new Xcode project by choosing File:
New -> Project than switch to watchOS
tab and select Watch App
.
Then it’s important to choose SwiftUI for User interface in project configuration window.
Now we’ve got some basic project structure. Let’s clean it up a little by dividing all the files into different directories.
We won’t need Notifications, Complication and Resources so far in this article. We may surely use Assets.xcassets to add some pretty application icons or complications though :)
Let’s start with the acknowledgement of what an app should be able to do. We’ll store and display a list of series and some basic information about them. We’ll also provide the possibility to update information, remove series from the app and add new.
Model
{struct Series: Identifiable, Codable, Equatable {
let id = UUID().uuidString
let name: String
var seasonNumber: Double = 1
var episodeNumber: Double = 1
var finishedWatching = false
}
First of all, we need to define a model.
Series structure contains id which is also required by the Identifiable protocol, name of the show, season and episode numbers and a bool value saying whether we’ve already finished watching it. Codable protocol is needed in order to store our shows in json format and Identifiable will soon be explained when it comes to the SwiftUI List structure.
final class DataStorage: ObservableObject {
// MARK: - Singleton
static let shared = DataStorage()
// MARK: - Properties
// Internal
private let seriesPersistanceIdentifier = "com.stfalcon.series_tracker.series"
// Data
@Published var series: [Series]
// MARK: - (Private) Init
private init() {
let storedSeriesData = UserDefaults.standard.data(forKey: seriesPersistanceIdentifier)
self.series = DataStorage.decodedFromJson(storedSeriesData) ?? exampleSeries ?? []
}
// MARK: - Private logic
func saveAll() {
self.save(series, forKey: seriesPersistanceIdentifier)
}
private func save<T: Codable>(_ data: T, forKey key: String) {
let rawData = DataStorage.encodedJsonRepresentation(of: data)
UserDefaults.standard.set(DataStorage.encodedJsonRepresentation(of: rawData),
forKey: key)
}
// MARK: - Private static utils
private static func encodedJsonRepresentation<T: Codable>(of data: T) -> Data? {
return try? JSONEncoder().encode(data)
}
private static func decodedFromJson<T: Codable>(_ data: Data?) -> T? {
guard let data = data else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
}
Now we should create some kind of persistence manager — DataStorage.
This class will have a shared
singleton. We’ll keep series in the array and store them in user defaults. Sure usually it’s a bad idea to store big amount of data there. However, it’s just good enough for our example.
There are two unusual things about the DataStorage — ObservableObject and @Published. ObservableObject is a protocol an object has to conform to make itself available for SwiftUI to track and @Published annotation is a property-wrapper that makes particular property of object to be available for observing. We’ll take look at how it works later in this article.
Let’s also add a line of code to ExtensionDelegate to save our data to UserDefaults each time our app becomes inactive.
func applicationWillResignActive() {
DataStorage.shared.saveAll()
}
Presentation
Now when we’ve done with the model let’s dive deeper in the presentation layer. There we have HostingController and ContentView so far. We’ll create our custom views and make one of them a subview of content view and then navigate to all the others. We will use HostingController as the only controller in the app and make our application something like a view-based.
Let’s start creating views from the most atomic ones. Our application will not be using any Internet connection so far so we won’t be able to download any pictures for our shows. However, we could still create a placeholder view with the first letter of the show’s title — LetterImageView.
struct LetterImageView: View {
// MARK: - Data
var letter: Character
// MARK: - View
var body: some View {
ZStack {
Rectangle()
.size(CGSize(width: 200, height: 200))
.foregroundColor(.gray)
GeometryReader { geometry in
Text(String(self.letter))
.font(Font.system(size: geometry.size.height / 2))
.bold()
}
}
.clipShape(Circle())
}
}
struct LetterImageView_Previews: PreviewProvider {
static var previews: some View {
LetterImageView(letter: "A")
}
}
Every SwiftUI View is a structure conforming to the View protocol. Unlike UIKit all the views are structures in SwiftUI. That leads to having less problems with inappropriate use of OOP principles and helps us to avoid memory-management mistakes (like retain cycles for instance).
Only one stored property is needed by our view. It’s a character we want to display as the placeholder. Like every View our LetterImageView must provide computed property body
that contains all the nested views. Most of SwiftUI views have an initialiser that has a closure parameter where we put all the nested views.
In this particular view we’ve used ZStack which is used to overlay the views: gray rectangle of fixed size and a label. Apart of ZStack, SwiftUI has also HStack and VStack (which are horizontal and vertical stacks). One should pay attention to that we wrap label inside of GeometryReader
. This is made in order to adjust label’s font to the view’s size in case the whole view is scaled. Let’s also mark that we clip the stack to the shape of circle. SwiftUI provides us with a variety of shapes we can clip our views to.
Text(self.currentSeries.finishedWatching ? "Finished watching" : "In progress")
.font(.subheadline)
.foregroundColor(self.currentSeries.finishedWatching ? .gray : .green)
Looks quite declarative, doesn’t it? Sure and that is a huge advantage of SwiftUI. Another great thing about this framework is that code is now the only source-of-truth and there’s zero possibility that any configurations are overridden elsewhere as that could happen while using Storyboards.
We should also pay attention to the LetterImageView_Preview structure. It is used by the preview engine and should provide all the mocked data for the view.
Now we’ve got a placeholder.
// MARK: - Data
@EnvironmentObject var store: DataStorage
var seriesId: String
var seriesIndex: Int {
store.series.firstIndex(where: { $0.id == seriesId })!
}
var currentSeries: Series {
store.series[seriesIndex]
Let’s create a view that will display all the information about the show — SeriesDetailedView. Here we’ll need a storage
as @EnvironmentObject, seriesId
as stored property. We’ll also need some utilities like computed seriesIndex
and currentSeries
.
@EnvironmentObject as well as @ObservableObject and @State are the object-wrappers provided by SwiftUI. If any of @Published values of these objects are modified, changes are automatically tracked by the framework which invalidated view’s layout and launches UI reload. The main difference between these three annotations is that @EnvironmentObject is usually used for the objects shared for the whole application and provided by special method func environmentObject < B > (_ bindable: B) -> some View where B : ObservableObject
of the View protocol; @ObservableObject is used for objects shared between a small number of views and provided via View’s initialiser; @State is used for the objects that are used in the context of single View and also provided via initialiser.
The body
of the SeriesDetailView is quite straightforward so let’s only focus on some SwiftUI features that were not described yet.
As soon as all the UI is written in closures we can easily use conditional statements to include/exclude some views as well as make any parameters of UI configuration depend on model. It’s easy to see this in In progress
label configuration.
Based on whether user has finished watching particular show we display different text on the status label and set the text’s color. Great declarative and quite convenient way to set up the UI.
Next we are going to create a view for editing current season and episode of the show and also the finishedWatching
property. That means that we have to navigate to the next screen somehow. In SwiftUI there’s a simple way to do that.
NavigationLink(destination:
EditSeriesView(
seriesId: seriesId
).environmentObject(self.store)
) {
Text("Update")
Here we create a NavigationLinkView and provide the destination view which is EditSeriesView in this case. We pass the seriesId and environment right in function parameter inline. We use trailing closure to specify the way NavigationLink should be drawn. In this case that would be a button Update
.
var currentSeriesBinding: Binding<Series> {
$store.series[seriesIndex]
}
Although the interface of EditSeriesView is very simple, there is one major point to described. We’ll create another utility as computed property. Binding is used to reference an object or structure we want to update with the use of UI Controls. One should pay attention to that we use special $-syntax to make Binding from our ObservableObject. Now if we pass this binding to Slider, it will automatically update the particular series in our EnvironmentObject which will surely update the UI and keep it up to date. Simply talking as soon as user triggers the slider, the text of the label is automatically updated.
Text("Current season: \(Int(currentSeries.seasonNumber))")
Slider(value: currentSeriesBinding.seasonNumber,
in: minValue...maxValue,
step: step)
.accentColor(.orange)
Here’s how it looks like on a device.
Now it’s time to create a list of all series in the app. SwiftUI List is a powerful analogue of UITableViewController from UIKit. It’s the main element in our SeriesListView. Later we’ll make this view the main view of our app providing it as a subview of the ContentView.
Let’s dive a bit deeper in the implementation of this view. Here we provide @EnvironmentObject that can be treated as some kind of datasource in this particular case. Moreover, we provide two @State properties. showInProgressOnly
is used to filter away the shows that are currently not watched by user. newSeriesName
will soon be used to store the name of newly added show.
struct SeriesList: View {
// MARK: - Data
@EnvironmentObject var store: DataStorage
@State var showInProgressOnly = true
@State var newSeriesName = ""
// MARK: - View
var body: some View {
List {
Toggle(isOn: $showInProgressOnly) {
Text("In progress")
}
ForEach(store.series) { series in
if !self.showInProgressOnly || !series.finishedWatching {
NavigationLink(
destination: SeriesDetailView(seriesId: series.id)
.environmentObject(self.store)
) {
SeriesRow(
seriesId: series.id
).environmentObject(self.store)
}
}
}
}
.navigationBarTitle("My Series")
}
}
Usually we do not need to use ForEach for views in List. Passing datasource and closure with nested views to the list initialiser is good enough if we have dynamic cells only. In this case we have both — static cell with toggle that controls filtering and a number of dynamic cells displaying the shows. It’s important to emphasise that our datasource (the array of Series) is Identifiable as soon as Series is Identifiable itself. That makes List or ForEach able to distinguish between different models and enables them to work with reusable views. If Series wasn’t Identifiable we would have to provide an additional parameter id
and specify #keyPath for the object’s primary key. It’s also nice to see how easily we can filter objects with the use of single conditional
statement (line 26).
Here’s how our list looks like on device.
Now we can display and modify our shows let’s give user the ability to remove ones.
To do this we should only create a method in our view that removes value from datasource based on the IndexSet and specify
.onDelete(perform: self.delete)
this function in onDelete
for the ForEach element. Yes, that easy!
private func delete(at offsets: IndexSet) {
store.series.remove(atOffsets: offsets)
}
TextField("Add new series", text: $newSeriesName, onCommit: {
self.addNewSeries()
})
Now let’s create an input for adding new series. We create a method that inserts a new value into the datasource and create a TextField which will modify newSeriesName
@State and call addNewSeries
function on commit.
Now we can add new TV series to our app.
Last but not least, most of the SwiftUI views are reusable all over the apple devices (watch, iPhone, Mac) and that is beautiful in case we want to develop a cross-platform application.
Conclusion
Although it’s true that SwiftUI is much, much more than these few things covered in this article, this tiny app is a brilliant example of how simple and straightforward the process of creating the stand-alone watchOS app is now. It truly looks like the beginning of the new era in mobile and wear software development.
The complete version of SeriesTracker can be found on the repo.
Originally published in Stfalcon.com.
Top comments (0)