Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 063 / 100

Widgets with WidgetKit: a practical guide for developers

Home screen and Lock Screen widgets.

Expona Admin10 min read

Widgets with WidgetKit: a practical guide for developers

Home screen and Lock Screen widgets have become essential real estate for iOS apps—they're often the first place users check for quick information without opening your app. Building effective widgets requires understanding WidgetKit, a framework that handles the lifecycle, data refresh, and UI rendering of these miniature app experiences. This guide walks through the practical patterns we've used in production apps to create widgets that feel responsive, stay fresh, and genuinely improve user engagement.

Understanding the Widget Lifecycle

Before diving into code, it's worth understanding how widgets differ from standard app development. Widgets run in a sandboxed environment with limited resources, no network access by default, and no direct interaction with your app's running process. The system calls your widget code at specific intervals to refresh its content, and you provide snapshots of data rather than continuous updates.

This architectural constraint shapes everything: you can't make network requests directly, you can't maintain state across refreshes, and you have limited CPU time. The payoff is that users see timely information without draining battery or data allowances. Understanding these boundaries helps you design widgets that work within the framework rather than fighting against it.

Timeline Providers Supply Entries

The foundation of any widget is the timeline provider—an object conforming to TimelineProvider that feeds your widget fresh data. The provider delivers entries at specific times, and the system renders your SwiftUI view for each entry. Think of it as a sequence of snapshots showing what your widget should display at different moments.

Your timeline provider needs three core methods: placeholder() for preview rendering, getSnapshot() for the widget gallery, and getTimeline() which does the real work. The getTimeline() method returns a Timeline object containing an array of TimelineEntry objects and a refresh policy.

swift
struct OrderEntry: TimelineEntry {
    let date: Date
    let orderCount: Int
    let lastOrderTime: String
    let isLoading: Bool
}

struct OrderWidgetProvider: TimelineProvider {
    func placeholder(in context: Context) -> OrderEntry {
        OrderEntry(
            date: Date(),
            orderCount: 0,
            lastOrderTime: "—",
            isLoading: false
        )
    }

    func getSnapshot(in context: Context, completion: @escaping (OrderEntry) -> ()) {
        // Called for widget gallery preview
        let entry = OrderEntry(
            date: Date(),
            orderCount: 3,
            lastOrderTime: "2 hours ago",
            isLoading: false
        )
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
        // Fetch fresh data from shared storage or local database
        let currentData = fetchOrderData()

        // Create entries for the next 4 hours, spaced 30 minutes apart
        var entries: [OrderEntry] = []
        let now = Date()

        for hourOffset in 0..<8 {
            let entryDate = Calendar.current.date(
                byAdding: .minute,
                value: hourOffset * 30,
                to: now
            ) ?? now

            entries.append(OrderEntry(
                date: entryDate,
                orderCount: currentData.count,
                lastOrderTime: currentData.lastOrderFormatted,
                isLoading: false
            ))
        }

        // Tell the system to refresh after 4 hours
        let timeline = Timeline(
            entries: entries,
            policy: .after(Calendar.current.date(byAdding: .hour, value: 4, to: now) ?? now)
        )
        completion(timeline)
    }
}

In production, we typically fetch data from a shared App Group container or a local database rather than making network calls. The widget provider runs in a separate process with limited permissions, so it can't directly access your app's data through normal means. Using UserDefaults(suiteName:) with a shared App Group identifier lets both your app and widget read the same stored data.

The refresh policy is crucial—set it too aggressively and the system throttles you anyway (wasting resources), set it too conservatively and your widget feels stale. For data that changes infrequently, .after(Date) works well. For data that changes often, use .atEnd to let the system decide when to refresh next, or return multiple entries covering different time periods.

SwiftUI Views for Widget UI

Widget UI is built with SwiftUI, but with constraints. You can't use NavigationStack, Sheet, or other full-screen presentation APIs. Your view runs in a read-only sandbox focused on displaying information. This simplification actually makes widget views cleaner than you might expect.

Widgets come in different sizes: .systemSmall (2x2), .systemMedium (4x2), and .systemLarge (4x4). Some widgets also support Lock Screen sizes. Adapt your view based on the available WidgetFamily from the environment.

swift
struct OrderWidgetView: View {
    let entry: OrderEntry
    @Environment(\.widgetFamily) var family

    var body: some View {
        switch family {
        case .systemSmall:
            smallWidget
        case .systemMedium:
            mediumWidget
        case .systemLarge:
            largeWidget
        case .accessoryRectangular:
            lockScreenWidget
        @unknown default:
            Text("Widget not supported")
        }
    }

    private var smallWidget: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Orders").font(.headline)
            Text("\(entry.orderCount)").font(.system(size: 32, weight: .bold))
            Spacer()
            Text("Last: \(entry.lastOrderTime)").font(.caption).foregroundColor(.secondary)
        }
        .padding()
        .background(Color(uiColor: .systemBackground))
    }

    private var mediumWidget: some View {
        HStack(spacing: 16) {
            VStack(alignment: .leading, spacing: 8) {
                Text("Orders Today").font(.headline)
                Text("\(entry.orderCount)")
                    .font(.system(size: 40, weight: .bold))
                    .foregroundColor(.blue)
                Spacer()
            }

            VStack(alignment: .trailing, spacing: 8) {
                Text("Status").font(.caption).foregroundColor(.secondary)
                Text(entry.isLoading ? "Updating..." : "Current")
                    .font(.body)
                Spacer()
            }

            Spacer()
        }
        .padding()
        .background(Color(uiColor: .systemBackground))
    }

    private var lockScreenWidget: some View {
        Text("\(entry.orderCount) orders")
            .font(.system(size: 14, weight: .semibold))
    }
}

