SwiftUI on Apple Watch

SwiftUI was one of my favourite announcements during WWDC 2019 alongside Combine. At the time I’m writing this post, mid July, beta 4 has just been released and although it isn’t yet stable I think SwiftUI is starting to get interesting.
I think it will be hard to drop iOS 12 support soon, but SwiftUI seems like a good choice to write a standalone watchOS app, so I thought I could start from there.

My first example can be found here https://github.com/gualtierofrigerio/WatchQuizApp
it is a simple quiz app, you can chose the subject and you get a list of questions, with multiple answers you can select.

SwiftUI

I’d like to point out some key aspects about SwiftUI that I think are important, and even if you know Swift there are some new proposal in 5.1 that made SwiftUI possible.
First, if you’re used to working with UIKit, note that Views in SwiftUI are struct, so a value type, and you don’t subclass UIView or UIViewController so your view object is not a reference type. Property wrappers are really important in SwiftUI, as they provide a way to have state variables or bindings in the structs you use to define views. I don’t think it is necessary to go deep into property wrappers in order to start playing with SwiftUI, but if you want to know what @State or @ObjectBinding etc. mean I recommend my previous post http://www.gfrigerio.com/property-wrappers/
As I said we know have to conform to the View protocol, but you’ll see the keyword some and that’s because we’re using opaque types, another new feature of Swift, read more here
And finally, SwiftUI use function builders, so you can declare the view’s content by having a sequence of other Views directly in the function call.

The app

While you can make an entire app in SwiftUI everything starts with WatchKit or UIKit. For the Watch your starting point is a WKHostingController, initialised with a SwiftUI view, while on iOS you have the SceneDelegate you have a UIHostingController for the same purpose. If you start a new watchOS app Xcode will create some files for you, including the ones necessary to handle a notification if you enable the checkbox.
In our app the main view is ContentView, that’s the default file Xcode creates for us when we use SwiftUI, you can see the source here


struct ContentView : View {
    let dataSource = DataSource()
    
    var body: some View {
        VStack {
            Text("Ready to play?")
            NavigationLink(destination: CategoriesList(dataSource: dataSource)) {
                Text("Start")
            }
        }
    }
}

As I said we’re dealing with structs, and they must conform to the View protocol. The body property is mandatory, and we have to return “some View” so something conforming to the View protocol.
VStack is basically a container, it places views vertically centered inside it. In this view we want a text and a button to go to the next page, and thanks to function builders we can write Text(…) and then NavigationLink(…) and both are added to the VStack. In UIKit you’d have written something like vstack.add(Text(…) and vstack.add(UIButton…).
NavigationLink is a button that pushes a new view into the NavigationView stack, so we don’t need to specify an action, but just the destination. The last parameter is itself a View, so you can place inside the Button everything you want, and you’ll go to the next View by tapping inside the view declared there.
You can think of Text as a UILabel in UIKit or a WKInterfaceLabel in WatchKit.
My destination is the list of categories, defined by the struct CategoriesList you can find here
But first let’s see what else we have in ContentView


#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
#endif

This is the live preview, you need Xcode11 and macOS 10.15 in order to use that feature. As you change your view’s code you can see the UI changes in the preview area, much more convenient than running the app every time! Every time you create a SwiftUI view Xcode will put those few lines of code at the end of the file, if you have parameters to be set you have to specify them in the PreviewProvider as well, so you may want to have something like a global variable to host those values so you can have something meaningful in the preview. For example in CategoriesList I need a DataSource object, so I had to create one for the Preview


#if DEBUG
struct CategoriesList_Previews : PreviewProvider {
    static var previews: some View {
        CategoriesList(dataSource: DataSource())
    }
}
#endif

Let’s take a look at CategoriesList, it is the list of subjects like Math and History. In WatchKit I’d need a WKInterfaceTable to have a list of rows, but SwiftUI is much simpler


struct CategoriesList : View {
    var dataSource:DataSource
    
    var body: some View {
        List(dataSource.categories, id:\.title) { category in
            NavigationLink(destination:QuestionsList(model:QuestionsListModel(category: category))) {
                CategoryRow(category:category)
            }
        }.listStyle(.carousel)
        .navigationBarTitle("Categories")
    }
}

List automatically creates the table for us, it iterates the array in dataSource.categories, it knows the title is the unique identifiers and provides a closure with each element in the array, so we can build each row with the correct information.
.listStyle allows us to specify the style, we can use .carousel or .plain, I chose Carousel so we have an effect similar to the Workout app.
.navigationBarTitle specifies the title you can see on the top next to the left arrow allowing us to go back to the previous view.
We use a NavigationLink again, as we want to push another view containing the list of questions. Each row is defined by another struct


struct CategoryRow : View {
    var category:Category
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(category.title)
                .font(.title)
                .foregroundColor(CommonUtility.colorFromString(colorString: category.color))
            Spacer()
            Text("\(category.questions.count) questions")
        }.padding()
    }
}

