Going for Gold- Taking full advantage of Apple Platforms

In a recently published blog-post on building widgets for iOS 14, I showed how to implement a home-screen widget to display the latest status of the London Underground network. In the sample project, I showed how a shared module could be used to share everything from networking code to SwiftUI views between both the actual app and the widget.

Since SwiftUI is supported on all of Apple’s platforms, and we can now create apps entirely with SwiftUI, I thought it would be interesting to see how easily we can adapt a similar app to support all Apple platforms, be accessible for all users, and implement as many flagship features as possible. SwiftUI makes it super easy to do this, but as developers we still need have each of these features at the back of our minds when we are working.

Sample code for this post is available on GitHub.
Make sure to checkout the going-for-gold branch of the repository!

iOS

WidgetKit / iOS

Let’s start assuming that we have implemented this, if you want more details on this, check out my previous blog post on the topic.

Dark Mode

In SwiftUI, we can implement dark mode very easily as part of our existing work. In-fact, when using default Views and Labels in SwiftUI these will work out of the box. If you are manually specifying colours, just be careful to use Color.label instead of Color.black and Color.systemBackground instead of Color.white for anything that should adapt when run with dark mode enabled.

There may be occasions where specific As you may have noticed, the existing sample app I created for Widgets didn’t specify many colours, apart from those of the Tube Lines. When overlaying text on these backgrounds, we always want to use white even in dark mode, so we can specify this manually.

Text(update.line.displayName)
    .foregroundColor(.white)
    .padding()
    .background(update.line.color)
Line name colour must remain the same, status text must adapt

TL;DR: Only use white and black when the colours should remain the same between dark and light modes.

Dynamic Type

As with dark mode, if we are using the default Labels in SwiftUI, we get dynamic type for free though there are a number of things that we should do to improve how it behaves when we start to implement more complex interfaces. Let’s check out how our Tube Status example works at the largest accessibility dynamic type size (AX5).

AX5 Font Size
AX5 Font Size
(Optimised for Dynamic Type)

Not too bad: since we’ve used the inbuilt SwiftUI fonts the size increases our body font from 17 to 53 points automatically. The containers get adjusted accordingly and we can read all the detailed information and see the status icon and the colour that represents the line.

However, two key bits of information have been truncated. We can no longer see the full name of the line or the status type. An experienced Londoner might be able to infer the information but this isn’t very accessible for tourists.

It would be much better to split these pieces of information to be split across multiple lines at this font-size. We can adapt this snippet from Hacking with Swift to automatically switch between a horizontal and vertical view when the user switches accessibility size. Instead of monitoring the size class, we need to check the sizeCategory.

@Environment(\.sizeCategory) private var sizeCategory

var body: some View {
    Group {
        if sizeCategory >= .accessibilityMedium {
            VStack(spacing: 0, content: content)
        } else {
            HStack(spacing: 0, content: content)
        }
    }
}

In general, horizontal grids, such as carousels are more difficult to provide dynamic type support as there is often nowhere for the text to expand into as the default is for the screen to scroll vertically.

Always test your app with different dynamic type sizes to ensure that your views don’t break if users enable this feature.

Voiceover

SwiftUI also provides excellent support for Voiceover straight out of the box. If you run the app with VoiceOver enabled you’ll notice that all of the elements will be read out as you navigate down the screen. This “free” implementation here is a great starting point but there are definitely things we can do to improve it.

Here, each of our text labels is classed as an individual element, which means we need to navigate through the three different elements to get to the next line status. We should combine these into a single accessibility element so that the user can navigate to the specific tube line they need much more quickly. Once they find the correct line, they can still hear each of the labels, since they are grouped.

VStack {
    LineStatusView(update: update)
    // ... etc.
}
.accessibilityElement(children: .combine)

However, upon combining the elements, you’ll notice that Voiceover says that the combined elements is an “image”. We can fix this by adding the following two lines.