struct OrderWidget: Widget {
    let kind: String = "com.example.orders.widget"

    var body: some WidgetConfiguration {
        StaticConfiguration(
            kind: kind,
            provider: OrderWidgetProvider()
        ) { entry in
            OrderWidgetView(entry: entry)
        }
        .configurationDisplayName("My Orders")
        .description("See your order count at a glance")
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
    }
}

Keep widget views lightweight. Heavy SwiftUI structures or complex loops cause rendering delays. Test on actual devices—the simulator sometimes behaves differently. One trap we've seen: developers try to replicate their full app experience in widget form. Resist that. Widgets shine when they show one focused piece of information quickly.

Interactive Widgets with App Intents

iOS 16 and later support interactive widgets using App Intents, letting users perform actions directly from the widget without opening your app. This is where widgets become genuinely useful rather than just informational.

To make a widget interactive, you need to create an AppIntent that defines what action happens when the user interacts with your widget. The intent runs in your app's context (not the widget sandbox), so it can perform real work like updating a database or sending a network request.

swift
struct MarkOrderCompleteIntent: AppIntent {
    static var title: LocalizedStringResource = "Mark as Complete"
    static var openAppWhenRun: Bool = false

    @Parameter(title: "Order ID") var orderId: String

    func perform() async throws -> some IntentResult {
        // This runs in your app's context
        let database = OrderDatabase.shared
        try await database.markOrderComplete(orderId: orderId)

        return .result()
    }
}

struct OrderWidgetView: View {
    let entry: OrderEntry
    @Environment(\.widgetFamily) var family

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Text("Orders").font(.headline)
                Spacer()
                Text("\(entry.orderCount)")
                    .font(.system(size: 20, weight: .bold))
                    .foregroundColor(.blue)
            }

            Button(intent: MarkOrderCompleteIntent(orderId: "order-123")) {
                Label("Complete", systemImage: "checkmark.circle")
            }
            .buttonStyle(.bordered)

            Text("Last: \(entry.lastOrderTime)")
                .font(.caption)
                .foregroundColor(.secondary)

            Spacer()
        }
        .padding()
        .background(Color(uiColor: .systemBackground))
    }
}

Each App Intent should be lightweight and complete in seconds. If your intent needs to fetch fresh data or make network calls, do that efficiently. The user expects immediate feedback, so show a loading state or confirmation quickly.

You can also use Button with intents or leverage contextMenu for additional actions. Some apps even use Deep Links within widgets by wrapping intents with custom URL schemes, though App Intents are the preferred modern approach.

Keep Data Small and Fresh

Widget performance hinges on managing data efficiently. The widget process has limited memory, and the system terminates widgets that consume too much CPU time. This isn't a limitation—it's good design enforcing you to think about what data actually matters.

Store only essential data in your shared App Group container. If you're tracking order counts, store the count and last update time. Don't store full order objects, images, or verbose histories. When your app updates, write minimal deltas rather than entire data structures.

swift
// Good: minimal stored data
struct WidgetData: Codable {
    let orderCount: Int
    let lastOrderTime: Date
    let updatedAt: Date
}

let defaults = UserDefaults(suiteName: "group.com.example.orders")
let data = WidgetData(orderCount: 5, lastOrderTime: Date(), updatedAt: Date())
defaults?.set(try JSONEncoder().encode(data), forKey: "widgetData")

// Avoid: storing large objects
struct Order: Codable {
    let id: String
    let items: [OrderItem]  // Avoid nested complexity
    let customer: Customer  // Avoid full object graphs
    let metadata: [String: String]
}

Use reasonable refresh intervals. A stock ticker might refresh every 15 minutes, a fitness widget every hour, a weather widget even less frequently. Ask yourself: would the user notice stale data in 2 hours? If not, set your refresh policy to 2 hours. The system respects your policy but may adjust it based on system load and battery state, so don't expect exact timing.

For widgets that need near-real-time updates, consider using background app refresh or push notifications to trigger widget timeline updates. Your app can call WidgetCenter.shared.reloadTimelines(ofKind:) when important data changes, nudging the system to refresh immediately.

Common Mistakes

  • Making network requests in the timeline provider: Widgets run in sandbox without network access. Store data in a shared container that your app populates, or use URLSession with background app refresh capability—but not direct network calls in the widget provider.
  • Ignoring widget size variations: Testing only on one size and finding your widget breaks on others is a painful discovery. Use @Environment(\.widgetFamily) and test all supported sizes from the start.
  • Storing too much data: Each widget refresh writes to the shared App Group container. Storing large JSON or multiple historical entries wastes space and slows refresh. Keep it minimal.
  • Not handling empty or error states: Your timeline provider might fail to fetch data or the device might be offline. Always provide sensible fallback entries and graceful error UI.
  • Overcomplicating App Intents: Intents that take more than a few seconds to complete frustrate users. Keep them simple—one action, fast execution, clear feedback.
  • Forgetting to test on physical devices: The simulator's widget behavior doesn't always match real devices. Always test refresh timing and performance on actual hardware.

Key Takeaways

  • Widgets derive their data from timeline providers that supply entries at specific times—design your provider to fetch from shared storage, not directly from your app.
  • SwiftUI widget views must adapt to different families (small, medium, large, Lock Screen) and respect the read-only sandbox constraints.
  • App Intents enable interactive widgets, letting users perform actions without opening your app—keep intents lightweight and fast.
  • Store minimal data in shared App Group containers and refresh at reasonable intervals to balance freshness with system resource usage.
  • Test on physical devices across all supported widget sizes and refresh scenarios—the simulator often behaves differently in production.
  • Widgets succeed when they display one focused piece of information quickly; don't try to replicate your full app experience.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles