Magazine

Lock Screen Widgets in SwiftUI

Get ready for the new release of iOS 16 and watchOS 9 with this lock screen widgets tutorial.

Introduction 👋

With many exciting new features coming to iOS this fall, the one that stuck with me as an iOS developer is bringing widgets to the lock screen. Home screen widgets made timely data more visible for the users, and now iOS is taking it one step further with them coming to the lock screen. I always felt like that was the missing piece on iOS devices – there’s just something about glancing at the lock screen and being up to date with your favorite apps.

So let’s see how we can extend our apps with some shiny new widgets 🤩.

Lock Screen Widgets 🔒

Okay, what’s it really all about? Complications from watchOS have come to iOS in the form of new Lock Screen Widgets. This also means that if we conform to a set of styling rules, we can write code for our widgets once and use it on different devices. The framework used for creating widgets in SwiftUI is called WidgetKit. You might be familiar with it, it’s been around since 2020, and the release of iOS 14. But now, the original widget family (as Apple calls it) has been extended, with new nuances.

There are 3 new types of Widgets added to the original Widget Family:

  • accessoryRectangular, which shows multiline text or even smaller graphs
  • accessoryCircular, used for showing progress views and gauges
  • accessoryInline, which presents text only, and is useful for somewhat longer content
Highlighting different types of widgets on the iPhone lock screen.

accessoryCorner also exists, but as that one is solely used on watchOS, we’ll save it for another time.

They can be rendered in 3 different coloring styles. But on iOS, to fit the lock screen appeal, it’s only possible to use the desaturated version on our lock screen widgets. We can check the look and visibility of our widgets in Xcode’s Live View or build them directly on the Simulator. Since widgets exist in a separate scheme, it’s easy to save time and only rebuild them while working on them, but we’ll talk about that in the tutorial section, later on.

We’ve mentioned some rules so let’s talk about them. Well, they’re not really rules, but just a couple of things to keep in mind while designing widgets, to make them more appealing and easy to use.

Be concise

The space we get for our widgets is quite limiting. We should always be brief and show only essential information. Think of it as a good thing. You don’t need to show a lot, like the title of the app or any text for that matter. Take Apple’s fitness widget, for example. It only shows 3 concentric progress circles with no text or numbers, but the users know exactly which circle shows what data.

Additionally, wrapping the widget’s content in ViewThatFits { ... } can be useful, especially when avoiding automatic ellipsis on a longer text.

Keep the design in mind

Users will combine different apps’ widgets on their lock screens, so you should stick to Apple’s styling rules when designing the widgets. They expect specific information when seeing bold text or a vertical line next to the content, so do your research and use the advantage of default font parameters and styles offered by SwiftUI.

Comparing the Apple Watch and iPhone widgets.
Source: Apple Developer.

To apply these stylings, we use modifiers like:

Text("I'm bold and colorful on the watch, and only bold on the phone")
    .font(.headline)
    .widgetAccentable()

Test your widgets with colorful backgrounds, which can make content less visible. We can take advantage of AccessoryWidgetBackground() here which gives us that nice native opaque background.

Connect your widgets and apps

Lock screen widgets act quite similar to watchOS complications so they’re not interactable. They’re meant to show glanceable content from your app, not have tappable elements. When tapped, they enter the app on the currently open screen or, if implemented, deep link into a specific view inside the app. I won’t get into details about deep linking because that’s a whole other blog post, but once you have it configured for your app, you can just add .widgetURL(entry.url) to your widget’s view and it will work like a charm.

Privacy

Since we’re presenting data on the lock screen, we need to be cautious about what exactly is presented. Widgets show unredacted data on the locked screen by default, which poses the danger of exposing users’ personal and sensitive data.

How can we implement sensitive data redaction when the screen is locked, you may ask? Let’s say we’re showing some sensitive data in a rectangular widget. We can present the placeholder while the screen is locked, and vice versa:

Privacy widgets on a locked and unlocked screen.

This is applicable to other widget formats as well. To achieve this in SwiftUI, simply add the .privacySensitive() modifier to the view. You can mark the sensitive data only, like text, and leave the icons shown for example. This masked view can also be used as a skeleton view while the data is loading.

Stick around to see how to achieve this in the code ⌨️.

Tutorial 📚

Setup 🔧

Add a new target to your project and find Widget Extension.

Widget Extension template in Xcode.

Give the widgets target a name. For example, my app is called Sparkle, so my target will be named SparkleWidgets.

Now open the SparkleWidgets.swift file. This is where all of the widget-related code can be found.

To briefly explain the auto-generated code in the file, we can see:

  • Timeline Provider, which takes care of refresh timings for the widget
  • Entry View, used for rendering UI for widgets, similar to SwiftUI views you’re probably already familiar with
  • Widget Configuration, which connects provider configuration with the view, and sets some configurations for the widget like types of widgets we support, display names, etc.
  • Preview Provider, Xcode’s live preview tool

If you prefer, you can extract these structures into separate files to make the project a bit cleaner.

Implementation 🚧

There are 3 types of lock screen widgets, and we want to decide how each of our widgets renders as well as what data it shows. The most common option to achieve this is using a switch case statement in the view’s body like this:

struct SparkleWidgetsEntryView : View {
    @Environment(\.widgetFamily) var widgetFamily
    
    var body: some View {
        switch widgetFamily {
        case .accessoryCircular:
            VStack {
                Gauge(value: 0.75) {
                    Text("3/4")
                }
                .gaugeStyle(.accessoryCircularCapacity)
            }
        case .accessoryInline:
            Text("3 undone todos")
        case .accessoryRectangular:
            VStack {
                HStack {
                    Image(systemName: "square")
                    Text("Pick up the milk")
                        .font(.headline)
                        .widgetAccentable()
                }
                HStack {
                    Image(systemName: "square")
                    Text("Take out the trash")
                }
                HStack {
                    Image(systemName: "square")
                    Text("Feed the cat")
                }
            }
            .privacySensitive()
        default:
            Text("Not yet implemented")
        }
    }
}

You’ll need the widgetFamily environment value to get this done. You probably noticed the Gauge view, which is new for the iOS widgets. It has an array of styles to pick from and usually shows a range or progress for something from our app.

Passing data between the apps 📦

Ideally, we’d want to show some sort of dynamic data on our widget, with no particular use of static text.

So let’s add one more spice to the mix: let’s share data from our main app with the widget itself. This can be done in multiple ways, but it’s recommended to use App Groups and UserDefaults.

App Groups create shared storage for a single development team, to use between our apps.

Here’s how:

Select your main target and navigate to the Signing & Capabilities tab. Then press the + Capability button in the upper left corner and search for the App Groups option. This should appear in your Xcode window:

App Groups section in Xcode.

Add a new container using the plus button. You must name it using this format: group.your_app’s_bundle_id. You can find your bundle id by quickly switching to the General tab. Select the checkbox in the App Groups section back at the Signing & Capabilities tab and the setup is finished.

Let’s get the coding done 🚀. In your main app, wherever it’s convenient regarding your business logic, save the data into our newly created shared data container:

UserDefaults(suiteName: "your_app_group_container_name")!.set(firstThreeTodos, forKey: "todo")

I’m using an array of strings since my widget is showing the top 3 most urgent undone tasks from my todo app, but any type of data can be used here, depending on your logic.

Time for the other side – the widget. Again, whichever place in your code makes sense, you’d use the following line to retrieve data sent from the main app:

UserDefaults(suiteName: "your_app_group_container_name")!.object(forKey: "todo") as? [String] ?? []

I put an empty array as a default value because it allows me to handle an empty state with a UI of convenience on the lock screen.

Disclaimer: if you are using custom types for the passing data, it won’t be visible in the widget’s scheme. To make the class visible, open the class/struct file in Xcode and open the right sidebar. Then navigate to the first tab called Identity and Type and make sure to check the box next to the widget target under the Target Membership section. Let’s say I decided to use my custom Todo type instead of a string. Then my Todo.swift file would have the following membership:

Target Membership section in Xcode.

After the embellishments are applied, my accessoryRectangular widget code now looks something like this:

case .accessoryRectangular:
            VStack (alignment: .leading) {
                if !todos.isEmpty {
                    ForEach(todos, id: \.self) { todo in
                        HStack {
                            Image(systemName: "square")
                            Text(todo)
                                .widgetAccentable()
                                .privacySensitive()
                        }
                    }
                } else {
                    Text("✨ No tasks for you\n✨ Enjoy your day")
                }
            }

And the widget looks something like this:

Widget example on the lock screen.

Let’s implement the circular widget as well. I’ll be using the previously mentioned Gauge view to show my “done to all todos” ratio. We have to divide the finished todo by the total number of todos. Since our gauge is used as a progress circle, that result will be the gauge’s value. And for the textual part, we’ll show it as a fraction. The code would look like this:

case .accessoryCircular:
    VStack {
        Gauge(value: todosRatio) {
            Text("\(doneCount)/\(todoCount)")
        }
        .gaugeStyle(.accessoryCircularCapacity)
    }
Progress bar example on the lock screen.

If you wanted, let’s say a linear progress bar, you’d use .accessoryLinearCapacity.

Refreshing the widget 🔄

So, we have our data passed between the apps, amazing🏅. But how do we trigger reloading the data on our widget to keep the data up to date?

In my case, I like to do the update each time my todos list changes in any way: edit, delete, you name it.

This is where the WidgetCenter class from WidgetKit comes in, more precisely, the WidgetCenter.shared.reloadAllTimelines() function. Executing this line will do exactly what the name says. Alternatively, you can specify which timeline to reload using reloadTimelines(ofKind: String).

Let’s run this thing on the device and see what we came up with:

Animation of how a sample app runs on the iPhone.

Et voila 🪄. You can see how the widgets change as our data inside the app is modified.

Where to go from here? 🚶🏻

There are several things to try out next. For example, you can try and build our newly created widgets as Apple Watch complications. Or implement the third widget type, accessoryInline.

I’d also encourage you to check out the new lock screen Live Activities as well. They combine the WidgetKit we just used with the ActivityKit framework and can be logically combined with our lock screen widgets.

Thanks for reading. Have fun! 😊

Leave a comment Be the first!