.accessibility(addTraits: [.isStaticText])
.accessibility(removeTraits: [.isImage])

This is obviously just a quick overview of what’s possible with some of the accessibility features in SwiftUI and iOS. For further reading I’d recommend checking out Rob Whitaker‘s blog.


iPadOS

For iPadOS we can use exactly the same codebase as we used for iOS. There’s not a huge amount more we would want to do specifically for iPad for such a simple app. One main distinction is that we can display our app in multiple windows. Since we’re already using WindowGroup as our SwiftUI scene, we get this ability for free.

Tube Status app running in multiple windows on iPad.

 Watch

The App

Until now, all our work has been focussed on iOS and iPadOS. We will now add the additional requirement of supporting other platforms. Since all the platforms support Swift and SwiftUI, we just need to make our shared framework compile to code for each of the additional platforms so that we can reuse our views across each.

Tube Status app running on watchOS

I used this tutorial to ensure that the same Shared framework we created in the previous blog post will support all four platforms. It will mean that we can, as we did with the widget project, share all of our code relating to networking, domain models and even SwiftUI views between our iOS app and the other Apple platforms.

Once we’ve enhanced the shared framework, it’s time to create a new target for our watch app. This should be familiar from the previous tutorial: in Xcode, go to File → New → Target → watchOS → Watch App and click Next. This will create a standalone app for Apple Watch that doesn’t require our existing iOS app to be installed. As we have previously, we want to use SwiftUI and the SwiftUI lifecycle for the app: this allows us to reuse the most amount of code between platforms. Select to include Complication which we will cover below, but not Notification scene which is not relevant for our simple tube app (since it does not implement push notifications).

Setup your watchOS Target in Xcode: SwiftUI Interface, SwiftUI Lifecycle, Swift Language, Include Complication.
Setup your watchOS Target in Xcode

Next, we just need to ensure we include the Shared framework into our watch app:

Be careful to include the Shared framework in the Watch app

The watch app template will include an App declaration and a template ContentView. Since we already have a content view in our shared framework, we can delete the provided one, and simply import the shared one into the app.

import Shared
import SwiftUI

@main
struct TubeStatusApp: App {
    @ObservedObject private var viewModel = StatusViewModel(client: .init())

    var body: some Scene {
        WindowGroup {
            ScrollableContentView(updates: viewModel.status)
        }
    }
}

One minor change, will fix the truncation showed in the screenshot above. We’ve already implemented logic for our AdaptiveStack to display vertically at higher dynamic type-sizes, but we can use this for our watch app too:

private var shouldDisplayVertically: Bool {
     #if os(iOS)
     return sizeCategory >= .accessibilityMedium
     #elseif os(watchOS)
     return true
     #else
     return false
     #endif
}

That’s it: the app should run seamlessly on watchOS (and it looks great too!)

Tube Status app optimised for watchOS
Tube Status app optimised for watchOS
Much better!

Complication

Adding a watchOS complication isn’t too difficult. It uses a lot of similar concepts to the iOS Widget we implemented previously. However, on the Apple Watch space is much more limited so we will need to massively reduce the amount of information we can provide.

There is no support for using Intents to supply user-configurable parameters for complications. This means we will have to provide separate complication descriptor for each of the tube lines we want to support. We do this using the getComplicationDescriptors method.

func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) {
    let descriptors = Line.allCases
        .map { $0.rawValue }
        .map {
            CLKComplicationDescriptor(identifier: $0,
                                      displayName: "\($0) Status",
                                      supportedFamilies: CLKComplicationFamily.allCases)
        }
    // Call the handler with the currently supported complication descriptors
    handler(descriptors)
}

As with Widgets, we provide data to our complication using a TimelineEntry type. As before, for apps like public transit status which can’t provide future forecasts, we can only ever provide a single reliable timeline entry using the getCurrentTimelineEntry method.

