Converting Streaks Apple Watch App to SwiftUI

With the release of Streaks 9, one of the major changes in the app is that the Apple Watch app is now (mostly) built in SwiftUI.

This has been in the works for over two years, so it's a great relief to finally ship it!

This article discusses some of the implementation details in achieving this milestone, as well as the state of converting Streaks to using more SwiftUI.

Thanks to Zach Simone for his huge help in this conversion. SwiftUI has been a huge learning curve for us and Zach has helped significantly with it all!

What We've Converted

At a high level, an Apple Watch app consists of the following:

  • The main Apple Watch app. This is the app users can launch and interact with.
  • Complications. These are the elements that appear on the Apple Watch clock face.
  • Rich notifications. When a notification is triggered (or forwarded to) on Apple Watch, it can be shown in a custom format.
  • Intents (Shortcuts / App Intents)

In this update, we've converted the main Apple Watch app to SwiftUI. We're still working on complications, rich notifications and intents.

Backwards Compatibility

At this time, Streaks for Apple Watch still supports watchOS 6. Even though SwiftUI runs on watchOS 6, there were some roadblocks in our implementation, so if you're on watchOS 6, you'll still be running the WatchKit version.

We achieve this by defaulting to the SwiftUI interface, then switching back to WatchKit if necessary.

Entry point: WKHostingController

The main entry point is HostingController, which extends from WKHostingController.

In ExtensionDelegate.applicationDidFinishLaunching, we check the watchOS version:

if #available(watchOS 7, *) {
    // Do nothing
}
else {
    WKInterfaceController.reloadRootPageControllers(
        withNames: [ InterfaceController.controllerName ],
        contexts: nil,
        orientation: .horizontal,
        pageIndex: 0
    )
}

Building Our SwiftUI Library

With the way Apple has rolled out SwiftUI to developers, it's allowed us to gradually build up our library of reusable SwiftUI views/components.

The first SwiftUI code we built for Streaks was for the widgets (released in iOS 14 in 2020). One of our widgets is called "Tasks", which can show 1-4 tasks.

The main circular task view.

In building this widget, we had to implement the "circular task" component. While this appears simple, there are many elements to this:

  • Draw the icon
  • Draw the track background with correct notches
  • Draw the track fill
  • Draw the streak number
  • Draw the task name

There are so many different combinations that can be drawn, so this component alone was a huge part of our SwiftUI implementation.

Once completed for widgets, it could also be dropped right into the Apple Watch:

The same task view dropped in to Apple Watch.

Animations

Widgets and complications only show non-interactive, static views. While the above component was a key part of converting to SwiftUI, to match the style of Streaks it needs to be interactive and animated.

One of the main (and most complex!) parts of the Streaks iPhone app is the same task view on the main screen. Users can long press on it to mark a task as complete.

Note: The embedded animations below don't do the real animation justice. They are silky smooth in the app.
Task completion animation.

Our previous (WatchKit) Apple Watch app also has this animation, but at a much lower frame rate, and relies on animating a series of UIImage.

In Streaks 9, this animated view on Apple Watch fully uses SwiftUI, not a series of animated images. This makes it much smoother, faster and nicer to interact with.

While creating animations in SwiftUI is somewhat straightforward, there are many edge-cases in Streaks:

  • Sometimes the animation fills an inner circle
  • Sometimes the animation files part of the outer circle
  • With timed tasks, either the inner or outer circle may animate, depending on the task settings
  • Sometimes the task may start with a non-zero progress.
  • Sometimes the task may already be complete
  • Needs to work in right-to-left
  • Needs to work clockwise (positive task) or anti-clockwise (negative task)
  • A task's timer may already be running when the view is loaded, meaning the animation needs to be running
  • Task title may need to animate (e.g. when a timed task is counting down)
  • Need to receive callbacks when animations start or finish
  • Need to be able to start/stop/resume animations for timers
  • Many more!

All of these edge cases are the primary reason why it's taken us two years to get all this working as well as we'd like.

While I don't have specific code to share about this aspect, building this all in SwiftUI has been extremely worthwhile, and we hope to bring this component back to the iPhone very soon.

Other Interface Considerations

Previously on Apple Watch, you would long press to reveal a menu. This was a built-in system feature, which we used to allow the user to show a settings screen.

Once this menuing system was deprecated and removed from WatchKit, we added a full screen WKLongPressGestureRecognizer to show the settings screen instead.

