
Widget Generator
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates WidgetKit widgets for iOS/macOS home and lock screen with timeline providers, interactive elements, and App Intent configuration.
About
Generates a complete WidgetKit widget implementation with timeline providers, size-specific views, lock screen accessory widgets, interactive elements, and App Intent configuration. A developer uses it when adding home or lock screen widgets to an app.
- Timeline providers and size-specific views
- Lock screen widgets and interactive elements
Widget Generator by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill widget-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates WidgetKit widgets for iOS/macOS home and lock screen with timeline providers, interactive elements, and App Intent configuration.
Files
Widget Generator
Generate a complete WidgetKit widget implementation with timeline providers, size-specific views, lock screen accessory widgets, interactive elements (iOS 17+), and App Intent configuration.
When This Skill Activates
Use this skill when the user:
- Asks to "add widgets" or "add a widget" to their app
- Mentions "WidgetKit" or "home screen widgets"
- Wants "lock screen widgets" or "accessory widgets"
- Asks about "widget timelines" or "timeline providers"
- Wants "interactive widgets" with buttons or toggles
- Mentions "widget configuration" or "configurable widgets"
- Asks about "App Intent widgets" or "AppIntentConfiguration"
- Wants to show data on the home screen or lock screen
Pre-Generation Checks
1. Project Context Detection
- [ ] Check for existing widget extension target
- [ ] Check for an existing
WidgetBundle - [ ] Verify deployment target (iOS 17+ recommended for interactive widgets)
- [ ] Identify source file locations and project structure
2. Conflict Detection
Search for existing widget code:
Glob: **/*Widget*.swift, **/*TimelineProvider*.swift
Grep: "WidgetKit" or "TimelineProvider" or "WidgetBundle" or "WidgetConfiguration"If an existing widget extension is found:
- Ask if the new widget should be added to the existing widget extension
- Check for an existing
WidgetBundleto extend
If a WidgetBundle already exists:
- Add the new widget to the existing bundle instead of creating a new one
- Do NOT create a second
@mainentry point
If widget code with the same name exists:
- Ask user whether to replace or rename
3. Required Capabilities
Widgets require:
- A widget extension target (File > New > Target > Widget Extension)
- App Groups capability if sharing data between the main app and widget
- iOS 14+ for basic widgets, iOS 16+ for lock screen, iOS 17+ for interactive widgets
Configuration Questions
Ask user via AskUserQuestion:
1. What is this widget for? (freeform)
- Examples: weather forecast, task list, fitness stats, quick actions, countdown timer
- This determines the data model and timeline update strategy
2. Which widget sizes should be supported?
- Home screen: systemSmall, systemMedium, systemLarge, systemExtraLarge (iPad only)
- Lock screen: accessoryCircular, accessoryRectangular, accessoryInline
- All home screen sizes
- All home screen + lock screen sizes (recommended)
3. What type of widget?
- Static (
StaticConfiguration) -- content updated on a schedule, no user configuration - Configurable (
AppIntentConfiguration, iOS 17+) -- user can choose what the widget displays via long-press edit - Interactive (
AppIntentConfiguration+Button/Toggle, iOS 17+) -- user can tap buttons or toggles directly on the widget
4. What is the data source?
- Local data (UserDefaults, SwiftData, Core Data)
- Shared data via App Groups (main app writes, widget reads)
- Network API (fetched during timeline refresh)
- Combination of local and network
5. How often should the widget update?
- Every 15 minutes (minimum practical interval)
- Every 30 minutes
- Every hour (recommended default)
- A few times per day
- Based on specific times (e.g., calendar events)
- On-demand from the main app via
WidgetCenter.shared.reloadTimelines(ofKind:)
Generation Process
Step 1: Determine File Locations
Check project structure:
- If a widget extension target directory exists, add view and provider files there
- Otherwise, instruct user to create a Widget Extension target first
For widget extension files:
- Place inside the existing widget extension directory (e.g.,
MyAppWidgets/)
For shared data models (if using App Groups):
- If
Sources/orShared/exists --> place there - Otherwise --> create alongside existing models
Step 2: Create Core Files
Generate these files based on configuration answers:
1. `{Name}Widget.swift` -- Widget definition with configuration
Widgetstruct withStaticConfigurationorAppIntentConfiguration- Supported families declaration
- Display name and description
2. `{Name}TimelineProvider.swift` -- Timeline logic
TimelineProvider(static) orAppIntentTimelineProvider(configurable)- Placeholder, snapshot, and timeline methods
3. `{Name}Entry.swift` -- Timeline entry model
TimelineEntrystruct with date and display data
4. `{Name}WidgetViews.swift` -- Size-specific views
- Separate view struct for each supported family
- Uses
containerBackgroundfor iOS 17+ removable backgrounds
5. `{Name}AppIntent.swift` (if interactive or configurable)
WidgetConfigurationIntentfor configurable widgetsAppIntentfor interactive widget buttons/toggles
6. WidgetBundle update -- Register the new widget
- Add to existing bundle or create new one
Step 3: Generate Code from Templates
Use the templates in templates.md and customize based on user answers:
- Replace placeholder names with the actual widget name
- Configure supported families based on size selection
- Include or exclude interactive elements
- Include or exclude lock screen accessory views
- Set up App Group shared data access if needed
- Configure timeline refresh policy based on update frequency
Output Format
After generation, provide:
Files Created
MyAppWidgets/
├── {Name}Widget.swift # Widget definition + configuration
├── {Name}TimelineProvider.swift # Timeline provider with placeholder/snapshot/timeline
├── {Name}Entry.swift # TimelineEntry data model
├── {Name}WidgetViews.swift # Size-specific views for each family
├── {Name}AppIntent.swift # (if configurable/interactive) App Intent
└── (update WidgetBundle if needed)
Shared/
└── {Name}DataProvider.swift # (if App Groups) Shared data accessIntegration Steps
1. Add the widget extension target (if not present):
- File > New > Target > Widget Extension
- Choose "Include Configuration App Intent" if configurable
- Ensure the widget extension embeds in the main app
2. Enable App Groups (if sharing data with the main app):
- Select the main app target > Signing & Capabilities > + Capability > App Groups
- Select the widget extension target > Signing & Capabilities > + Capability > App Groups
- Use the same group identifier (e.g.,
group.com.yourcompany.yourapp)
3. Register the widget in the WidgetBundle:
@main
struct MyAppWidgets: WidgetBundle {
var body: some Widget {
// Existing widgets...
{Name}Widget()
}
}4. Trigger widget updates from the main app when data changes:
import WidgetKit
// Reload a specific widget
WidgetCenter.shared.reloadTimelines(ofKind: "{Name}Widget")
// Or reload all widgets
WidgetCenter.shared.reloadAllTimelines()5. For App Group data sharing, write from the main app:
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")
sharedDefaults?.set(encodedData, forKey: "widgetData")
// Then trigger reload
WidgetCenter.shared.reloadTimelines(ofKind: "{Name}Widget")Testing Instructions
1. Simulator support: Widgets can be previewed in Xcode Canvas and tested in Simulator. 2. Add to home screen: Long-press the home screen > tap "+" > find your app > select the widget size. 3. Lock screen widgets: Long-press the lock screen > "Customize" > select the widget area. 4. Preview in Xcode: Use #Preview with timeline entry data for rapid iteration. 5. Timeline debugging: Use WidgetCenter.shared.getCurrentConfigurations to verify registered widgets. 6. Interactive widget testing (iOS 17+): Tap buttons/toggles directly on the widget in Simulator or device. 7. Memory profiling: Widgets have a 40MB memory limit. Profile in Instruments if loading images or large datasets.
Common Widget Patterns
Weather Widget
- Data: Temperature, condition icon, hourly forecast
- Sizes: systemSmall (current temp), systemMedium (hourly), accessoryCircular (temp gauge)
- Update: Every 30 minutes via network API
- Timeline: Generate entries for next few hours with forecast data
Calendar / Events Widget
- Data: Upcoming events, times, locations
- Sizes: systemSmall (next event), systemMedium (next 3 events), accessoryRectangular (next event)
- Update: Based on event start times using
.after(nextEventDate)policy - Timeline: One entry per upcoming event transition
Fitness / Health Widget
- Data: Steps, calories, activity rings
- Sizes: systemSmall (ring summary), accessoryCircular (ring gauge), accessoryRectangular (stats)
- Update: Every 15-30 minutes from HealthKit via App Groups
- Interactive: None (read-only data display)
Quick Actions Widget
- Data: Action buttons (start timer, toggle light, log water)
- Sizes: systemSmall (single action), systemMedium (2-4 actions)
- Update: Infrequent (actions are static, only state changes)
- Interactive:
Button(intent:)for each action (iOS 17+)
Countdown Widget
- Data: Target date, label, time remaining
- Sizes: systemSmall (days remaining), accessoryCircular (days number), accessoryInline (text countdown)
- Update: Daily or use
Text(date, style: .timer)/Text(date, style: .relative)for automatic live updates - Timeline: SwiftUI date styles update automatically without timeline refreshes
Gotchas and Limits
- Timeline budget: The system limits how often your timeline provider runs. Typically ~40-70 refreshes per day. Do not rely on exact timing.
- 40MB memory limit: Widget extensions are killed if they exceed 40MB. Avoid loading large images or datasets. Use thumbnails and minimal data.
- `containerBackground` required (iOS 17+): All widget views must use
.containerBackground(for: .widget)to support the system's removable background feature. Without this, widgets show a default placeholder background. - Accessory family rendering: Lock screen widgets render in a limited color space. Use
AccessoryWidgetBackground()for backgrounds and keep designs simple with high contrast. - No animation: Widgets do not support explicit animations. Use
Text(date, style: .timer)for countdowns; the system animates these for you. - No scrolling: Widgets cannot scroll. Design for fixed, visible content.
- No video or maps: MapKit and AVKit are not available in widget extensions.
- Networking in timeline provider: Network requests in
getTimelinemust complete quickly. The system may terminate long-running providers. - `@main` conflict: Only one
@mainper widget extension. If you have multiple widgets, use aWidgetBundleas the single@mainentry point. - Configurable widget data persistence:
AppIntentparameter values are stored by the system. Do not rely on UserDefaults for configuration state. - Xcode previews: Use
#Preview(as: .systemSmall)for family-specific widget previews. - Shared code with main app: Timeline entries and data models referenced by both targets must have target membership in both the main app and the widget extension.
References
- templates.md -- Production-ready code templates for widget definition, timeline provider, views, and App Intents
- WidgetKit Documentation
- Creating a Widget Extension
- Making a Configurable Widget
- Adding Interactivity to Widgets
- WidgetKit Human Interface Guidelines
Widget Code Templates
Production-ready Swift templates for WidgetKit widgets. All code targets iOS 17+ and uses modern Swift concurrency with App Intents.
Widget Definition (StaticConfiguration)
Use this for widgets that do not require user configuration.
import SwiftUI
import WidgetKit
/// A widget that displays {description}.
struct MyWidget: Widget {
let kind = "MyWidget"
var body: some WidgetConfiguration {
StaticConfiguration(
kind: kind,
provider: MyTimelineProvider()
) { entry in
MyWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("My Widget")
.description("Shows important information at a glance.")
.supportedFamilies([
.systemSmall,
.systemMedium,
.systemLarge,
.accessoryCircular,
.accessoryRectangular,
.accessoryInline
])
.contentMarginsDisabled() // Optional: remove default content margins
}
}Widget Definition (AppIntentConfiguration)
Use this for configurable and interactive widgets (iOS 17+). The user can long-press the widget and choose what it displays.
import SwiftUI
import WidgetKit
/// A configurable widget that displays {description}.
struct MyWidget: Widget {
let kind = "MyWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: MyWidgetConfigurationIntent.self,
provider: MyAppIntentTimelineProvider()
) { entry in
MyWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("My Widget")
.description("Shows information you choose.")
.supportedFamilies([
.systemSmall,
.systemMedium,
.systemLarge,
.accessoryCircular,
.accessoryRectangular,
.accessoryInline
])
}
}Timeline Entry
The data model that the timeline provider produces and the views consume.
import WidgetKit
import Foundation
/// A single entry in the widget timeline.
struct MyWidgetEntry: TimelineEntry {
/// The date at which the widget should be displayed.
let date: Date
/// The data to display. Use an enum or optional to handle loading/error states.
let title: String
let value: String
let subtitle: String?
let progress: Double?
let iconName: String
/// Whether this is placeholder data for the widget gallery.
let isPlaceholder: Bool
// MARK: - Convenience Initializers
/// Placeholder entry shown in the widget gallery before data loads.
static var placeholder: MyWidgetEntry {
MyWidgetEntry(
date: .now,
title: "Widget Title",
value: "42",
subtitle: "Subtitle text",
progress: 0.65,
iconName: "star.fill",
isPlaceholder: true
)
}
/// Snapshot entry for the widget picker and gallery.
static var snapshot: MyWidgetEntry {
MyWidgetEntry(
date: .now,
title: "Today's Progress",
value: "1,234",
subtitle: "Steps today",
progress: 0.65,
iconName: "figure.walk",
isPlaceholder: false
)
}
}Timeline Provider (Static)
For StaticConfiguration widgets with no user configuration.
import WidgetKit
import Foundation
/// Provides timeline entries for the widget.
///
/// The system calls these methods at different times:
/// - `placeholder`: Immediately, for the widget gallery redacted preview.
/// - `getSnapshot`: When showing the widget in the gallery or during transitions.
/// - `getTimeline`: When the widget needs fresh data. The system manages call frequency.
struct MyTimelineProvider: TimelineProvider {
typealias Entry = MyWidgetEntry
/// Provides a placeholder entry used in the widget gallery.
/// This is displayed with a redacted modifier. Return instantly with representative data.
func placeholder(in context: Context) -> MyWidgetEntry {
.placeholder
}
/// Provides a snapshot for the widget gallery and transient situations.
/// Should return quickly. Use sample data if real data is not immediately available.
func getSnapshot(in context: Context, completion: @escaping (MyWidgetEntry) -> Void) {
if context.isPreview {
completion(.snapshot)
return
}
Task {
let entry = await fetchCurrentEntry()
completion(entry)
}
}
/// Provides a timeline of entries for the widget to display over time.
/// Include a reload policy so the system knows when to ask for new data.
func getTimeline(in context: Context, completion: @escaping (Timeline<MyWidgetEntry>) -> Void) {
Task {
let currentEntry = await fetchCurrentEntry()
// Option 1: Single entry, refresh after a fixed interval
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
let timeline = Timeline(entries: [currentEntry], policy: .after(nextUpdate))
// Option 2: Multiple entries for known future states
// var entries: [MyWidgetEntry] = []
// for hourOffset in 0..<5 {
// let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: .now)!
// let entry = MyWidgetEntry(date: entryDate, ...)
// entries.append(entry)
// }
// let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
// MARK: - Data Fetching
/// Fetch the current data for the widget.
private func fetchCurrentEntry() async -> MyWidgetEntry {
// Replace with real data fetching logic:
// - Read from App Group shared UserDefaults
// - Query a local database (SwiftData, Core Data)
// - Make a network request (keep it fast)
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")
let title = sharedDefaults?.string(forKey: "widgetTitle") ?? "No Data"
let value = sharedDefaults?.string(forKey: "widgetValue") ?? "--"
return MyWidgetEntry(
date: .now,
title: title,
value: value,
subtitle: nil,
progress: nil,
iconName: "star.fill",
isPlaceholder: false
)
}
}Timeline Provider (AppIntentConfiguration)
For AppIntentConfiguration widgets where the user can choose what to display.
import WidgetKit
import AppIntents
import Foundation
/// Provides timeline entries for a configurable widget.
///
/// The `configuration` parameter on each method gives access to the user's
/// chosen intent values (e.g., which category to display).
struct MyAppIntentTimelineProvider: AppIntentTimelineProvider {
typealias Entry = MyWidgetEntry
typealias Intent = MyWidgetConfigurationIntent
func placeholder(in context: Context) -> MyWidgetEntry {
.placeholder
}
func snapshot(for configuration: MyWidgetConfigurationIntent, in context: Context) async -> MyWidgetEntry {
if context.isPreview {
return .snapshot
}
return await fetchEntry(for: configuration)
}
func timeline(for configuration: MyWidgetConfigurationIntent, in context: Context) async -> Timeline<MyWidgetEntry> {
let entry = await fetchEntry(for: configuration)
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
return Timeline(entries: [entry], policy: .after(nextUpdate))
}
// MARK: - Data Fetching
private func fetchEntry(for configuration: MyWidgetConfigurationIntent) async -> MyWidgetEntry {
// Use configuration.category (or other intent parameters) to filter data
let category = configuration.category
// Fetch data based on the user's configuration choice
return MyWidgetEntry(
date: .now,
title: category.displayName,
value: "42",
subtitle: "Updated just now",
progress: 0.65,
iconName: category.iconName,
isPlaceholder: false
)
}
}Widget Views (All Families)
Each widget family gets its own view struct to keep the type checker fast and layouts clean.
import SwiftUI
import WidgetKit
// MARK: - Entry View Router
/// Routes to the correct view based on the widget family.
struct MyWidgetEntryView: View {
let entry: MyWidgetEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
case .systemLarge:
LargeWidgetView(entry: entry)
case .accessoryCircular:
AccessoryCircularView(entry: entry)
case .accessoryRectangular:
AccessoryRectangularView(entry: entry)
case .accessoryInline:
AccessoryInlineView(entry: entry)
default:
SmallWidgetView(entry: entry)
}
}
}
// MARK: - System Small
/// Compact view for the small home screen widget.
/// Design: Single focused piece of information with an icon.
struct SmallWidgetView: View {
let entry: MyWidgetEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Image(systemName: entry.iconName)
.font(.title2)
.foregroundStyle(.blue)
Spacer()
Text(entry.value)
.font(.title.bold())
.minimumScaleFactor(0.6)
Text(entry.title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
// MARK: - System Medium
/// Horizontal layout for the medium home screen widget.
/// Design: Icon and value on the left, details on the right.
struct MediumWidgetView: View {
let entry: MyWidgetEntry
var body: some View {
HStack(spacing: 16) {
// Left: icon and primary value
VStack(alignment: .leading, spacing: 4) {
Image(systemName: entry.iconName)
.font(.title2)
.foregroundStyle(.blue)
Spacer()
Text(entry.value)
.font(.title.bold())
Text(entry.title)
.font(.caption)
.foregroundStyle(.secondary)
}
Divider()
// Right: additional details
VStack(alignment: .leading, spacing: 8) {
if let subtitle = entry.subtitle {
Text(subtitle)
.font(.subheadline)
}
if let progress = entry.progress {
ProgressView(value: progress)
.tint(.blue)
Text("\(Int(progress * 100))% complete")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
}
.padding()
}
}
// MARK: - System Large
/// Full-size view for the large home screen widget.
/// Design: Header with value, then a content area with detailed information.
struct LargeWidgetView: View {
let entry: MyWidgetEntry
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Header
HStack {
Image(systemName: entry.iconName)
.font(.title2)
.foregroundStyle(.blue)
VStack(alignment: .leading) {
Text(entry.title)
.font(.headline)
if let subtitle = entry.subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(entry.value)
.font(.title.bold())
}
Divider()
// Content area
if let progress = entry.progress {
VStack(alignment: .leading, spacing: 6) {
Text("Progress")
.font(.subheadline.weight(.medium))
ProgressView(value: progress)
.tint(.blue)
Text("\(Int(progress * 100))% of daily goal")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
// Footer
HStack {
Text("Updated \(entry.date, style: .relative) ago")
.font(.caption2)
.foregroundStyle(.tertiary)
Spacer()
}
}
.padding()
}
}
// MARK: - Accessory Circular (Lock Screen)
/// Circular lock screen widget.
/// Design: Gauge or single icon with a small label.
struct AccessoryCircularView: View {
let entry: MyWidgetEntry
var body: some View {
if let progress = entry.progress {
Gauge(value: progress) {
Image(systemName: entry.iconName)
} currentValueLabel: {
Text(entry.value)
.font(.system(.caption2, design: .rounded).bold())
}
.gaugeStyle(.accessoryCircularCapacity)
} else {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 2) {
Image(systemName: entry.iconName)
.font(.title3)
Text(entry.value)
.font(.system(.caption2, design: .rounded).bold())
}
}
}
}
}
// MARK: - Accessory Rectangular (Lock Screen)
/// Rectangular lock screen widget.
/// Design: Small label with a value, optionally a gauge bar.
struct AccessoryRectangularView: View {
let entry: MyWidgetEntry
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: entry.iconName)
Text(entry.title)
.font(.headline)
.lineLimit(1)
}
.widgetAccentable()
Text(entry.value)
.font(.system(.title3, design: .rounded).bold())
if let progress = entry.progress {
ProgressView(value: progress)
}
}
}
}
// MARK: - Accessory Inline (Lock Screen)
/// Inline lock screen widget (single line of text above the clock).
/// Design: Icon and short text, very constrained space.
struct AccessoryInlineView: View {
let entry: MyWidgetEntry
var body: some View {
Label {
Text("\(entry.title): \(entry.value)")
} icon: {
Image(systemName: entry.iconName)
}
}
}
// MARK: - Previews
#Preview("Small", as: .systemSmall) {
MyWidget()
} timeline: {
MyWidgetEntry.snapshot
}
#Preview("Medium", as: .systemMedium) {
MyWidget()
} timeline: {
MyWidgetEntry.snapshot
}
#Preview("Large", as: .systemLarge) {
MyWidget()
} timeline: {
MyWidgetEntry.snapshot
}
#Preview("Circular", as: .accessoryCircular) {
MyWidget()
} timeline: {
MyWidgetEntry.snapshot
}
#Preview("Rectangular", as: .accessoryRectangular) {
MyWidget()
} timeline: {
MyWidgetEntry.snapshot
}
#Preview("Inline", as: .accessoryInline) {
MyWidget()
} timeline: {
MyWidgetEntry.snapshot
}Interactive Widget with AppIntent (iOS 17+)
Buttons and toggles that execute actions directly on the widget without opening the app.
Interactive Button
import SwiftUI
import AppIntents
import WidgetKit
// MARK: - App Intent for Button Action
/// An intent that performs an action when the user taps a button on the widget.
struct ToggleTaskIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Task"
static var description: IntentDescription = "Marks a task as complete or incomplete."
/// The ID of the task to toggle. Passed from the widget button.
@Parameter(title: "Task ID")
var taskID: String
init() {}
init(taskID: String) {
self.taskID = taskID
}
func perform() async throws -> some IntentResult {
// 1. Read current state from shared storage
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")
var completedTasks = sharedDefaults?.stringArray(forKey: "completedTasks") ?? []
// 2. Toggle the task
if completedTasks.contains(taskID) {
completedTasks.removeAll { $0 == taskID }
} else {
completedTasks.append(taskID)
}
// 3. Write updated state back
sharedDefaults?.set(completedTasks, forKey: "completedTasks")
// 4. Reload the widget timeline to reflect the change
WidgetCenter.shared.reloadTimelines(ofKind: "MyTaskWidget")
return .result()
}
}
// MARK: - Interactive Widget View
/// A widget view with tappable buttons that execute App Intents.
struct InteractiveTaskWidgetView: View {
let entry: TaskWidgetEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Tasks")
.font(.headline)
ForEach(entry.tasks) { task in
Button(intent: ToggleTaskIntent(taskID: task.id)) {
HStack(spacing: 8) {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(task.isCompleted ? .green : .secondary)
Text(task.title)
.strikethrough(task.isCompleted)
.foregroundStyle(task.isCompleted ? .secondary : .primary)
Spacer()
}
}
.buttonStyle(.plain)
}
}
.padding()
}
}Interactive Toggle
import SwiftUI
import AppIntents
import WidgetKit
/// An intent that toggles a boolean setting.
struct ToggleFeatureIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Feature"
static var description: IntentDescription = "Turns a feature on or off."
@Parameter(title: "Enabled")
var isEnabled: Bool
init() {}
init(isEnabled: Bool) {
self.isEnabled = isEnabled
}
func perform() async throws -> some IntentResult {
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")
sharedDefaults?.set(isEnabled, forKey: "featureEnabled")
WidgetCenter.shared.reloadTimelines(ofKind: "MyToggleWidget")
return .result()
}
}
/// A widget with a toggle control.
struct ToggleWidgetView: View {
let entry: ToggleWidgetEntry
var body: some View {
VStack(spacing: 12) {
Image(systemName: entry.isEnabled ? "bolt.fill" : "bolt.slash")
.font(.largeTitle)
.foregroundStyle(entry.isEnabled ? .yellow : .secondary)
Toggle(isOn: entry.isEnabled, intent: ToggleFeatureIntent(isEnabled: !entry.isEnabled)) {
Text(entry.isEnabled ? "Enabled" : "Disabled")
.font(.caption)
}
.toggleStyle(.switch)
.tint(.blue)
}
.padding()
}
}Widget Configuration Intent (iOS 17+)
Allows the user to choose what the widget displays via the long-press edit interface.
import AppIntents
import Foundation
/// Configuration intent that lets users choose what the widget displays.
///
/// When the user long-presses the widget and taps "Edit Widget", the system
/// presents UI generated from these parameters.
struct MyWidgetConfigurationIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Configure Widget"
static var description: IntentDescription = "Choose what information the widget shows."
/// The category to display in the widget.
@Parameter(title: "Category", default: .general)
var category: WidgetCategory
/// Whether to show detailed information.
@Parameter(title: "Show Details", default: true)
var showDetails: Bool
}
// MARK: - Category Enum
/// The categories available for widget configuration.
enum WidgetCategory: String, AppEnum {
case general
case fitness
case productivity
case finance
static var typeDisplayRepresentation: TypeDisplayRepresentation {
"Category"
}
static var caseDisplayRepresentations: [WidgetCategory: DisplayRepresentation] {
[
.general: "General",
.fitness: DisplayRepresentation(title: "Fitness", image: .init(systemName: "figure.walk")),
.productivity: DisplayRepresentation(title: "Productivity", image: .init(systemName: "checkmark.circle")),
.finance: DisplayRepresentation(title: "Finance", image: .init(systemName: "dollarsign.circle"))
]
}
/// The SF Symbol icon for this category.
var iconName: String {
switch self {
case .general: return "star.fill"
case .fitness: return "figure.walk"
case .productivity: return "checkmark.circle.fill"
case .finance: return "dollarsign.circle.fill"
}
}
/// Display name for UI.
var displayName: String {
switch self {
case .general: return "General"
case .fitness: return "Fitness"
case .productivity: return "Productivity"
case .finance: return "Finance"
}
}
}Dynamic Options (Query-Based)
For configuration parameters that load options dynamically (e.g., from a database):
import AppIntents
import Foundation
/// A selectable item for widget configuration, loaded from the app's data store.
struct SelectableItem: AppEntity {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Item"
static var defaultQuery = SelectableItemQuery()
var id: String
var name: String
var iconName: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: LocalizedStringResource(stringLiteral: name),
image: .init(systemName: iconName)
)
}
}
/// Query that provides selectable items for the widget configuration UI.
struct SelectableItemQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [SelectableItem] {
// Load items matching the given IDs from your data store
let allItems = loadItems()
return allItems.filter { identifiers.contains($0.id) }
}
func suggestedEntities() async throws -> [SelectableItem] {
// Return all available items for the picker
loadItems()
}
func defaultResult() async -> SelectableItem? {
loadItems().first
}
private func loadItems() -> [SelectableItem] {
// Load from App Group shared storage, database, etc.
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")
// Decode and return items...
return [
SelectableItem(id: "1", name: "Item One", iconName: "1.circle"),
SelectableItem(id: "2", name: "Item Two", iconName: "2.circle"),
]
}
}WidgetBundle Registration
Register multiple widgets in a single widget extension.
import SwiftUI
import WidgetKit
/// The widget bundle that registers all widgets in this extension.
///
/// Only one `@main` entry point is allowed per widget extension.
/// Add all widgets and Live Activities here.
@main
struct MyAppWidgets: WidgetBundle {
var body: some Widget {
// Home screen widgets
MyWidget()
MySecondWidget()
// Live Activities (if applicable)
// MyLiveActivity()
}
}If the widget extension contains only a single widget, the @main attribute can go directly on the Widget struct instead of using a WidgetBundle:
@main
struct MyWidget: Widget {
let kind = "MyWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: MyTimelineProvider()) { entry in
MyWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("My Widget")
.description("Shows important information.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}App Group Shared Data Access Pattern
Read and write data shared between the main app and the widget extension using App Groups.
Writing from the Main App
import Foundation
import WidgetKit
/// Provides write access to shared data for the widget.
///
/// Use this from the main app to update data that the widget reads.
final class WidgetDataWriter {
private let suiteName: String
private let sharedDefaults: UserDefaults?
init(suiteName: String = "group.com.yourcompany.yourapp") {
self.suiteName = suiteName
self.sharedDefaults = UserDefaults(suiteName: suiteName)
}
/// Write widget data and trigger a timeline reload.
/// - Parameters:
/// - data: The data to encode and store.
/// - key: The UserDefaults key.
/// - widgetKind: The widget kind string to reload. Pass nil to reload all.
func write<T: Encodable>(_ data: T, forKey key: String, widgetKind: String? = nil) {
guard let encoded = try? JSONEncoder().encode(data) else { return }
sharedDefaults?.set(encoded, forKey: key)
if let widgetKind {
WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
} else {
WidgetCenter.shared.reloadAllTimelines()
}
}
}Reading from the Widget Extension
import Foundation
/// Provides read access to shared data in the widget extension.
///
/// Use this from the timeline provider to read data written by the main app.
struct WidgetDataReader {
private let sharedDefaults: UserDefaults?
init(suiteName: String = "group.com.yourcompany.yourapp") {
self.sharedDefaults = UserDefaults(suiteName: suiteName)
}
/// Read and decode widget data.
/// - Parameters:
/// - type: The type to decode.
/// - key: The UserDefaults key.
/// - Returns: The decoded data, or nil if not found or decoding fails.
func read<T: Decodable>(_ type: T.Type, forKey key: String) -> T? {
guard let data = sharedDefaults?.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(type, from: data)
}
}Using Shared File Container
For larger data (images, databases), use the shared container directory:
import Foundation
/// Access the shared App Group container for file-based data.
struct SharedContainer {
static let appGroupID = "group.com.yourcompany.yourapp"
/// The shared container directory URL.
static var containerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
}
/// Write an image to the shared container for the widget to display.
static func writeImage(_ imageData: Data, named filename: String) {
guard let containerURL else { return }
let fileURL = containerURL.appendingPathComponent(filename)
try? imageData.write(to: fileURL)
}
/// Read an image from the shared container.
static func readImageData(named filename: String) -> Data? {
guard let containerURL else { return nil }
let fileURL = containerURL.appendingPathComponent(filename)
return try? Data(contentsOf: fileURL)
}
}containerBackground Usage (iOS 17+)
All widget views must apply a containerBackground so the system can remove or replace the background in StandBy mode, Lock Screen, and other contexts.
// MARK: - Standard Background
/// Apply to the outermost view in your widget entry view.
MyWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
// MARK: - Gradient Background
MyWidgetEntryView(entry: entry)
.containerBackground(for: .widget) {
LinearGradient(
colors: [.blue.opacity(0.3), .purple.opacity(0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
// MARK: - Color Background
MyWidgetEntryView(entry: entry)
.containerBackground(.blue.gradient, for: .widget)
// MARK: - Custom View Background
MyWidgetEntryView(entry: entry)
.containerBackground(for: .widget) {
Color.clear // Transparent, system manages background
}Note: For lock screen accessory widgets, the system ignores containerBackground and renders in its own style. You do not need to conditionally apply it.
Widget URL / Deep Linking
Open the main app to a specific view when the user taps the widget.
// MARK: - On the Widget View
/// Apply a URL to the entire widget (systemSmall only supports one tap target).
struct SmallWidgetView: View {
let entry: MyWidgetEntry
var body: some View {
VStack {
Text(entry.title)
Text(entry.value)
}
.widgetURL(URL(string: "myapp://widget/\(entry.itemID)"))
}
}
/// For medium and large widgets, use Link for multiple tap targets.
struct MediumWidgetView: View {
let entry: MyWidgetEntry
var body: some View {
HStack {
Link(destination: URL(string: "myapp://item/1")!) {
Text("Item 1")
}
Link(destination: URL(string: "myapp://item/2")!) {
Text("Item 2")
}
}
}
}
// MARK: - Handling in the Main App
/// In your App struct, handle the widget URL.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
// Parse the URL and navigate to the relevant view
// e.g., url.pathComponents to extract the item ID
handleWidgetURL(url)
}
}
}
private func handleWidgetURL(_ url: URL) {
// Route based on URL path
guard url.scheme == "myapp" else { return }
// Navigate to the appropriate view
}
}Patterns: Good and Bad
containerBackground
// ✅ Good: Always provide containerBackground for iOS 17+
MyWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
// ❌ Bad: No containerBackground -- widget shows default placeholder background
MyWidgetEntryView(entry: entry)Timeline Entries
// ✅ Good: Return meaningful placeholder data instantly
func placeholder(in context: Context) -> MyEntry {
MyEntry(date: .now, title: "Steps", value: "1,234", iconName: "figure.walk", isPlaceholder: true)
}
// ❌ Bad: Fetching data in placeholder (this must return synchronously)
func placeholder(in context: Context) -> MyEntry {
// WRONG: Cannot do async work here
let data = fetchDataSynchronously() // Blocks the main thread
return MyEntry(date: .now, data: data)
}View Complexity
// ✅ Good: Separate view struct for each family
struct MyWidgetEntryView: View {
@Environment(\.widgetFamily) var family
let entry: MyEntry
var body: some View {
switch family {
case .systemSmall: SmallView(entry: entry)
case .systemMedium: MediumView(entry: entry)
default: SmallView(entry: entry)
}
}
}
// ❌ Bad: All families in one massive body with inline conditionals
var body: some View {
if family == .systemSmall {
// 50 lines of layout...
} else if family == .systemMedium {
// 80 lines of layout...
} else {
// 100 lines of layout...
}
}Data Access
// ✅ Good: Use App Groups for shared data
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")
let value = sharedDefaults?.string(forKey: "widgetData")
// ❌ Bad: Use standard UserDefaults (widget cannot access main app's container)
let value = UserDefaults.standard.string(forKey: "widgetData")Interactive Widgets
// ✅ Good: Use Button with AppIntent for interactivity (iOS 17+)
Button(intent: ToggleIntent(itemID: item.id)) {
Label("Complete", systemImage: "checkmark.circle")
}
// ❌ Bad: Try to use onTapGesture (not supported in widgets)
Text("Tap me")
.onTapGesture { /* This does nothing in a widget */ }Memory Usage
// ✅ Good: Load small, optimized thumbnails
if let imageData = SharedContainer.readImageData(named: "widget-thumb-small.jpg") {
Image(uiImage: UIImage(data: imageData)!)
.resizable()
}
// ❌ Bad: Load full-resolution images (risks exceeding 40MB limit)
if let imageData = SharedContainer.readImageData(named: "original-photo-12MB.heic") {
Image(uiImage: UIImage(data: imageData)!)
}