The online home of John Pollard

Rewriting my WatchOS apps with SwiftUI

WatchOS apps can be so much nicer using SwiftUI, but it's still early days for the technology

I’ve been doing Paul Hudson’s excellent 100 Days of SwiftUI course, and decided to take the plunge and rewrite my WatchOS apps using SwiftUI.

Advantages of SwiftUI on WatchOS

I believe SwiftUI started life as a replacement for WatchKit and it really shows. It’s always been hard to build rich interfaces on WatchOS before, and Apple’s built-in apps couldn’t have been using WatchKit as they had many features simply unavailable to non-system developers.

With the power of SwiftUI, it’s now pretty easy to build rich animations, scrolling card views, and generally make first class apps that are much nicer to use.

The one (slight) downside is that you need to be running WatchOS 6.0 and above to run SwiftUI-based apps. However, I think Apple Watch users are pretty likely to be running the latest OS, and for my particular apps, the previous versions were pretty simplistic so didn’t see much usage anyway.

Changing to a SwiftUI mindset

SwiftUI is going to be a great way of developing apps for Apple’s multiple plaforms, although it’s still very new. The documentation leaves A LOT to be desired, and there are still bugs being fixed in each new version of Xcode/iOS.

The most interesting challenge was switching to a more reactive, event-driven way of propogating data changes into the app.

I won’t copy a whole bunch of code here, as both apps are open source and available here and here.

Updating the UI when the app became active

One particular challenge it took a while to figure out was how to trigger the animation of the progress control in Count The Days Left every time the app became visible.

There is an onAppear() function that can be added to a View. However, it turns out that is only run the first time a view appears, so when you reopen the app that is still in-memory, it won’t get called again.

This also meant that if the app stayed in memory overnight, it wouldn’t update the view the next day - which would obviously then be out of date 😟

The solution

  • In the data model class the the view observes, add a PassthroughSubject object:
import SwiftUI
import Combine

class WatchDaysLeftData: ObservableObject {
    
    @Published var currentPercentageLeft: String = ""
    @Published var currentTitle: String = ""
    @Published var currentSubTitle: String = ""
    @Published var percentageDone: Double = 0.0
    
    let modelChanged = PassthroughSubject<(), Never>()
    ...
  • The observable object also exposes a updateViewData() function, that (amongst other things):
    • Resets the self.percentageDone to be 0.0, and then calls self.modelChanged.send(())
    • Calculates the correct self.percentageDone, and then calls self.modelChanged.send(()) again
  • The View class receives messages from the modelChanged object, and updates the view using an animation:
struct ProgressControl: View {
    @State private var animationProgress: Double = 0.0
    @ObservedObject var model: WatchDaysLeftData

    // Code cut for clarity

    var body: some View {
      ZStack {
        // Code cut for clarity

        Arc(progress: self.animationProgress)
        // Code cut for clarity
          .onReceive(model.modelChanged, perform: { _ in
            withAnimation(.easeInOut(duration: self.duration)) {
              self.animationProgress = self.model.percentageDone
            }
          })
        }
    }
}
  • Obviously this “animates to 0.0” and then “animates back to the real value” because two separate messages are sent to the View.

  • Finally, the actual data model passed through into the View is a property on the extension delegate, so we can call self.dataModel.updateViewData() in the extension’s applicationDidBecomeActive() event handler, which triggers a “re-animation” each time the app becomes active.

Did that make sense?

As you can see from the video below, this isn’t ideal, and to be honest feels a bit hacky. It would have been MUCH nicer if the View’s onAppear() worked as the name implies, and runs every time the view actually appears!

It could also be that I’m still learning SwiftUI - so definitely let me know if you have a better solution!

Videos showing what I’ve been trying to say

Here’s the new Count The Days Left, showing the nice animation (with room for improvement!):

… and here’s how my new, improved Yeltzland app looks. The card view is perfect for Watch apps, and was as easy adding .listStyle(CarouselListStyle()) to the List View showing the fixtures and results data.

Please excuse the scroll glitch, as my home-made video setup meant I struggled to turn the digital crown with my wrist at a funny angle 🙄

Summary

I’m really happy how much better my two WatchOS apps are now, and SwiftUI is truly game-changing (not just for the Watch!)

Once I decide to bump the main Count The Days Left app up to support only iOS13 and above, I should be able to reuse most of the SwiftUI code pretty much as is, which will be fantastic.

I’m also considering building Mac Catalyst versions of the apps at some point, so again could consider doing those in SwiftUI. However other issues (around some 3rd party dependencies) don’t make this easy, so that may be a while off yet.