TL;DR
- Widgets use
TimelineProviderto supply snapshots of your data at scheduled intervals — they are not live views. - Home Screen widgets support
.systemSmall,.systemMedium,.systemLarge, and.systemExtraLarge(iPad). Lock Screen widgets use.accessoryCircular,.accessoryRectangular, and.accessoryInline. - Share data between your app and widget extension via App Groups and
UserDefaults(suiteName:). - iOS 17+ adds interactive widgets with
ButtonandTogglepowered byAppIntent. - StandBy mode on iPhone and iPad Lock Screen widgets work with the same WidgetKit code — no extra implementation required.
Widgets are the highest-visibility real estate on iOS. They sit on the Home Screen, the Lock Screen, and (since iOS 17) the StandBy display — surfaces your user sees hundreds of times per day without ever opening your app. Apple reported that widgets drove a 40% increase in daily app engagement for apps that adopted them in the first year of WidgetKit. For indie developers, a well-designed widget is one of the most effective retention and re-engagement tools available, second only to push notifications. This tutorial covers everything you need to build production-ready widgets in SwiftUI, from scratch, with real code you can copy into your project today.
What Are iOS Widgets and Why Do They Matter?
Widgets are lightweight, glanceable views that display a snapshot of your app's content outside the app itself. Unlike a full SwiftUI view, a widget is not a live, interactive screen. It is a rendered timeline of snapshots that iOS displays and refreshes on a schedule you define. Think of them as posters your app puts up around the operating system — each poster is static until iOS swaps in the next one from your timeline.
Since iOS 14, widgets live on the Home Screen. iOS 16 brought them to the Lock Screen. iOS 17 added StandBy mode (the always-on landscape display when iPhone is charging) and interactive controls. iOS 18 expanded widget placement further, allowing them on any Home Screen page and tinting them to match the wallpaper.
The engagement numbers are compelling. Apps with widgets see significantly higher retention because the widget acts as a passive reminder. A fitness app showing today's step count on the Lock Screen does not need to send a push notification to get the user thinking about their goal — the data is already in their line of sight. A habit tracker widget showing an incomplete streak is a nudge that costs zero notification budget.
WidgetKit supports these widget families:
- Home Screen:
.systemSmall,.systemMedium,.systemLarge,.systemExtraLarge(iPad only) - Lock Screen / StandBy:
.accessoryCircular,.accessoryRectangular,.accessoryInline
Each family has different size constraints, content expectations, and design guidelines. We will build real examples for each one in this tutorial.
How Do You Create Your First Widget in SwiftUI?
Widgets live in a separate target — a Widget Extension. In Xcode, go to File → New → Target → Widget Extension. Name it something like MyAppWidget. Xcode generates the boilerplate for you, but understanding each piece is critical because you will customize all of it.
Every widget has three core components:
- TimelineEntry — a struct that holds the data for a single snapshot, plus a
dateproperty that tells iOS when to display it. - TimelineProvider — a protocol that supplies entries to WidgetKit. It has three methods:
placeholder,getSnapshot, andgetTimeline. - Widget body — a SwiftUI view that renders the entry.
Here is the minimal structure:
// MyAppWidget.swift
import WidgetKit
import SwiftUI
// 1. Define the data model for each snapshot
struct HabitEntry: TimelineEntry {
let date: Date
let habitName: String
let completedCount: Int
let goalCount: Int
let streakDays: Int
}
// 2. Provide timeline data to WidgetKit
struct HabitProvider: TimelineProvider {
// Shown while the widget is loading (no data yet)
func placeholder(in context: Context) -> HabitEntry {
HabitEntry(
date: .now,
habitName: "Drink Water",
completedCount: 5,
goalCount: 8,
streakDays: 12
)
}
// Shown in the widget gallery preview
func getSnapshot(
in context: Context,
completion: @escaping (HabitEntry) -> Void
) {
let entry = HabitEntry(
date: .now,
habitName: "Drink Water",
completedCount: 5,
goalCount: 8,
streakDays: 12
)
completion(entry)
}
// The real data — called periodically by WidgetKit
func getTimeline(
in context: Context,
completion: @escaping (Timeline<HabitEntry>) -> Void
) {
// Read the latest data from shared storage
let defaults = UserDefaults(suiteName: "group.com.yourapp.shared")
let name = defaults?.string(forKey: "habitName") ?? "Habit"
let completed = defaults?.integer(forKey: "completedCount") ?? 0
let goal = defaults?.integer(forKey: "goalCount") ?? 8
let streak = defaults?.integer(forKey: "streakDays") ?? 0
let entry = HabitEntry(
date: .now,
habitName: name,
completedCount: completed,
goalCount: goal,
streakDays: streak
)
// Refresh every 30 minutes
let nextUpdate = Calendar.current.date(
byAdding: .minute,
value: 30,
to: .now
)!
let timeline = Timeline(
entries: [entry],
policy: .after(nextUpdate)
)
completion(timeline)
}
}
// 3. The widget declaration
struct MyAppWidget: Widget {
let kind: String = "MyAppWidget"
var body: some WidgetConfiguration {
StaticConfiguration(
kind: kind,
provider: HabitProvider()
) { entry in
HabitWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Habit Tracker")
.description("Track your daily habit progress.")
.supportedFamilies([
.systemSmall,
.systemMedium,
.systemLarge,
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
])
}
}The placeholder method returns a dummy entry that WidgetKit uses while the real data loads. The getSnapshot method returns a single representative entry for the widget gallery (where users browse and add widgets). The getTimeline method is where your real data lives — WidgetKit calls it periodically and you return one or more entries along with a reload policy.
Important: The
.containerBackgroundmodifier is required starting in iOS 17. Without it, your widget will show a warning in Xcode previews and may render incorrectly on devices. Always include it.
How Do You Build a Home Screen Widget?
Now let us build the actual widget view. A well-designed Home Screen widget needs to handle three size classes — small, medium, and large — each with different content density. The small widget should show one key metric. The medium widget adds context. The large widget can show a full summary. Here is the complete view using our habit tracker example:
// HabitWidgetView.swift
import SwiftUI
import WidgetKit
struct HabitWidgetView: View {
let entry: HabitEntry
@Environment(\.widgetFamily) var family
var progress: Double {
guard entry.goalCount > 0 else { return 0 }
return Double(entry.completedCount) / Double(entry.goalCount)
}
var body: some View {
switch family {
case .systemSmall:
smallWidget
case .systemMedium:
mediumWidget
case .systemLarge:
largeWidget
default:
smallWidget
}
}
// MARK: - Small Widget
private var smallWidget: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "flame.fill")
.foregroundStyle(.orange)
.font(.title3)
Text("\(entry.streakDays)d streak")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(entry.habitName)
.font(.headline)
.lineLimit(1)
ProgressView(value: progress)
.tint(progress >= 1.0 ? .green : .blue)
Text("\(entry.completedCount)/\(entry.goalCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding()
}
// MARK: - Medium Widget
private var mediumWidget: some View {
HStack(spacing: 16) {
// Left: circular progress
ZStack {
Circle()
.stroke(.quaternary, lineWidth: 8)
Circle()
.trim(from: 0, to: progress)
.stroke(
progress >= 1.0 ? .green : .blue,
style: StrokeStyle(
lineWidth: 8,
lineCap: .round
)
)
.rotationEffect(.degrees(-90))
VStack(spacing: 2) {
Text("\(Int(progress * 100))%")
.font(.title2.bold())
Text("done")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(width: 80, height: 80)
// Right: details
VStack(alignment: .leading, spacing: 6) {
Text(entry.habitName)
.font(.headline)
Text("\(entry.completedCount) of \(entry.goalCount) completed")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.foregroundStyle(.orange)
.font(.caption)
Text("\(entry.streakDays) day streak")
.font(.caption)
.foregroundStyle(.secondary)
}
if progress >= 1.0 {
Label("Goal reached!", systemImage: "checkmark.circle.fill")
.font(.caption.bold())
.foregroundStyle(.green)
}
}
Spacer()
}
.padding()
}
// MARK: - Large Widget
private var largeWidget: some View {
VStack(alignment: .leading, spacing: 12) {
// Header
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(entry.habitName)
.font(.title3.bold())
Text("Today's progress")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.foregroundStyle(.orange)
Text("\(entry.streakDays)d")
.font(.subheadline.bold())
}
}
// Large progress ring
HStack {
Spacer()
ZStack {
Circle()
.stroke(.quaternary, lineWidth: 12)
Circle()
.trim(from: 0, to: progress)
.stroke(
progress >= 1.0 ? .green : .blue,
style: StrokeStyle(
lineWidth: 12,
lineCap: .round
)
)
.rotationEffect(.degrees(-90))
VStack(spacing: 4) {
Text("\(entry.completedCount)")
.font(.system(size: 36, weight: .bold))
Text("of \(entry.goalCount)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(width: 120, height: 120)
Spacer()
}
Spacer()
// Weekly summary placeholder
VStack(alignment: .leading, spacing: 6) {
Text("This Week")
.font(.caption.bold())
.foregroundStyle(.secondary)
HStack(spacing: 4) {
ForEach(0..<7, id: \.self) { day in
RoundedRectangle(cornerRadius: 4)
.fill(day < entry.streakDays % 7
? Color.green.opacity(0.8)
: Color.secondary.opacity(0.2))
.frame(height: 24)
}
}
}
if progress >= 1.0 {
Label(
"Goal complete — keep it going!",
systemImage: "star.fill"
)
.font(.caption.bold())
.foregroundStyle(.yellow)
}
}
.padding()
}
}The key pattern here is using @Environment(\\.widgetFamily) to switch between layouts. This is the standard approach — one widget view that adapts to every supported family. Do not create separate widgets for each size. A single widget with a switch statement keeps your code maintainable and lets the user choose their preferred size.
Previewing Widgets in Xcode
You can preview your widget at every size using the #Preview macro:
#Preview("Small", as: .systemSmall) {
MyAppWidget()
} timeline: {
HabitEntry(date: .now, habitName: "Drink Water",
completedCount: 5, goalCount: 8, streakDays: 12)
HabitEntry(date: .now, habitName: "Drink Water",
completedCount: 8, goalCount: 8, streakDays: 13)
}
#Preview("Medium", as: .systemMedium) {
MyAppWidget()
} timeline: {
HabitEntry(date: .now, habitName: "Drink Water",
completedCount: 3, goalCount: 8, streakDays: 7)
}
#Preview("Circular", as: .accessoryCircular) {
MyAppWidget()
} timeline: {
HabitEntry(date: .now, habitName: "Water",
completedCount: 5, goalCount: 8, streakDays: 12)
}How Do You Build a Lock Screen Widget?
Lock Screen widgets were introduced in iOS 16 and they use the .accessory widget families. These are small, monochrome widgets designed to blend with the Lock Screen clock and date. They do not support full color — WidgetKit automatically renders them with a vibrancy effect that adapts to the Lock Screen wallpaper. This means you should design with contrast in mind, not color.
There are three Lock Screen widget families:
.accessoryCircular— a small circular widget, similar in size to a complication on Apple Watch. Perfect for a single metric with a gauge or progress ring..accessoryRectangular— a rectangular widget that can display 2-3 lines of text plus an optional small graphic. The most versatile Lock Screen format..accessoryInline— a single line of text (and an optional SF Symbol) that appears alongside the date. Extremely limited but highly visible.
Add these cases to the widget view:
// Add to HabitWidgetView
extension HabitWidgetView {
// Circular: progress ring with count
var circularWidget: some View {
Gauge(value: progress) {
Text("\(entry.completedCount)")
.font(.system(.title3, design: .rounded).bold())
}
.gaugeStyle(.accessoryCircularCapacity)
.tint(progress >= 1.0 ? .green : .blue)
}
// Rectangular: multi-line summary
var rectangularWidget: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.caption2)
Text("\(entry.streakDays)d streak")
.font(.caption2.bold())
}
Text(entry.habitName)
.font(.headline)
.lineLimit(1)
ProgressView(value: progress)
Text("\(entry.completedCount)/\(entry.goalCount) today")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
// Inline: text beside the date
var inlineWidget: some View {
HStack(spacing: 4) {
Image(systemName: "flame.fill")
Text("\(entry.habitName): \(entry.completedCount)/\(entry.goalCount)")
}
}
}
// Update the body switch statement:
var body: some View {
switch family {
case .systemSmall:
smallWidget
case .systemMedium:
mediumWidget
case .systemLarge:
largeWidget
case .accessoryCircular:
circularWidget
case .accessoryRectangular:
rectangularWidget
case .accessoryInline:
inlineWidget
default:
smallWidget
}
}The Gauge view with .accessoryCircularCapacity is the recommended approach for circular Lock Screen widgets. It automatically handles the vibrancy rendering and gives you a clean progress ring. For the rectangular widget, keep the content tight — you have roughly the space of 3-4 lines of caption text.
Design tip: Do not try to force full-color designs into Lock Screen widgets. WidgetKit ignores custom colors and applies a system vibrancy treatment instead. Use SF Symbols and let the system handle the rendering. Test on multiple wallpapers — a widget that looks great on a dark wallpaper might be unreadable on a light one if your contrast is off.
How Do You Share Data Between Your App and Widget?
This is where most developers hit their first real obstacle. Your widget extension runs in a separate process from your main app. It cannot access your app's UserDefaults, Core Data store, or SwiftData container directly. The solution is App Groups — a shared container that both your app and widget extension can read from and write to.
Setting Up App Groups
- In Xcode, select your main app target → Signing & Capabilities → + Capability → App Groups.
- Create a group identifier:
group.com.yourcompany.yourapp. - Select your widget extension target → add the same App Group.
- Both targets now share a container directory.
Sharing via UserDefaults
The simplest approach — and the one I recommend starting with — is shared UserDefaults:
// In your main app — write data
let shared = UserDefaults(suiteName: "group.com.yourcompany.yourapp")
shared?.set("Drink Water", forKey: "habitName")
shared?.set(5, forKey: "completedCount")
shared?.set(8, forKey: "goalCount")
shared?.set(12, forKey: "streakDays")
// Tell WidgetKit to refresh
import WidgetKit
WidgetCenter.shared.reloadAllTimelines()
// In your widget extension — read data (already shown in the provider)
let defaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")
let name = defaults?.string(forKey: "habitName") ?? "Habit"
let completed = defaults?.integer(forKey: "completedCount") ?? 0Call WidgetCenter.shared.reloadAllTimelines() whenever your app's data changes and you want the widget to update. This is the explicit way to trigger a refresh outside the scheduled timeline.
Sharing via SwiftData / Core Data
For more complex data, you can share a SwiftData or Core Data store:
// Shared model container configuration
import SwiftData
let sharedModelContainer: ModelContainer = {
let schema = Schema([HabitModel.self])
let config = ModelConfiguration(
schema: schema,
url: FileManager.default
.containerURL(
forSecurityApplicationGroupIdentifier:
"group.com.yourcompany.yourapp"
)!
.appendingPathComponent("HabitStore.sqlite"),
allowsSave: true
)
return try! ModelContainer(
for: schema,
configurations: [config]
)
}()
// Use in both your app and widget extension:
// App:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}
// Widget:
struct HabitProvider: TimelineProvider {
let container = sharedModelContainer
func getTimeline(
in context: Context,
completion: @escaping (Timeline<HabitEntry>) -> Void
) {
let context = ModelContext(container)
let habits = try? context.fetch(
FetchDescriptor<HabitModel>(
sortBy: [SortDescriptor(\.name)]
)
)
// Convert to entries...
}
}Using @AppStorage with Suite
For SwiftUI views in your main app, you can use @AppStorage with a suite name to read and write to the shared container:
struct HabitView: View {
@AppStorage("completedCount", store: UserDefaults(
suiteName: "group.com.yourcompany.yourapp"
))
var completedCount: Int = 0
var body: some View {
Button("Complete") {
completedCount += 1
// Reload widget after update
WidgetCenter.shared.reloadAllTimelines()
}
}
}How Do Widget Families Compare?
Here is a complete reference for every widget family, including sizes, content guidelines, and platform support:
| Family | Approx. Size (pt) | Content Guidance | Color | Platform |
|---|---|---|---|---|
| .systemSmall | 158 x 158 | Single metric or tap target. No scrolling. | Full color | iPhone, iPad |
| .systemMedium | 338 x 158 | Key metric + supporting detail. 2-3 data points. | Full color | iPhone, iPad |
| .systemLarge | 338 x 354 | Rich content, lists, charts, multi-section layouts. | Full color | iPhone, iPad |
| .systemExtraLarge | 715 x 354 | Full dashboard-style layout. iPad only. | Full color | iPad |
| .accessoryCircular | ~76 x 76 | Single gauge, icon, or number. Minimal text. | Monochrome / vibrancy | iPhone, iPad, Apple Watch |
| .accessoryRectangular | ~160 x 76 | 2-3 lines text with optional graphic. | Monochrome / vibrancy | iPhone, iPad, Apple Watch |
| .accessoryInline | ~1 line | Short text string + optional SF Symbol. | System tint | iPhone, iPad, Apple Watch |
Note that sizes vary slightly by device. The values above are approximate for a standard iPhone display. Always test on real devices or multiple Simulator sizes.
How Do You Add Interactivity to Widgets?
Before iOS 17, widgets were entirely static — tapping anywhere opened your app. With iOS 17, Apple introduced interactive widgets powered by AppIntent. You can now add Button and Toggle controls that execute an actionwithout opening the app.
This is a game-changer for utility apps. A to-do list widget can mark tasks complete. A habit tracker can log a completion. A music app can play/pause. The interaction happens instantly in the widget itself.
Creating an AppIntent for Widget Interaction
// HabitIntent.swift
import AppIntents
import WidgetKit
struct LogHabitIntent: AppIntent {
static var title: LocalizedStringResource = "Log Habit"
static var description = IntentDescription("Log a habit completion.")
// This makes the intent available to widgets
static var isDiscoverable: Bool { true }
func perform() async throws -> some IntentResult {
// Update shared data
let defaults = UserDefaults(
suiteName: "group.com.yourcompany.yourapp"
)
let current = defaults?.integer(forKey: "completedCount") ?? 0
defaults?.set(current + 1, forKey: "completedCount")
// Refresh the widget immediately
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}Using Button in a Widget
// In your widget view
struct InteractiveHabitWidget: View {
let entry: HabitEntry
var body: some View {
VStack(spacing: 12) {
Text(entry.habitName)
.font(.headline)
Text("\(entry.completedCount)/\(entry.goalCount)")
.font(.title.bold())
// Interactive button — no app launch!
Button(intent: LogHabitIntent()) {
Label("Log", systemImage: "plus.circle.fill")
.font(.subheadline.bold())
}
.tint(.green)
}
.padding()
}
}When the user taps the button, LogHabitIntent.perform() runs in the background, updates the shared UserDefaults, and triggers a timeline reload. The widget updates in place — the user never leaves the Home Screen.
Using Toggle in a Widget
// Toggle a boolean habit (e.g., "Did you meditate?")
struct ToggleHabitIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Habit"
@Parameter(title: "Completed")
var isCompleted: Bool
func perform() async throws -> some IntentResult {
let defaults = UserDefaults(
suiteName: "group.com.yourcompany.yourapp"
)
defaults?.set(isCompleted, forKey: "habitCompleted")
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
// In the widget view:
Toggle(isOn: entry.isCompleted,
intent: ToggleHabitIntent(isCompleted: !entry.isCompleted)) {
Text(entry.habitName)
.font(.headline)
}Deep Linking with widgetURL
For widgets on iOS 16 and earlier, or when you want taps on non-interactive areas to open a specific screen, use widgetURL:
// On the widget view
smallWidget
.widgetURL(URL(string: "myapp://habit/drink-water"))
// For medium/large widgets with multiple tap targets:
Link(destination: URL(string: "myapp://habit/drink-water")!) {
HabitRow(habit: waterHabit)
}
Link(destination: URL(string: "myapp://habit/exercise")!) {
HabitRow(habit: exerciseHabit)
}
// In your main app, handle the URL:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
// Parse the URL and navigate
DeepLinkRouter.shared.handle(url)
}
}
}
}Small widgets only support a single widgetURL for the entire surface. Medium and large widgets can use Link views to create multiple tap regions, each with a different destination URL.
How Do You Support StandBy Mode and iPad Lock Screen?
The good news: if you already support the standard widget families, StandBy mode and iPad Lock Screen widgets require zero additional code. StandBy mode on iPhone (iOS 17+) displays Home Screen widgets in a landscape, always-on format when the phone is charging on a stand. Your .systemSmall and .systemMedium widgets automatically appear in StandBy without any changes.
There are a few design considerations:
- Viewing distance: StandBy mode is designed to be viewed from across a room (like a bedside clock). Use larger text and high-contrast colors. Avoid small caption text that works on the Home Screen but is unreadable at arm's length.
- Night Mode: In StandBy Night Mode, iOS applies a red tint to reduce blue light. Your widget's colors will be shifted. Test with Simulator → Features → Toggle Appearance to see how your widget looks.
- Always-On Display: On iPhone 14 Pro and later, the always-on display dims your widget. WidgetKit handles this automatically, but very low-contrast designs may become invisible. Ensure your key metrics have strong contrast against the background.
- iPad Lock Screen: iPadOS 17 supports Lock Screen widgets using the same
.accessoryfamilies. If you already support those families, your widgets work on iPad Lock Screen automatically.
If you want to detect whether your widget is currently being displayed in StandBy mode, check the widget rendering mode:
struct HabitWidgetView: View {
@Environment(\.showsWidgetContainerBackground)
var showsBackground
// In StandBy, the container background is not shown
// Use this to adjust your layout
var body: some View {
VStack {
Text("Steps")
.font(showsBackground ? .caption : .title3)
Text("8,432")
.font(showsBackground ? .title2.bold() : .largeTitle.bold())
}
}
}Widget Performance and Best Practices
Widgets run under strict resource constraints. WidgetKit manages a timeline budgetper widget — a daily limit on how many times your widget can reload. If you burn through your budget, WidgetKit stops calling getTimeline until the next day, and your widget displays stale data. Here is how to stay within budget and keep your widgets fast:
Timeline Budget Rules
- WidgetKit typically allows 40-70 reloads per day for a standard widget. The exact budget varies by device usage and system conditions.
- Widgets the user views more frequently get a higher budget. A widget on the first Home Screen page reloads more often than one buried on the last page.
WidgetCenter.shared.reloadAllTimelines()counts against your budget.Do not call it on every minor data change. Batch updates and call it once.- Return multiple entries in
getTimelinewhen your data is time-based. Instead of requesting a reload every hour, return 24 entries (one per hour) and let WidgetKit swap them automatically — this uses a single reload for an entire day.
Efficient Timeline Strategy
func getTimeline(
in context: Context,
completion: @escaping (Timeline<HabitEntry>) -> Void
) {
let defaults = UserDefaults(
suiteName: "group.com.yourcompany.yourapp"
)
let name = defaults?.string(forKey: "habitName") ?? "Habit"
let completed = defaults?.integer(forKey: "completedCount") ?? 0
let goal = defaults?.integer(forKey: "goalCount") ?? 8
let streak = defaults?.integer(forKey: "streakDays") ?? 0
// Generate entries for the next 4 hours
// (reduces reload frequency)
var entries: [HabitEntry] = []
let now = Date.now
for hourOffset in 0..<4 {
let date = Calendar.current.date(
byAdding: .hour,
value: hourOffset,
to: now
)!
entries.append(HabitEntry(
date: date,
habitName: name,
completedCount: completed,
goalCount: goal,
streakDays: streak
))
}
// Reload after the last entry expires
let timeline = Timeline(
entries: entries,
policy: .atEnd
)
completion(timeline)
}Keep Widget Views Lightweight
- No network calls in the widget view. All data should be pre-fetched and stored in the shared container. The view just reads and renders.
- Avoid heavy image processing. If you display images, resize them before writing to the shared container, not in the widget's
getTimeline. - Keep the view hierarchy shallow. Deep nesting and complex conditionals increase render time. Widgets have a strict render-time limit — if your view takes too long, WidgetKit may display a placeholder instead.
- Use
.contentTransition(.numericText())on text that shows changing numbers for a polished update animation when the timeline advances. - Test on real devices. The Simulator is generous with resources. A widget that renders fine in Simulator may hit the time limit on an older iPhone SE.
Common Mistakes to Avoid
- Calling
reloadAllTimelines()in a tight loop. This drains your budget instantly. Call it once after all data updates are complete. - Relying on
.neverreload policy without manual refreshes. If you use.never, you must callreloadAllTimelines()from your app. If the user never opens the app, the widget goes stale permanently. - Not handling the
isPreviewcontext. IngetSnapshot, checkcontext.isPreviewand return representative data immediately — do not fetch real data for the gallery preview. - Forgetting App Group on the widget target. The most common crash: your widget tries to read from
UserDefaults(suiteName:)but the widget extension does not have the App Group capability. The result isnildefaults and empty widgets.
Ship Widgets Faster with The Swift Kit
Every pattern in this tutorial — the timeline provider, the multi-family widget view, the App Group data sharing, the interactive intent, the preview macros — follows the same architecture we use in The Swift Kit. The boilerplate is designed so that adding a widget extension to your project is straightforward: the shared data layer is already configured with App Groups, the design token system gives you consistent colors across your app and widget, and the performance patterns ensure your timeline stays within budget.
If you are building a new iOS app and want widgets from day one, The Swift Kit saves you from wiring up all of this infrastructure yourself. The architecture is already widget-ready — you just add your content. Pair it with our animation guide for polished transitions, the analytics integration to measure widget engagement, and the 30-day launch playbook to get your widget-powered app to the App Store fast. Check out the full feature list or visit pricing to get started.