func getCurrentTimelineEntry(for complication: CLKComplication,
                             withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) {
    // Use the identifier to retrieve the Line from the selected complication
    guard let line = Line(rawValue: complication.identifier) else { handler(nil); return }
    // Reuse our StatusService from the Shared framework to retrieve status data
    StatusService.getStatus(client: .init(), for: line) { [weak self] statuses in
        guard let status = statuses.first,
              let template = self?.getTemplateForLineStatusUpdate(status, matching: complication)
        else { handler(nil); return }
        // Call the async handler with the current timeline entry
        handler(CLKComplicationTimelineEntry(date: Date(), complicationTemplate: template))
    }
}

For iOS Widgets our view implemented in a separate configuration type, decoupled from our WidgetKit.TimelineEntry model. In contrast the CLKComplicationTimelineEntry type for complications contains all the information needed to display our complication.

All we need to do now is to map our status information (LineStatusUpdate) into the CLKComplicationTemplate type by implementing the getTemplateForLineStatusUpdate(status: LineStatusUpdate, matching: CLKComplication) method we have used in the snippet above:

func getTemplateForLineStatusUpdate(_ update: LineStatusUpdate,
                                    matching complication: CLKComplication) -> CLKComplicationTemplate? {
let template: CLKComplicationTemplate?
    switch complication.family {
    case .modularSmall:
        template = CLKComplicationTemplateModularSmallStackImage(line1ImageProvider: imageProvider, 
                                                                 line2TextProvider: header)
    // Implement the CLKComplicationTemplate for each other 
    // supported complication format
    ...
    @unknown default:
        template = nil
    }
    template?.tintColor = UIColor(Line.bakerloo.color)
    return template
}

Three methods implemented; another great feature available for our users.

watchOS Complications give our users the tube status for various lines with a quick glance.

 TV

When I was writing this section of the article my partner asked me: why would on earth would we want to display the tube status on our TV?

How about something like a large real-time display that we can check at work just before we leave the office to go home? Here’s a photo of the one we have at my day-job- not (YET) using this implementation! Or we could even use it to power the display boards at the station.

Since we’ve already done the cross-platform work to make our shared framework to run across both iOS and watchOS, adding support for the TV should be trivial:

  • Add a new target for supporting tvOS in the same way as before:
    File → New → Target → tvOS → App.
  • Implement the app declaration, we can actually use exactly the same declaration (watchOS: TubeStatusApp.swift) that we used for the watch above.
  • Build and run!
Tube Status app running on tvOS
Tube Status app running on tvOS

As an alternative, we could use the StaticContentView we implemented for our iOS Widget to display all the statuses and hide the detailed descriptions. This would be more practical if we are indeed running in a Kiosk mode.

Tube Status app running on tvOS without scroll

Clearly this is a design question, but it illustrates how the code reuse across the different platforms can give us greater flexibility in our design choices.

macOS

Finally, we come to the longest standing platform, macOS. By now, our app is pretty good at adapting to various screen-sizes and contexts.

We can add a new target for supporting macOS in the same way as before:
File → New → Target → macOS → App.

If we run the app now, we will see a blank screen and output to the console complaining of issues connecting to the Internet. This is because by default macOS apps run inside an App Sandbox, preventing access to files, networking and other system functionality such as hardware. Not to fear- enabling network connectivity for our app is as simple as ensuring the checkbox is set correctly in Signing & Capabilities for the target.

We need to ensure that Outgoings Connections are allowed for our macOS app

Run again and we should see the familiar Tube Status view:

Tube Status app running on macOS

As with all the other platforms, this should fully support Dark Mode, Voiceover, etc. when set within macOS System Preferences.

There are plenty of additional features we could implement for macOS.

In Total

There are so many further tweaks and improvements we can make, but using SwiftUI we’ve managed to very quickly add support

Overall, we’ve implemented apps that work across:

  • All four distinct Apple platforms with their individual nuances
  • VoiceOver and dynamic type
  • Quick access to information through widgets on iOS, macOS and watchOS complications
  • Both dark and light mode

Sample code for this post is available on GitHub.
Make sure to checkout the going-for-gold branch of the repository!

Enjoyed this article?
Share it to your network, and let me know what you thought!

Constructing Data with Swift Function Builders

When Apple introduced SwiftUI in 2019, they showed how Swift 5.1’s function builders could be used to quickly, and readably, build user interfaces containing a wide range of elements. As developers we can use function builders ourselves when building arrays of objects in our code.

In iOS apps we often use UIAlertController to display error messages. I often see the following extension on UIViewController, allowing alerts to be easily presented throughout the app.

extension UIViewController {
    func presentAlert(title: String, message: String,
                      actions: [UIAlertAction]) {
        let alert = UIAlertController(title: title, message: message,
                                      preferredStyle: .alert)
        actions.forEach { alert.addAction($0) }
        present(alert, animated: true)
    }
}

Often this results in some logic at the call site when there are alerts that change depending on the message being displayed. For example, we may want to handle an error that comes back from one of our API calls. In this case, the error may either be resolvable or not depending on its HTTP status code. If it is a resolvable error we want to give the user the option to retry.

func handleAPIError(_ error: NetworkError) {
    var actions = [UIAlertAction(title: "Cancel", style: .cancel)]
    let errorIsResolvable = (500...599).contains(error.code)
    if errorIsResolvable {
        actions.append(
            UIAlertAction(title: "Retry", style: .default) { _ in
                retryRequest()
            }
        )
    }
    
    navigationController?.presentAlert(title: "An Error Occurred",
                                       message: error.localizedDescription,
                                       actions: actions)
}

This logic isn’t too complex, but if we were to have a number of conditions or actions to append it can become messy very quickly. To prevent this, we could create a function builder for in-place of the array of actions that we were originally passing in.

This will allow us to add actions, or not, in the same way that we do when building our SwiftUI views.

@_functionBuilder
public struct UIAlertActionBuilder {
    public static func buildBlock() -> [UIAlertAction] {
        []
    }

    public static func buildBlock(_ elements: UIAlertAction...) -> [UIAlertAction] {
        elements.compactMap { $0 }
    }

    public static func buildBlock(_ elements: [UIAlertAction]...) -> [UIAlertAction] {
        elements.flatMap { $0 }
    }

    public static func buildIf(_ elements: [UIAlertAction]?) -> [UIAlertAction] {
        elements ?? []
    }
}
extension UIViewController {
    func presentAlert(title: String?, message: String?,
                      @UIAlertActionBuilder actions: () -> [UIAlertAction]) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        actions().forEach { alert.addAction($0) }
        present(alert, animated: true)
    }
}

Our original logic can now become much more readable (and declarative).

func handleAPIError(message: String, code: Int) {
    let errorIsResolvable = (500...599).contains(code)
    navigationController?.presentAlert(title: "An Error Occurred",
                                       message: message) {
        UIAlertAction(title: "Cancel", style: .cancel)
        if errorIsResolvable {
            UIAlertAction(title: "Retry", style: .default) { _ in
                retryRequest()
            }
        }
    }
}

Without much change, we can even take this one step further by creating a generic ArrayBuilder. This will allow us to build arrays of any type that can be useful in all sorts of places in our codebase.

@_functionBuilder
public struct ArrayBuilder<T> {
    public static func buildBlock() -> [T] {
        []
    }
    
    // ... etc.
}

We can also implement the methods for buildEither so that we can support else branches when building arrays.

public static func buildEither(first: [T]) -> [T] {
    first
}

public static func buildEither(second: [T]) -> [T] {
    second
}

In order to use this new ArrayBuilder in place of our UIAlertActionBuilder.
We would simply use the following snippet.

func presentAlert(title: String?, message: String?,
                  @ArrayBuilder<UIAlertAction> actions: () -> [UIAlertAction])

As you can see, function builders can be an invaluable tool to help create lists of data cleanly in Swift. There are often places in our apps where elements may or may not appear.

It’s helped me to massively reduce the amount of code needed, for example in Settings menus, for some of my apps: where certain settings are shown or hidden depending on others and the profile of the user who views the page.


Get in touch?

Building (almost) anything on Bitrise using Docker

Bitrise is gaining a lot of users in the mobile development community- but did you know you can also use it as a CI tool for non-mobile projects too?

For Little Journey we use Bitrise to build our full stack of applications including front-end (Angular) and back-end (Vapor) web. The web applications are both deployed to a Linode server.

Docker 18.09 came with a new tool called BuildKit which allows you to export executables built inside a container so that they can be deployed directly on the target platform.

DOCKER_BUILDKIT=1 docker build --output type=tar,dest=release.tar .

By setting the DOCKER_BUILDKIT variable, we can output a tarfile containing the build artifacts we need, such as executables or compiled code.

In order to do this, we need to create a Dockerfile with a multi-stage (two stages) build. The first stage will install any dependencies and run the build. The second stage copies our build files into a clean container so that we can export just the build files that we need, rather than the whole codebase.

FROM swift:xenial AS build-stage
WORKDIR /root
COPY . .
RUN apt-get -qq update && apt-get install -yq libssl-dev libicu-dev
RUN swift build -c release

FROM scratch AS export-stage
# Vapor Swift stores build files in .build/release
COPY --from=build-stage /root/.build/release /

For our front-end Angular app, the deployment is now as simple as transferring this tar file to the relevant folder on our Linode server and decompressing it by running tar -xvf release.tar from the command line.

For our Vapor API, we can ensure that the executable is built in a container that matches our server (Ubuntu 16.04 LTS) so that it will run correctly when deployed to the server, even though we are building it using a macOS agent on Bitrise.

Docker is also great for running our tests so that we can be sure they all pass before each pull request gets merged into our main codebase.

Multi-stage Docker builds are also great for doing this. We can add a test-stage between our initial setup / build and export stages.

If required, it’s also possible to add additional phases for different release environments (such as pre-production) environments if different build configurations are required.

# Create a Lightweight Node environment with our 
# dependencies to use as a base container
FROM timbru31/node-alpine-firefox AS base
WORKDIR /root
COPY . .
# Skip Chromium download as we use Firefox for testing
RUN npm config set puppeteer_skip_chromium_download true -g
# Install our dependencies
RUN npm install

# Create a test container (on top of base), set path to Firefox
# Run lint to ensure code-style and run the unit tests
FROM base AS test
ENV FIREFOX_BIN=/usr/bin/firefox
RUN npm run-script lint && npm test

# Create a build container (on top of base) and run the build
FROM base AS prod
RUN npm run-script build-prod

# Create an empty container copying the output
FROM scratch AS build-prod
COPY --from=prod /dist/little-journey /

We can now pass a stage target parameter into Docker to either

  • run the tests for pull requests:
    docker build --target test .
  • build an executable on code-merge:
    DOCKER_BUILDKIT=1 docker build --output type=tar,dest=release.tar --target build-beta .

Want to know more?

I’d recommend this article from @ZachSimone on how to deploy a Vapor app to a Linode Ubuntu server.

Check out the full documentation on Docker BuildKit and Bitrise.

We use these techniques for building our front-end (Admin Panel) and back-end web applications at Little Journey. Little Journey is an interactive, virtual reality (VR) mobile app designed to prepare children aged 3 to 12 years for day-case surgery.

Create a Tube Status home-screen Widget for iOS 14

One of iOS 14’s most exciting changes for both users and developers will undoubtedly be it’s addition of widgets for quick access to information directly from the home-screen without having to open the app. This type of functionality has previously been limited to just two first-party applications (Calendar and Clock).

London Underground status home-screen widgets on iOS 14

In this article, I will detail how to quickly and easily create a home-screen widget for your app, using the London Underground status board as a real-world example. Transport for London provides a free-to-use Open API (https://api.tfl.gov.uk) we can easily utilise that will allow us to get up-to-date status for all its services.

Sample code for the project is available on GitHub:


Getting Started

Requirements

Developing for iOS 14 requires Xcode 12. Xcode 12 will run on any Mac which is running either macOS 10.15 (Catalina) or macOS 11.0 (Big Sur). You can download it from the beta software tab on the Apple Developer Downloads page: https://developer.apple.com/download/

PSA: iOS 14 is currently in early beta.
Do not install it on your main device, especially one you rely on.

You can run widgets in the simulator, but to take full advantage of this tutorial, including supporting multiple widgets and intents it’s best to have a device running iOS 14.

Project Setup

Widgets cannot be provided standalone, so we will need to start with a boilerplate app. Here we will setup a new Xcode project and produce a basic app. If you are a seasoned app developer, you may want to skip to the “Your First Widget” section.

In order to get started, let’s select “Create a new Xcode project” from the Xcode launch screen. Select app, then next.

Xcode 12 Launch window

WidgetKit requires “SwiftUI” as the interface so we will use this for our app too.

  • Enter a name for your project
  • Enter your own name, or company name under “Organization Identifier”.
  • Select “SwiftUI” to use for the “Interface”
  • Use “SwiftUI App” as the Lifecycle
  • Use “Swift” as the language.

This article won’t go into detail about how to test your app, but feel free to leave the “Include Tests” box checked.

Then, finally, choose a location to store your app on your Mac.
Xcode will generate an app template containing YourProjectApp.swift.

Architecture

In order to share code between the main-app and widget, we can create a shared framework in Xcode. This isn’t completely necessary for a simple app such as this, but it’s good practice as you may want to share logic further if you are implementing other features such as Siri Shortcuts or iMessage apps.

Architecture of London Underground app

Create a new framework in Xcode, go to File → New → Target… and select Framework. Some common product names for shared frameworks are “Core” or “Shared”. From now on, it’s easiest to create all new source files within this new shared framework target.

Model

We need to create a model to represent our data. Using Swift’s Codable protocol we can automatically decode the JSON response from the TfL API.

public struct LineStatusUpdate: Identifiable, Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case line = "name"
        case statuses = "lineStatuses"
    }
    public let id: String
    let line: Line
    let statuses: [StatusUpdate]
}

In order to keep our variable names “Swifty”, we can use coding keys to map from the response that the API returns:

{
    id: "bakerloo",
    name: "Bakerloo",
    lineStatuses: [ ... ]
}

The full model used to build the app can be found in the sample code, linked at the bottom of this article.

Networking

We can implement a NetworkClient to handle our API calls. We will use URLSession from the Foundation library to do this.

import Foundation

public final class NetworkClient {
    private let session: URLSession = .shared

    enum NetworkError: Error {
        case noData
    }

    func executeRequest(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void) {
        session.dataTask(with: request) { (data, response, error) in
            if let error = error {
                completion(.failure(error))
                return
            }
            guard let data = data else {
                completion(.failure(NetworkError.noData))
                return
            }
            completion(.success(data))
        }.resume()
    }
}

Create a new service to call the NetworkClient and decode the API data into the model we implemented earlier.

import Foundation

public struct StatusService {
    public static func getStatus(client: NetworkClient, completion: (([LineStatusUpdate]) -> Void)? = nil) {
        runStatusRequest(.lineStatus, on: client, completion: completion)
    }

    private static func runStatusRequest(_ request: URLRequest,
                                         on client: NetworkClient,
                                         completion: (([LineStatusUpdate]) -> Void)? = nil) {
        client.executeRequest(request: request) { result in
            switch result {
            case .success(let data):
                let decoder = JSONDecoder()
                do {
                    let lineStatus = try decoder.decode([LineStatusUpdate].self, from: data)
                    completion?(lineStatus)
                } catch {
                    print(error.localizedDescription)
                }
            case .failure(let error):
                print(error.localizedDescription)
            }
        }
    }
}

