NewAppLander — App landing pages in 60s$69$39
The Swift Kit logoThe Swift Kit
Tutorial

SwiftUI Widget Tutorial — Build Home Screen & Lock Screen Widgets with WidgetKit

A complete, code-first guide to building iOS widgets in SwiftUI. From your first TimelineProvider to interactive Lock Screen widgets, StandBy mode support, App Group data sharing, and the performance patterns that keep your widgets fast and your timeline budgets intact.

Ahmed GaganAhmed Gagan
14 min read

TL;DR

  • Widgets use TimelineProvider to 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 Button and Toggle powered by AppIntent.
  • 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:

  1. TimelineEntry — a struct that holds the data for a single snapshot, plus a date property that tells iOS when to display it.
  2. TimelineProvider — a protocol that supplies entries to WidgetKit. It has three methods: placeholder, getSnapshot, and getTimeline.
  3. 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 .containerBackground modifier 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

  1. In Xcode, select your main app target → Signing & Capabilities → + Capability → App Groups.
  2. Create a group identifier: group.com.yourcompany.yourapp.
  3. Select your widget extension target → add the same App Group.
  4. 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") ?? 0

Call 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:

FamilyApprox. Size (pt)Content GuidanceColorPlatform
.systemSmall158 x 158Single metric or tap target. No scrolling.Full coloriPhone, iPad
.systemMedium338 x 158Key metric + supporting detail. 2-3 data points.Full coloriPhone, iPad
.systemLarge338 x 354Rich content, lists, charts, multi-section layouts.Full coloriPhone, iPad
.systemExtraLarge715 x 354Full dashboard-style layout. iPad only.Full coloriPad
.accessoryCircular~76 x 76Single gauge, icon, or number. Minimal text.Monochrome / vibrancyiPhone, iPad, Apple Watch
.accessoryRectangular~160 x 762-3 lines text with optional graphic.Monochrome / vibrancyiPhone, iPad, Apple Watch
.accessoryInline~1 lineShort text string + optional SF Symbol.System tintiPhone, 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.accessory families. 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 getTimeline when 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 .never reload policy without manual refreshes. If you use .never, you must call reloadAllTimelines() from your app. If the user never opens the app, the widget goes stale permanently.
  • Not handling the isPreview context. In getSnapshot, check context.isPreview and 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 is nil defaults 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.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit