12.
SwiftUI Complications
Written by Scott Grosch
For most complications, you’ll use the templates you learned about in Chapter 8: “Complications Introductory”. Sometimes, however, you’ll want greater control over the design. watchOS lets you design some complications with SwiftUI.
Graphs are, of course, a great use case for SwiftUI-based complications. That’s no fun, though, so you’re going to show the next calendar entry for today.
Showing an appointment
Open CalendarComplication.xcodeproj from this chapter’s starter materials. EventStore.swift reads the local calendar via EventKit, as well as EventView.swift, which is the view you’ll modify.
Build and run the project on a physical device. Your watch will ask you to grant calendar permissions, which of course, you must agree to for the project to work.
Note: You must use a physical device because the Simulator doesn’t include a calendar. Also, EventKit will only display calendar items from your local calendar, not calendars in the cloud.
Event display
Edit EventView.swift, the view you’ll see in the complication. I already performed the pieces related to EventKit to save you some time. Your goal is to create a display similar to the one Apple provides for their Calendar app.
Find the “Hello, World!” Text line and replace it with an HStack that contains a vertical line:
HStack {
// 1
RoundedRectangle(cornerRadius: 3)
// 2
.frame(width: 5)
// 3
.foregroundColor(Color(event.calendar.cgColor))
}
In the preceding code:
- You draw a rectangle with a small corner radius, so the edges are slightly rounded.
- By making it five pixels wide, you essentially create a vertical line.
- EventKit provides the calendar color as a
CGColor, which you then convert to a SwiftUIColor, using that for the line’s color.
Calendar appointments usually show a time range instead of just the start time. To format the dates, add a new property to the top of the struct:
// 1
private let formatter: DateIntervalFormatter = {
let formatter = DateIntervalFormatter()
// 2
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
// 3
}()
That pattern might look a bit strange to you if you’re new to Swift. Here’s what’s happening:
- You let the compiler know that you’re creating a property of type
DateIntervalFormatter, which you then configure. - The formatter won’t show dates and will use a short time style, like 2:00 pm.
- The
}()closes the configuration, runs it and then assigns the result to the formatter. This type of structure lets you perform the necessary configuration for a property right with the initialization.
Excellent. Now you’re ready to show the dates. Inside the HStack, after the rectangle, show the details of the calendar item:
// 1
VStack(alignment: .leading) {
// 2
Text(formatter.string(from: event.startDate, to: event.endDate))
.font(.subheadline)
// 3
Text(event.title)
.font(.headline)
// 4
if let location = event.location {
Text(location)
.font(.subheadline)
}
}
Pretty standard SwiftUI there:
- Creating a
VStackwith the.leadingalignment ensures the start of the text items will line up. By default, the items would be centered. - You show the date range for the appointment in a
.subheadlinefont for a slightly smaller size. - Using a
.headlinefont makes the title slightly larger than the date range. - Locations are optional, so you only show the location if one is set.
If you have the Canvas displayed, you’ll notice that you don’t see the appointment due to calendar permissions. That’s not ideal.
Build and run the app. As long as you’ve created an appointment on your local calendar, the one labeled ON MY IPHONE, you’ll see the event:
Event refactoring
Right now, the code has multiple issues. Not only are you unable to preview the complication, but also everything is tightly tied to EventKit. What happens when you work with CalDAV or any of the other calendaring platforms?
Creating an Event type
Create a new file named Event.swift and paste in:
import SwiftUI
import EventKit
// 1
struct Event {
let color: Color
let startDate: Date
let endDate: Date
let title: String
let location: String?
// 2
init(ekEvent: EKEvent) {
color = Color(ekEvent.calendar.cgColor)
startDate = ekEvent.startDate
endDate = ekEvent.endDate
title = ekEvent.title
location = ekEvent.location
}
// 3
init(
color: Color,
startDate: Date,
endDate: Date,
title: String,
location: String?
) {
self.color = color
self.startDate = startDate
self.endDate = endDate
self.title = title
self.location = location
}
}
There’s nothing magical in the preceding code:
- You’ve created a custom type to hold your calendar events.
-
EKEventis a common use case, so providing a constructor that takes that type simplifies the rest of the codebase. - Sometimes, especially for previews, you’ll want to specify the different values manually.
If your app expands in the future to include other calendar types, you would simply add a new initializer to this file.
Notice how you’re expecting callers to pass you a Color, not a CGColor. Your use case is currently for events, which use CGColor, but you may be passing events from a web download, for example.
Refactoring the view
Sometimes you find that you’ve named a view poorly and need to fix it. In this case, EventView should really be EventComplicationView because you need an EventView for previewing the event.
Perform the following steps:
- Open EventView.swift.
- Right-click the
EventViewname, then choose Refactor ▸ Rename… - Place the mouse just before the V and type Complication so that the view is named EventComplicationView, then press Enter. With that step, you rename all instances of the view across the entire project as well as the filename.
- You’ll need to manually rename
EventView_PreviewstoEventComplicationView_Previews. - Select the entire
HStackand press Control-X to cut the code and add it to the clipboard. - Create a new SwiftUI View named EventView.swift and paste the
HStackas the contents of the body, replacingText("Hello, World!").
At this point, Xcode isn’t happy because it doesn’t know what event means. Add the following property:
let event: Event
Then, replace Color(event.calendar.cgColor) with event.color.
Next, move the DateIntervalFormatter from EventComplicationView.swift to EventView.swift.
Your refactored view is now complete, with the exception of fixing the EventView_Previews. Add a static property to represent an Event:
static var event = Event(
color: .blue,
startDate: .now,
endDate: .now.addingTimeInterval(3600),
title: "Gnomes rule!",
location: "Everywhere"
)
Since this is only for a development preview, it’s OK to skip the standard calendrical calculation methods and directly add one hour to the current time.
You want to see the view as it will appear on a complication. So, import ClockKit at the top of the file:
import ClockKit
Then replace the previews body with:
Group {
EventView(event: event)
CLKComplicationTemplateGraphicRectangularFullView(
EventView(event: event)
)
.previewContext()
}
If it’s not already showing, bring up the canvas by pressing Option‑Command‑Enter. The canvas will now show what the view looks like by itself and how it appears on the watch face:
Now that you know the display looks the way you wanted, switch back to EventComplicationView.swift.
Add a new property to the view:
let event: Event?
Find the line where the Stack used to be, plus the if check that went with it:
} else if let event = eventStore.nextEvent {
Replace with these lines:
} else if let event = event {
EventView(event: event)
You’re simply passing the event-specific details to the view that shows them properly. In EventComplicationView_Previews, pass nil to make the compiler happy:
EventComplicationView(event: nil)
Now, build the app to ensure you don’t have any compiler errors from missed steps.
Whoops! You didn’t fix CalendarComplicationApp.swift to use the new complication view as the entry point instead of EventView. It’s definitely not because I forgot to tell you to do so. :]
OK, replace:
EventComplicationView()
With:
EventComplicationView(event: nil)
Now the app builds without errors. If you run the app right now, you’d always see a message saying there were no more events for today. Why? You just told CalendarComplicationApp to pass nil for the event. Remember, this app is all about the complication, so you don’t really care about what displays if you run the app.
The event complication
It’s finally time to use your SwiftUI view in a complication.
This example uses the .graphicRectangular family since it works well for a calendar display. To keep the example focused, you won’t implement any other complication families.
Event to timeline entry
All the complication methods need to be able to create a CLKComplicationTimelineEntry from an EKEvent. So, add the following method to ComplicationController.swift:
private func timelineEntry(for ekEvent: EKEvent?) -> CLKComplicationTimelineEntry {
// 1
let event: Event?
if let ekEvent = ekEvent {
event = Event(ekEvent: ekEvent)
} else {
event = nil
}
// 2
let template = CLKComplicationTemplateGraphicRectangularFullView(
EventComplicationView(event: event)
)
// 3
return .init(
date: event?.startDate ?? .now,
complicationTemplate: template
)
}
The code creates your timeline entry:
- First, you convert the
EKEventyou have to anEventsince that’s what your views expect. Remember, if you passnilto theeventparameter of theEventComplicationViewinitializer, it will display a message saying there are no more events. -
CLKComplicationTemplateGraphicRectangularFullViewis one of the template types which expects you to give it a SwiftUI view to use as a template. - You generate a
CLKComplicationTimelineEntrybased on the event’s start date and the SwiftUI template. If there isn’t an event, then use the current date.
Since you’re using EKEvent, you’ll need to import EventKit at the top of the file:
import EventKit
Localizable sample
It’s important to have a sample complication for users to see when they’re choosing complications. Provide one with the following delegate method, still in ComplicationController.swift:
func localizableSampleTemplate(
for complication: CLKComplication
) async -> CLKComplicationTemplate? {
// 1
let start = Calendar.current.date(
bySettingHour: 10, minute: 0, second: 0, of: .now
)!
// 2
let end = Calendar.current.date(
byAdding: .hour, value: 1, to: start
)!
// 3
return CLKComplicationTemplateGraphicRectangularFullView(
EventView(event: .init(
color: .blue,
startDate: start,
endDate: end,
title: "Gnomes rule!",
location: "Everywhere"
))
)
}
Here you:
- Create a
Dateentry for the current day at 10:00 am. Recall that your date display doesn’t include the day, so using today is OK. - Add one hour to the start time to indicate when the event ends.
- Create a
CLKComplicationTemplateGraphicRectangularFullViewwith some fake data to display in the sample template.
The current appointment
Just like when using non-SwiftUI templates, you have to provide the current timeline entry if one exists. Replace the body of currentTimelineEntry(for:) with:
return timelineEntry(for: EventStore.shared.nextEvent)
Notice how even if there’s not an event, you don’t return nil. If you return nil, you don’t get an actual display for the complication, which isn’t what you want. If there’s no event, you still want the “No more events” message.
Future appointments
If your work calendar is anything like mine, you have way more than one event every day. You’ll want to provide future events like you did in previous chapters.
Paste the following method into your code:
func timelineEntries(
for complication: CLKComplication,
after date: Date,
limit: Int
) async -> [CLKComplicationTimelineEntry]? {
// 1
guard let events = EventStore.shared.eventsForToday() else {
return [timelineEntry(for: nil)]
}
let wanted = events
// 2
.filter {
date.compare($0.startDate) == .orderedAscending
}
// 3
.prefix(limit)
// 4
.map { timelineEntry(for: $0) }
// 5
return wanted.count > 0 ? wanted : [timelineEntry(for: nil)]
}
A lot is going on in that code:
- If there are no more events for today, return the “No more events” placeholder event.
- If there are events left, ensure that they’re after the date specified in the
datemethod parameter. - Honor the
limitmethod parameter by only creating that many timeline entries. - Convert each
EKEventto aCLKComplicationTimelineEntry. - Finally, if there were events, return them.
Notice how, in step five, you might have zero entries after filtering. Be sure you don’t return nil. Instead, return the “No more entries” item.
Build and run the app. Once the app starts on your Apple Watch, add a new watch face using the Modular Compact design. That face includes the graphic rectangular complication. Select your calendar app for the complication and then return to the home screen.
Tinting
In Chapter 11: “Tinted Complications”, you learned about tinting. SwiftUI views let you specify the foreground via the complicationForeground modifier.
In EventView.swift, just after the .foregroundColor(event.color) line of RoundedRectangle, add:
.complicationForeground()
Then do the same for the event title and add it right after:
Text(event.title)
.font(.headline)
Now, in the previews body, change the previewContext to:
.previewContext(faceColor: .green)
In the canvas, you’ll see that both the rectangle and the title now receive the tint color:
Ideally, specifying the foreground will be all you need to make your complication look perfect. Depending on whether the watch face is tinted, you might need to do something more drastic at times.
ClockKit provides ComplicationRenderingMode, an enum with two values:
-
.tinted: For when the watch face uses tinting. -
.fullColor: For when the watch face doesn’t use tinting.
The rendering mode is available as an environment variable that you can add to your view with:
@Environment(\.complicationRenderingMode) var renderingMode
You may wish, for example, to use some type of gradient when the watch face is tinted. By examining the value of renderingMode in your code, you can take appropriate action.
Key points
- ClockKit provides multiple graphic complication types that use a SwiftUI
View. - SwiftUI views easily support tinting via the
complicationForeground()modifier. - For complete tinting control, use the
ComplicationRenderingModeenvironment property.
Where to go from here?
For more information, check out these resources:
- Apple’s documentation on Building Complications with SwiftUI.
- Apple’s WWDC2020 Build complication in SwiftUI video.