Views

SwiftUI is great for quickly creating user interfaces for your application.
The app template will already include a ContentView, but we can create new views within the shared framework. As long as we declare that these views are public, they will be accessible from both the application and (later on!) our widget extension.

public struct LineStatusView: View {
    let update: LineStatusUpdate
    var body: some View {
        HStack {
            Text(update.line.displayName)
                .font(.subheadline)
                .fontWeight(.medium)
                .padding()
                .background(update.line.color)
            if let status = update.statuses.first {
                Text(status.type.rawValue)
                    .font(.subheadline)
                    .padding()
            }
        }
    }
}

This code creates a view showing the name of the tube line and the first status that is returned by the API.

Make sure to import the shared framework at the top of the file when you want to use this new view.

import Shared

LineStatusView(update: update)

You can use this new view in the content view of your app to display the status of each of the lines when the app is opened. Now we have a simple working app, we can look at displaying the view on the home-screen too!


Your First Widget

Create the Target

Let’s begin implementing our widget. First, we need to create a new target. In Xcode, go to File → New → Target… and then select the Widget Extension template. Enter a name for your Widget and uncheck “Include Configuration Intent”- we will not need this for our first, basic widget.

Xcode has now created a template widget for us. There are boilerplate implementations of TimelineProviderTimelineEntry and Widget that we can add our implementation to. From now on, create the new source files within the newly created widget folder.

Timeline Provider & Timeline Entry

The timeline provider works on a similar concept to CLKComplicationDataSource which is used for creating complications for the Apple Watch. We can provide an array of data entries from the current time into the future. This is useful for apps, such as the weather app which can provide entries for future forecasts in one go, reducing amount of refreshes that are needed. For our transport status app, we are obviously unable to predict future disruption so we will only provide one entry- the current status.

The TimelineEntry for our status update can use the models from our shared library that we created above:

struct StatusUpdateEntry: TimelineEntry {
    let date: Date?
    let updates: [LineStatusUpdate]
}

The TimelineProvider protocol requires us to implement two methods:

The snapshot method is called when we need to provide a short-lived display of our widget, such as from the widget selection menu. The Apple documentation suggests that sample data is used here, if required, to allow the snapshot to return as quickly as possible.

The timeline method is called when the widget is being displayed normally, on the home-screen of the device. We need to return a timeline of entries. In our transport status example, we are only able to return a single entry. We can set the expiry date of the timeline to be two minutes into the future so that we are regularly refreshing the widget with the latest up-to-date information.

public func timeline(with context: Context,
                     completion: @escaping (Timeline<Entry>) -> ()) {
    // Fetch the latest travel information from the API
    StatusService.getStatus(client: NetworkClient()) { updates in
        let entry = SimpleEntry(date: Date(), updates: updates)
        // Refresh the data every two minutes:
        let expiryDate = Calendar
            .current.date(byAdding: .minute, value: 2, 
                          to: Date()) ?? Date()
        let timeline = Timeline(entries: [entry], 
                                policy: .after(expiryDate))
        completion(timeline)
    }
}

Widget

The widget implementation uses the @main property wrapper, new in Swift 5.3, to mark it as the entry point to our widget.

We provide a WidgetConfiguration as the body for this. The widget configuration requires a placeholder view, which is displayed while our widget is loading, and the timeline provider we discussed above.

We can use modifiers to specify name and description that will be shown to the user when they are selecting a widget to add to their home-screen. We can also specify which widget sizes we support. In the case of our Tube Status widget, as it will contain quite a lot of information, we should add .supportedFamilies([.systemLarge]).

We build the view inside the widget configuration in the same way as we build our WindowGroup in the main app.

