Why Widgets Deserve Your Attention in 2026
The way people use iPhones has shifted — and honestly, it's been a long time coming. In 2026, the Home Screen isn't just a grid of app icons anymore. It's a living dashboard of information and quick actions. Widgets have evolved from static snapshots into functional micro-apps that let users complete tasks without ever launching your app. Log a glass of water, check a delivery status, toggle a smart light — all in under three seconds, right from the Home Screen.
iOS 26 pushes this even further.
WidgetKit now supports push-driven updates from your server, glass rendering that blends with the Liquid Glass design language, widgets on visionOS and CarPlay, and a new Relevance system for watchOS Smart Stacks. If your app doesn't have a widget yet, you're leaving engagement on the table. And if it does, iOS 26 gives you some powerful new tools to make it shine.
This guide takes you from an empty Xcode project to a production-ready interactive widget. We'll cover the timeline system, interactive controls with App Intents, configurable widgets, iOS 26 glass rendering, push updates, and performance best practices — all with working Swift code you can drop into your own project. So, let's dive in.
Setting Up a Widget Extension in Xcode
Widgets live in a separate target from your main app. The widget extension runs in its own process with its own lifecycle, and it communicates with your app through shared containers rather than direct function calls. This tripped me up the first time I worked with widgets — don't assume you can just call into your app's code directly.
Creating the Extension Target
In Xcode, go to File → New → Target. Select Widget Extension from the Application Extension group. Give it a descriptive name like TaskWidgetExtension. Uncheck "Include Live Activity" and "Include Configuration App Intent" for now — we'll add those manually so you understand every piece.
Xcode generates a handful of files. The most important is your widget struct, which conforms to the Widget protocol:
import WidgetKit
import SwiftUI
struct TaskCountWidget: Widget {
let kind: String = "TaskCountWidget"
var body: some WidgetConfiguration {
StaticConfiguration(
kind: kind,
provider: TaskCountProvider()
) { entry in
TaskCountWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Task Counter")
.description("Shows your remaining tasks for today.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
The kind string uniquely identifies this widget — you'll reference it when requesting timeline reloads from your app. StaticConfiguration tells WidgetKit this widget has no user-configurable options (we'll upgrade to AppIntentConfiguration later). And the .containerBackground modifier? That's been required since iOS 17 and controls how the widget background renders across different system appearances.
Enabling App Groups
Since your widget extension runs in a separate process, it can't directly access your app's data. You need an App Group — a shared container both targets can read from and write to.
- Select your main app target in Xcode and go to Signing & Capabilities.
- Click + Capability and add App Groups.
- Create a new group identifier like
group.com.yourcompany.taskapp. - Repeat the same steps for your widget extension target, selecting the same group identifier.
Now both targets can share data through UserDefaults(suiteName: "group.com.yourcompany.taskapp") or by writing files to the shared container directory. Pretty straightforward.
Understanding the Timeline System
This is where widgets get interesting — and, honestly, a bit unintuitive at first.
Unlike a normal SwiftUI view that re-renders reactively, a widget is archive-based. WidgetKit asks your code for a timeline of entries — each entry is a snapshot of data paired with a date. The system archives these entries and renders the appropriate one at the appropriate time. Your view code only executes during this archiving step, not when the widget is actually displayed on screen.
TimelineProvider Protocol
Every widget needs a TimelineProvider (or AppIntentTimelineProvider for configurable widgets). The protocol requires three methods:
struct TaskCountProvider: TimelineProvider {
typealias Entry = TaskCountEntry
func placeholder(in context: Context) -> TaskCountEntry {
TaskCountEntry(date: .now, remainingTasks: 5, completedTasks: 3)
}
func getSnapshot(in context: Context, completion: @escaping (TaskCountEntry) -> Void) {
let entry = TaskCountEntry(
date: .now,
remainingTasks: SharedTaskStore.remainingCount,
completedTasks: SharedTaskStore.completedCount
)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
let currentEntry = TaskCountEntry(
date: .now,
remainingTasks: SharedTaskStore.remainingCount,
completedTasks: SharedTaskStore.completedCount
)
// Refresh every 30 minutes
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
let timeline = Timeline(entries: [currentEntry], policy: .after(nextUpdate))
completion(timeline)
}
}
placeholder returns sample data for the system to render a skeleton view. getSnapshot provides a single entry for the widget gallery preview. And getTimeline is the workhorse — it returns the actual timeline entries along with a reload policy.
Timeline Entries
A timeline entry is just a simple struct conforming to TimelineEntry. It must have a date property, and you tack on whatever data your widget view needs:
struct TaskCountEntry: TimelineEntry {
let date: Date
let remainingTasks: Int
let completedTasks: Int
var totalTasks: Int { remainingTasks + completedTasks }
var completionPercentage: Double {
guard totalTasks > 0 else { return 0 }
return Double(completedTasks) / Double(totalTasks)
}
}
Reload Policies
The TimelineReloadPolicy controls when WidgetKit requests a fresh timeline:
.atEnd— Requests a new timeline after the last entry's date passes. Best when you supply multiple future entries..after(Date)— Requests a new timeline after a specific date. Use this when you know your data changes on a schedule..never— Never refreshes automatically. Your app must explicitly callWidgetCenter.shared.reloadTimelines(ofKind:)to trigger an update.
A common pattern for data-driven widgets is to use .never and then call reloadTimelines from your app whenever the underlying data changes. This gives you precise control without burning through the system's refresh budget on unnecessary reloads.
Building the Widget View
Widget views are standard SwiftUI — but with some notable constraints. You can't use navigation, sheets, scroll views, or stateful controls like TextField. What you can do is build beautiful, glanceable layouts using stacks, images, text, gauges, and charts.
struct TaskCountWidgetView: View {
var entry: TaskCountEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
smallView
case .systemMedium:
mediumView
default:
smallView
}
}
private var smallView: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "checklist")
.font(.title2)
.foregroundStyle(.blue)
Spacer()
}
Spacer()
Text("\(entry.remainingTasks)")
.font(.system(size: 44, weight: .bold, design: .rounded))
.contentTransition(.numericText())
Text("tasks remaining")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
}
private var mediumView: some View {
HStack(spacing: 16) {
smallView
VStack(alignment: .leading, spacing: 4) {
Gauge(value: entry.completionPercentage) {
Text("Progress")
} currentValueLabel: {
Text("\(Int(entry.completionPercentage * 100))%")
}
.gaugeStyle(.accessoryCircular)
.tint(.blue)
Text("\(entry.completedTasks) of \(entry.totalTasks) done")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.trailing)
}
}
}
Notice the widgetFamily environment value. Since a single widget can support multiple families, you should adapt your layout to the available space. A small widget shows just the count, while a medium widget adds a progress gauge. It's a small detail, but it makes a big difference in how polished things feel.
Making Widgets Interactive with App Intents
This is the part that really gets me excited. Interactive widgets transform a widget from a passive display into a genuine productivity tool. Since iOS 17, WidgetKit supports two interactive controls: Button(intent:) and Toggle(isOn:intent:). Both use App Intents under the hood, which means the same intent that powers your widget button can also work as a Siri command or Shortcuts action.
Defining the Intent
Let's create an intent that marks a task as complete directly from the widget:
import AppIntents
import WidgetKit
struct CompleteNextTaskIntent: AppIntent {
static let title: LocalizedStringResource = "Complete Next Task"
static let description: IntentDescription = "Marks the next pending task as complete."
func perform() async throws -> some IntentResult {
SharedTaskStore.completeNextTask()
return .result()
}
}
struct ToggleTaskIntent: AppIntent {
static let title: LocalizedStringResource = "Toggle Task"
@Parameter(title: "Task ID")
var taskId: String
@Parameter(title: "Is Completed")
var isCompleted: Bool
init() {}
init(taskId: String, isCompleted: Bool) {
self.taskId = taskId
self.isCompleted = isCompleted
}
func perform() async throws -> some IntentResult {
SharedTaskStore.setCompleted(isCompleted, for: taskId)
return .result()
}
}
When perform() returns, WidgetKit automatically requests a new timeline from your provider. The widget re-renders with the updated data — no manual reload call needed. That's one less thing to worry about.
Adding Interactive Controls to the View
Now let's wire these intents into the widget view:
struct InteractiveTaskWidgetView: View {
var entry: TaskListEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Today's Tasks")
.font(.headline)
ForEach(entry.tasks.prefix(3)) { task in
HStack {
Toggle(
isOn: task.isCompleted,
intent: ToggleTaskIntent(
taskId: task.id,
isCompleted: !task.isCompleted
)
) {
Text(task.title)
.strikethrough(task.isCompleted)
.font(.caption)
}
.toggleStyle(.checkbox)
}
}
Spacer()
Button(intent: CompleteNextTaskIntent()) {
Label("Complete Next", systemImage: "checkmark.circle")
.font(.caption2)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.padding()
}
}
A few important details worth calling out. The Toggle provides instant visual feedback — it switches its appearance immediately when tapped, before the intent even finishes executing. The Button triggers the intent and shows a brief loading indicator while it runs. And both controls are inactive on a locked device for security reasons (which makes sense, but can catch you off guard during testing).
Invalidatable Content for Instant Feedback
If your intent takes a moment to complete, you can use invalidatableContent to show a placeholder while the new timeline loads:
Text("\(entry.remainingTasks) remaining")
.invalidatableContent()
When the intent triggers, any view marked with invalidatableContent() shows a subtle loading treatment, then snaps to the updated value once the new timeline arrives. It's a nice touch that prevents the UI from feeling stale.
Configurable Widgets with AppIntentConfiguration
Static widgets show the same content for everyone. Configurable widgets let users customize what they see — which task list to display, which category to filter, how many items to show. Users set these options right in the widget editor by long-pressing and selecting "Edit Widget."
Defining a Configuration Intent
Create a WidgetConfigurationIntent that describes the user-facing options:
import AppIntents
struct TaskWidgetConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Configure Task Widget"
static let description: IntentDescription = "Choose which task list to display."
@Parameter(title: "Task List", default: .today)
var taskFilter: TaskFilterOption
@Parameter(title: "Show Completed", default: false)
var showCompleted: Bool
}
enum TaskFilterOption: String, AppEnum {
case today
case thisWeek
case highPriority
case all
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Filter"
static let caseDisplayRepresentations: [TaskFilterOption: DisplayRepresentation] = [
.today: "Today",
.thisWeek: "This Week",
.highPriority: "High Priority",
.all: "All Tasks"
]
}
Switching to AppIntentTimelineProvider
Replace your TimelineProvider with an AppIntentTimelineProvider that receives the configuration:
struct ConfigurableTaskProvider: AppIntentTimelineProvider {
typealias Entry = TaskListEntry
typealias Intent = TaskWidgetConfigIntent
func placeholder(in context: Context) -> TaskListEntry {
TaskListEntry(date: .now, tasks: TaskItem.samples)
}
func snapshot(for configuration: TaskWidgetConfigIntent, in context: Context) async -> TaskListEntry {
let tasks = SharedTaskStore.tasks(
filter: configuration.taskFilter,
includeCompleted: configuration.showCompleted
)
return TaskListEntry(date: .now, tasks: tasks)
}
func timeline(for configuration: TaskWidgetConfigIntent, in context: Context) async -> Timeline {
let tasks = SharedTaskStore.tasks(
filter: configuration.taskFilter,
includeCompleted: configuration.showCompleted
)
let entry = TaskListEntry(date: .now, tasks: tasks)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
return Timeline(entries: [entry], policy: .after(nextUpdate))
}
}
Updating the Widget Configuration
Switch your widget definition from StaticConfiguration to AppIntentConfiguration:
struct ConfigurableTaskWidget: Widget {
let kind: String = "ConfigurableTaskWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: TaskWidgetConfigIntent.self,
provider: ConfigurableTaskProvider()
) { entry in
InteractiveTaskWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Task List")
.description("Shows tasks from your selected list.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
Users can now long-press the widget, tap "Edit Widget," and choose their preferred filter and whether to show completed tasks. Each configuration gets its own timeline, so different widget instances on the Home Screen can show different task lists simultaneously. Pretty handy if you want, say, a "Today" widget and a "High Priority" widget side by side.
Sharing Data Between Your App and Widget
The data-sharing layer is the backbone of any useful widget. Since your app and widget extension are separate processes, you need a shared storage mechanism. There are two main approaches depending on how much data you're working with.
Shared UserDefaults for Simple Data
For lightweight data like counters, preferences, or small arrays, UserDefaults with your App Group suite name works great:
enum SharedTaskStore {
private static let defaults = UserDefaults(suiteName: "group.com.yourcompany.taskapp")!
static var remainingCount: Int {
get { defaults.integer(forKey: "remainingCount") }
set {
defaults.set(newValue, forKey: "remainingCount")
WidgetCenter.shared.reloadTimelines(ofKind: "TaskCountWidget")
}
}
static var completedCount: Int {
get { defaults.integer(forKey: "completedCount") }
set { defaults.set(newValue, forKey: "completedCount") }
}
static func completeNextTask() {
guard remainingCount > 0 else { return }
remainingCount -= 1
completedCount += 1
}
}
Notice how the remainingCount setter calls WidgetCenter.shared.reloadTimelines. This ensures the widget updates immediately when the app modifies the data. It's a small but critical detail that's easy to forget.
Shared File Container for Complex Data
For richer data like full task lists, you'll want to encode models to JSON in the shared container:
extension SharedTaskStore {
private static var containerURL: URL {
FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.yourcompany.taskapp"
)!
}
private static var tasksFileURL: URL {
containerURL.appendingPathComponent("tasks.json")
}
static func saveTasks(_ tasks: [TaskItem]) {
let data = try? JSONEncoder().encode(tasks)
try? data?.write(to: tasksFileURL)
WidgetCenter.shared.reloadTimelines(ofKind: "ConfigurableTaskWidget")
}
static func loadTasks() -> [TaskItem] {
guard let data = try? Data(contentsOf: tasksFileURL),
let tasks = try? JSONDecoder().decode([TaskItem].self, from: data) else {
return []
}
return tasks
}
static func tasks(filter: TaskFilterOption, includeCompleted: Bool) -> [TaskItem] {
var result = loadTasks()
if !includeCompleted {
result = result.filter { !$0.isCompleted }
}
switch filter {
case .today:
result = result.filter { Calendar.current.isDateInToday($0.dueDate ?? .distantFuture) }
case .thisWeek:
let weekStart = Calendar.current.startOfWeek(for: .now)
let weekEnd = Calendar.current.date(byAdding: .day, value: 7, to: weekStart)!
result = result.filter {
guard let due = $0.dueDate else { return false }
return due >= weekStart && due < weekEnd
}
case .highPriority:
result = result.filter { $0.priority == .high || $0.priority == .urgent }
case .all:
break
}
return result
}
}
iOS 26 Glass Rendering and Accented Mode
iOS 26 introduces the Liquid Glass design language, and widgets are a first-class participant. When a user chooses a tinted or clear Home Screen appearance, the system automatically transforms your widget's visual presentation. Understanding how this works lets you create widgets that look stunning in every mode.
How Accented Rendering Works
In accented rendering mode, the system tints your widget content to white and replaces the background with a themed glass effect. Here's the good news: this happens automatically — your existing widget will work without any changes.
But you can control the behavior for a more polished result. Use the widgetRenderingMode environment value to detect the current mode and adapt:
struct AdaptiveWidgetView: View {
var entry: TaskCountEntry
@Environment(\.widgetRenderingMode) var renderingMode
var body: some View {
VStack(alignment: .leading) {
HStack {
Image(systemName: "checklist")
.font(.title2)
.foregroundStyle(iconColor)
Spacer()
Text("\(entry.remainingTasks)")
.font(.system(size: 36, weight: .bold, design: .rounded))
}
Spacer()
Text("tasks remaining")
.font(.caption)
.foregroundStyle(.secondary)
if renderingMode == .fullColor {
ProgressView(value: entry.completionPercentage)
.tint(.blue)
}
}
.padding()
}
private var iconColor: Color {
switch renderingMode {
case .fullColor:
return .blue
case .accented:
return .primary
default:
return .primary
}
}
}
Controlling Image Rendering
The widgetAccentedRenderingMode modifier gives you fine-grained control over how images appear in accented mode:
// Blends with the glass effect using luminance mapping
Image("taskIcon")
.widgetAccentedRenderingMode(.desaturated)
// Tinted to match the accent color group
Image("categoryBadge")
.widgetAccentedRenderingMode(.accentedDesaturated)
// Preserves full color — use for media content only
Image("albumArt")
.widgetAccentedRenderingMode(.fullColor)
For most widgets, .desaturated or .accentedDesaturated creates the best visual harmony with the glass Home Screen. Reserve .fullColor for content images like album artwork or book covers where color is essential to recognition.
Setting the Container Background
The containerBackground modifier defines what the system shows behind your widget content. In glass mode, the system replaces this with a glass effect, but in full-color mode your background shows through:
.containerBackground(for: .widget) {
LinearGradient(
colors: [.blue.opacity(0.15), .purple.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
If your widget's design absolutely requires the custom background to always appear, you can opt out of automatic removal:
.containerBackgroundRemovable(false)
Use this sparingly though — most widgets look better when they harmonize with the system's glass treatment rather than fighting it.
Push Updates for Widgets
New in iOS 26, WidgetKit supports server-driven push updates. This is a game-changer for widgets that display remote data — delivery tracking, sports scores, stock prices — where changes happen outside the user's device and you need the widget to update right away.
How Push Updates Work
The flow is surprisingly straightforward:
- Your widget extension registers for push tokens and sends them to your server.
- When data changes on your server, you send a push notification to the widget's push token.
- WidgetKit wakes your extension and requests a new timeline.
- Your provider fetches fresh data and returns updated entries.
This is fundamentally different from the app-level push notification flow. Widget push tokens are separate from your app's APNs tokens, and they target the widget extension directly.
Implementing Push Updates
First, enable push updates in your widget configuration:
struct PushEnabledWidget: Widget {
let kind: String = "DeliveryTrackerWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: DeliveryWidgetConfigIntent.self,
provider: DeliveryProvider()
) { entry in
DeliveryWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Delivery Tracker")
.description("Track your active deliveries.")
.supportedFamilies([.systemSmall, .systemMedium])
.pushUpdatesEnabled()
}
}
Then handle the push token in your provider by implementing the onPushTokenUpdate callback to send the token to your server. When your server sends a push to that token, WidgetKit wakes your extension and calls your timeline method to fetch fresh data.
Keep in mind that push updates are still subject to the system's budget. During development, enable WidgetKit Developer Mode in Settings → Developer → WidgetKit Developer Mode to bypass budget limitations for testing. You'll thank yourself later.
Widget Families and Adaptive Layouts
WidgetKit supports a range of widget sizes across platforms. Designing for each family means adapting your layout, information density, and interactive controls to fit the available space.
Available Families
.systemSmall— Square widget. Focus on a single piece of information or one action. The entire widget is tappable as a deep link..systemMedium— Wide rectangle. Room for a list of 2-4 items or a primary stat with supplementary info. Supports buttons and toggles..systemLarge— Tall rectangle. Space for detailed lists, charts, or rich layouts. Can include multiple interactive controls..systemExtraLarge— Extra-wide, available on iPad and macOS. Ideal for dashboard-style layouts..accessoryCircular,.accessoryRectangular,.accessoryInline— Lock Screen and watchOS complications. Minimal, glanceable info only.
Adaptive Layout Pattern
Use the widgetFamily environment value to choose between layouts, and design each one independently:
struct TaskWidgetAdaptiveView: View {
var entry: TaskListEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
CompactTaskView(entry: entry)
case .systemMedium:
MediumTaskView(entry: entry)
case .systemLarge:
DetailedTaskView(entry: entry)
case .accessoryRectangular:
LockScreenTaskView(entry: entry)
case .accessoryCircular:
CircularTaskBadge(entry: entry)
default:
CompactTaskView(entry: entry)
}
}
}
struct LockScreenTaskView: View {
var entry: TaskListEntry
var body: some View {
VStack(alignment: .leading) {
Text("\(entry.remainingCount) tasks")
.font(.headline)
.widgetAccentable()
Text("Next: \(entry.tasks.first?.title ?? "None")")
.font(.caption)
}
}
}
The widgetAccentable() modifier tells the system which elements should receive the accent color on the Lock Screen. It's a small thing, but it makes your widget stand out in the right way.
Deep Linking from Widgets
Small widgets are tappable as a single unit — the entire widget is one link. For medium and larger widgets, you can attach different deep links to different parts of the view.
// Entire small widget links to a URL
struct CompactTaskView: View {
var entry: TaskListEntry
var body: some View {
VStack {
Text("\(entry.remainingCount)")
.font(.largeTitle.bold())
Text("remaining")
.font(.caption)
}
.widgetURL(URL(string: "taskapp://today")!)
}
}
// Medium widget with per-item links
struct MediumTaskView: View {
var entry: TaskListEntry
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(entry.tasks.prefix(3)) { task in
Link(destination: URL(string: "taskapp://task/\(task.id)")!) {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
Text(task.title)
.font(.caption)
.lineLimit(1)
}
}
}
}
.padding()
}
}
Handle these URLs in your main app using the onOpenURL modifier on your root view or scene. Don't forget to actually implement the routing logic — I've seen a few projects where the deep links were set up in the widget but never handled on the app side.
Performance Best Practices
Widgets run under strict system constraints. Ignoring these leads to blank widgets, killed extensions, or frustrated users staring at stale data. Here's what you need to keep in mind.
Respect the Refresh Budget
Each widget gets a daily refresh budget — typically 40 to 70 reloads per day. That averages to roughly one refresh every 15 to 60 minutes. The budget is dynamic and depends on how often the widget is actually visible on screen.
- Use
.neverreload policy and trigger reloads from your app only when data actually changes. - Pre-populate your timeline with future entries when you can predict changes (hourly weather, upcoming calendar events).
- Avoid calling
WidgetCenter.shared.reloadAllTimelines()— reload only the specific widget kind that changed.
Keep the Extension Lightweight
Widget extensions have a strict memory limit — roughly 30MB. Exceeding it triggers a system kill, and users end up with a blank widget. To stay within budget:
- Don't import heavy frameworks unnecessarily in your widget target.
- Avoid loading large images. Use SF Symbols or small, optimized assets.
- Keep your data model small — the widget only needs the data it actually displays.
Handle the Widget Lifecycle Correctly
A common mistake is putting expensive work in the wrong place:
- Placeholder should be instant — return hardcoded sample data. No network calls, no database reads.
- Snapshot should be fast — it's used for the widget gallery. If data is available, use it; otherwise, return sample data.
- Timeline can do async work — fetch from the network, read from the database, compute derived values. But keep it under 30 seconds or the system will terminate your extension.
Test with WidgetKit Developer Mode
During development, enable WidgetKit Developer Mode in Settings → Developer → WidgetKit Developer Mode. This bypasses budget limitations so you can trigger rapid reloads for testing without hitting the daily cap. Just remember to test with it off before shipping — you want to make sure your widget behaves well under real-world constraints too.
Bundling Multiple Widgets
Most apps benefit from offering multiple widget types. Use a WidgetBundle to group them together:
@main
struct TaskWidgetBundle: WidgetBundle {
var body: some Widget {
TaskCountWidget()
ConfigurableTaskWidget()
PushEnabledWidget()
}
}
Each widget in the bundle gets its own kind string, its own timeline provider, and its own configuration. Users can add any combination of them to their Home Screen.
Frequently Asked Questions
Can I use SwiftData or Core Data in a widget?
Yes, but with caveats. The widget extension is a separate process, so you need to configure your ModelContainer or NSPersistentContainer to use the App Group's shared container URL for its database file. For SwiftData, pass the shared container URL when configuring your ModelConfiguration. Keep queries lightweight — widgets should read data, not write heavy operations.
Why does my widget show a blank or placeholder view?
This almost always means your widget extension crashed or exceeded its memory budget. Check the console in Xcode for crash logs from your widget extension process. Common causes include importing unnecessary frameworks, loading oversized images, or performing too much work in the placeholder method. Enable WidgetKit Developer Mode and use Instruments to profile memory usage. In my experience, the culprit is usually an image that's way too large.
How many times can a widget refresh per day?
The daily budget is typically 40 to 70 reloads, depending on how frequently the widget is visible. Widgets that are viewed often get a larger budget. Server push updates in iOS 26 also count against this budget, so plan your refresh strategy carefully. Using a .never reload policy with explicit reloadTimelines calls from your app gives you the most control over when refreshes actually happen.
Do interactive widgets work on the Lock Screen?
Interactive controls (Button and Toggle) work in Home Screen and StandBy widget families (.systemSmall, .systemMedium, .systemLarge). Lock Screen accessory widgets (.accessoryCircular, .accessoryRectangular, .accessoryInline) don't support interactive controls — they're display-only and tapping them opens the app. Also, on a locked device, interactive controls on Home Screen widgets are inactive until the user unlocks.
What is the difference between StaticConfiguration and AppIntentConfiguration?
StaticConfiguration creates a widget with no user-configurable options — it shows the same content for every instance. AppIntentConfiguration lets users customize the widget through a configuration interface that appears when they long-press and tap "Edit Widget." If your widget benefits from any user choice at all (which list to show, which account to use, which metric to display), go with AppIntentConfiguration. It's a bit more setup, but the user experience is worth it.