We've now changed this to show a dedicated Settings button at the bottom. We plan to expand this area in future to show more options, such as statistics (and hopefully an option to create a task directly on Apple Watch!)

Adding a dedicated Settings button

Similarly, we've done the same when viewing a task: a new feature in Streaks 9 is to record notes, so we've brought this feature to Apple Watch too. Previously these options were hidden behind a long press gesture.

Task options now appear beneath the task.

Recording Notes on Apple Watch

Being able to record notes is a new feature in Streaks 9, so we wanted to add this to the Apple Watch app too.

To record a note, the user taps the speech bubble, and then they're shown the keyboard input.

Recording a note on Apple Watch

To create the speech bubble, we created a custom shape in SwiftUI:

public struct SpeechBubbleShape: Shape {

    public init() { }

    public func path(in rect: CGRect) -> Path {
        let bezierPath = UIBezierPath(speechBubble: rect.size)

        return Path(bezierPath.cgPath)

    }
}

You'll notice here that the actual shape in drawn in a custom extension to UIBezierPath. We did this so the same shape can be used in the iPhone app's UIKit views.

To accept text input, we use SwiftUI's TextField:

TextField("", text: $notesContent, axis: .vertical)
    .lineLimit(3, reservesSpace: true)
    .multilineTextAlignment(.leading)

There were two problems here though:

  1. Using TextField like this meant we couldn't use our speech bubble shape, since it just shows a rounded rectangle.
  2. The multiline support in TextField on Apple Watch is buggy: while it would reserve 3 lines of space, it would just truncate the first line and not show subsequent lines.
FB12028013

To combat this, we made our speech bubble interactive using the following technique:

  1. Draw the TextField as usual
  2. Draw a black overlay over it to hide it
  3. Draw the speech bubble over that
  4. Make the speech bubble non-interactive, so taps fall through to the TextField.
ZStack {
    TextField("", text: $notesContent, axis: .vertical)
        .lineLimit(3, reservesSpace: true)
        .multilineTextAlignment(.leading)
        .textFieldStyle(.plain)
        .overlay {
            RoundedRectangle(cornerRadius: 12, style: .continuous)
                .foregroundColor(.black)
                .allowsHitTesting(false)
        }

    SpeechBubbleShape()
        .foregroundColor(.gray)
        .allowsHitTesting(false)
}
c

SwiftUI in the iPhone App

Since we'd built up a large library of components when building the iPhone widgets and now the Apple Watch app, we decided to try and use some SwiftUI in the main iPhone app.

One of the widgets already available in Streaks is a "stats" widget, which allows the user to choose from a range of displays, such a calendar, all time completion, or to show notes.

The stats widget in Streaks.

When profiling the app for the Streaks 9 release, we found that launch times of the main app were being slowed down by the drawing of the calendar and mini charts.

The calendar and mini-charts were slow.

To speed things up, we made two changes:

  • The calendar and charts are now loaded lazily. In other words, they're only loading when the user tries to view them. Previously, the calendar was loaded for every single task, even if the user never looked at them.
  • These two components are now embedded using SwiftUI and UIHostingController.

This has significantly improved the overall performance the app. It's also a good demonstration of how the iPhone app can be migrated to SwiftUI piece-by-piece.

SwiftUI Roadmap

So for those playing at home, Streaks is now structured as follows:

  • iPhone/iPad/Catalyst App: Mostly UIKit with several small components in SwiftUI
  • iPhone Widgets: 100% SwiftUI
  • iPhone Shortcuts ("IntentsUI"): 100% UIKit
  • iPhone Rich Notifications: 100% UIKit
  • Apple Watch: 100% SwiftUI, with a WatchKit legacy mode for watchOS 6 users
  • Apple Watch Complications: 100% Legacy ClockKit widgets
  • Apple Watch Rich Notifications: 100% WatchKit
  • Apple Watch Shortcuts: Legacy Intents target, which has no custom UI.

Our roadmap for SwiftUI conversion in the next year or so is as follows:

  • iPhone/iPad/Catalyst App: Replace more small parts with existing SwiftUI components.
  • iPhone/Apple Watch Shortcuts: Implement App Intents, which uses SwiftUI for snippets.
  • Apple Watch Complications: Migrate all to WidgetKit.
  • Apple Watch Rich Notifications: Migrate all to using SwiftUI

As you can see, a slowly-but-surely approach has been quite effective. The final hurdle will migrating the entire iPhone app, but in the spirit of Streaks, just doing one bit at a time will eventually allow us to reach that goal!