struct AllLinesWidget: Widget {
    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: "All Lines",
                            provider: AllLinesProvider(),
                            placeholder: AllLinesPlaceholderView()) { entry in
            ContentView(updates: entry.updates)
        }
        .configurationDisplayName("Tube Status")
        .description("See the status board for all underground lines")
        .supportedFamilies([.systemLarge])
    }
}

Multiple Widgets

Widget Bundle

We can provide multiple types of widget using the WidgetBundle protocol. This is fairly simple to use, we can just build our group of widgets in the same way we build a SwiftUI view.

@main
struct TubeWidgets: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        AllLinesWidget()
    }
}

When you add main here, make sure you remove it from the widget itself- otherwise the Swift compiler won’t be able to work out which is the correct entry-point for our app.

We can add as many different types of widget as we like here:

var body: some Widget {
    MyFirstWidget()
    MySecondWidget()
    MyThirdWidget()
}

Providing Customisation

Intents

The above example works well if we want to display status information for all lines, but what if our user only travels on one particular line? We could create a new widget for each line, but this quickly becomes messy and our widget selection menu would require the user to swipe through a lot of lines to find the one that they are interested in. And what about if we wanted to also provide widgets for buses, DLR and other transport modes?

Using Intents to select a Tube Line

We can use Intents to allow the user to configure a single widget for the line they want to see. Using WidgetBundles, we can keep our original status widget and implement an additional widget using an intent with IntentTimelineProvider.

Let’s start by creating the Intent. If you have implemented something using Siri Shortcuts before, you will likely already be familar with intents.

Create a new intent definition file: File → New → File → SiriKit Intent Definition File. Ensure that this file is included in both the app target and the widget extension.

We can create a new enum datatype to hold the different Line options:

Create an enum for the different Tube line options using the visual editor

The unknown case is not displayed in the picker, but we can use it later for specifying a default value.

Next we need to create a new Custom Intent:
Add a title and description so that the user knows what they are choosing.
The Intent needs to be eligible for widgets, but Siri Shortcuts and Suggestions are not needed for this.

Create a custom intent to allow the user to select a Line for the widget

We also need to declare the intent in the application’sInfo.plist file, otherwise it won’t be able to load in the “Edit Widget” modal.

<key>NSUserActivityTypes</key>
<array>
    <string>LineSelectionIntent</string>
</array>

Intent Timeline Provider

In order to support intents, we need to conform our timeline provider to IntentTimelineProvider rather than TimelineProvider. This protocol is very similar but passes the intent into snapshot and timeline methods.

We can use this intent parameter to retrieve the user parameters that have been specified.

public func snapshot(for configuration: LineSelectionIntent,
                     with context: Context,
                     completion: @escaping (SimpleEntry) -> () {
    let line = self.line(for: configuration)
    StatusService.getStatus(client: NetworkClient(), for: line) {
        let entry = LineStatusUpdateEntry(date: Date(), 
                                          line: line,
                                          updates: updates)
        completion(entry)
    }
}

A simple line method will be able to map from the autogenerated Intent enum to the Line enum in our model:

func line(for configuration: LineSelectionIntent) -> Line {
    switch configuration.line {
        case .circle:
            return .circle
        case .district:
            return .district
        ...
    }
}

Intent Configuration

We can now set up our new widget with an IntentConfiguration. This is similar to the WidgetConfiguration we have used previously but requires us to specify the Intent type.

struct SingleLineWidget: Widget {
    public var body: some WidgetConfiguration {
        IntentConfiguration(kind: "Single Line",
                            intent: LineSelectionIntent.self,
                            provider: SingleLineProvider(),
                            placeholder: SingleLinePlaceholderView()) { entry in
            ...
        }
        .configurationDisplayName("Line Status")
        .description("See the status for a specific London Underground line")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

More Resources

Sample code available on GitHub:

Other information:

If you enjoyed this article, please do let me know- this is the first technical blog post I’ve ever written so I’d love to receive some feedback!