The row contains two text labels, with Spacer() we can have them separated and padding sets, guess what, a padding around our VStack. You can specify a fixed amount of padding or provide insets if you need more control over the padding.

The last view pushed onto the stack is QuestionList, find the code here
In this struct you’ll see the property wrappers I mentioned before.


struct QuestionsList : View {
    
    @State private var currentQuestionId = 0
    @State private var showAnswers = false
    @ObjectBinding var model:QuestionsListModel
    
    var body: some View {
        List(model.questions, id:\.id) { question in
            Button(action: {
                self.showAnswers.toggle()
                self.currentQuestionId = question.id
            }) {
                QuestionRow(question: question, currentAnswer: self.model.getAnswer(forQuestionId: question.id))
            }
        }
        .navigationBarTitle(model.category.title)
        .actionSheet(isPresented: $showAnswers) {
            getActionSheetForQuestionId(currentQuestionId)}
    }
}

Here they are, @State and @ObjectBinding. Those are property wrappers, and allow us to have state variables and bindings in our View struct.
Let’s start with the body, we use a List and this time we have a Button with an action, as we’re not simply pushing another view via NavigationLink. When the button is pressed we toggle the Bool value of showAnswers, and that triggers the presentation of an ActionSheet. Thanks to @State we have this persistent boolean variable we can bind to isPresented, so when the variable is set to true SwiftUI shows the ActionSheet, and when the sheet is dismissed the variable is set back to false. What about @ObjectBinding? It allows us to use a model conforming to BindableObject, so when our model changes the view is updated with the new questions. You can find the view model here


class QuestionsListModel : BindableObject {
    var willChange = PassthroughSubject()
    var questions:[Question]
    var category:Category
    private var answers:[Int:AnswerType] = [:]
    
    init(category:Category) {
        self.category = category
        self.questions = category.questions
    }
    
    func getAnswer(forQuestionId id:Int) -> AnswerType {
        if let answer = answers[id] {
            return answer
        }
        return .unanswered
    }
    
    func getQuestion(id:Int) -> Question? {
        for question in questions {
            if question.id == id {
                return question
            }
        }
        return nil
    }
    
    func setAnswer(_ answer:String, forQuestionId id:Int) {
        willChange.send()
        for index in 0..< questions.count {
            if questions[index].id == id {
                let type:AnswerType = questions[index].correctAnswer == answer ? .correct : .wrong
                answers[id] = type
            }
        }
    }
}

The key aspect here is willChange, it is a PassthroughtSubject, a Publisher that sends no value (thus Void) and never fails. If you're interested in Combine I have a couple of posts http://www.gfrigerio.com/combine-first-example/ and http://www.gfrigerio.com/networking-example-with-combine/ so I don't go into details here, but as you can see in setAnswers I call willChange.send() and then change the model. This way after the user answers to a question I can either set it to correct or wrong, and the QuestionsList view is updated automatically by SwiftUI to reflect the changes in the model. That's why I used a BindableObject via @ObjectBinding. I didn't need to use it with the list of categories as they don't change after the view is rendered.

I had fun playing with SwiftUI on the Watch and I'm really looking forward to making more stuff with it.
I used this project to file a radar to Apple about the Behaviour of ActionSheet, since the binding variable showAnswers is not set back to false after the sheet is dismissed via the cancel button (iOS has the correct behaviour so I think this is a bug they'll fix).

This was just a quick introduction, I hope you'll have as fun as I had writing a small app for the Watch! Happy coding