CLAUDE LABJP
WWDC — WWDC 2026 opens Jun 8; the revamped Siri is reported to run on Google Gemini, with Claude among the third-party AI choicesBILLING — From Jun 15, Agent SDK, headless Claude Code, GitHub Actions, and third-party agents move off subscription limits to API-rate monthly credit (1 week left)FALLBACK — Claude Code adds a fallbackModel setting that tries up to three models in order when the primary is overloaded (Jun)DENY-GLOB — Deny rules now support glob patterns in the tool-name position, with stronger cross-session message security (Jun)OPUS4.8 — Claude Opus 4.8 is now the default on Max, Team Premium, Enterprise pay-as-you-go, and the Anthropic API (Jun)MANAGED-AGENTS — Claude Managed Agents can run in a sandbox you control and connect to your private MCP servers (Jun)WWDC — WWDC 2026 opens Jun 8; the revamped Siri is reported to run on Google Gemini, with Claude among the third-party AI choicesBILLING — From Jun 15, Agent SDK, headless Claude Code, GitHub Actions, and third-party agents move off subscription limits to API-rate monthly credit (1 week left)FALLBACK — Claude Code adds a fallbackModel setting that tries up to three models in order when the primary is overloaded (Jun)DENY-GLOB — Deny rules now support glob patterns in the tool-name position, with stronger cross-session message security (Jun)OPUS4.8 — Claude Opus 4.8 is now the default on Max, Team Premium, Enterprise pay-as-you-go, and the Anthropic API (Jun)MANAGED-AGENTS — Claude Managed Agents can run in a sandbox you control and connect to your private MCP servers (Jun)
Articles/Claude Code
Claude Code/2026-04-18Advanced

Building iOS 18 Widgets & App Intents with Claude Code: A Complete Implementation Guide

Accelerate WidgetKit and App Intents development with Claude Code. A hands-on guide covering interactive widgets, Live Activities, and Siri integration—with working code and real-world pitfalls.

Claude Code239iOS 18WidgetKitApp IntentsSwift4SwiftUI7Live Activities

Premium Article

The first wall you hit when building iOS 18 widgets is almost always the same: the timeline isn't updating. You set up the refresh schedule, rebuild the app, and the home screen just... stays the same. WidgetKit is notoriously hard to debug in the simulator alone—you end up chasing device logs, toggling background app refresh, and narrowing down the cause one hypothesis at a time.

Since integrating Claude Code into this process, my debugging cycles have shortened considerably. I paste the error log directly, ask "what's wrong with this timeline setup?", and get back answers that account for WidgetKit-specific constraints I might not have checked. This guide documents the practical knowledge I accumulated while implementing iOS 18 widget features with Claude Code as a coding partner—complete with working Swift code, pitfalls I actually hit, and the prompt patterns that helped me move faster.

WidgetKit Foundations and What Changed in iOS 18

The Timeline Model

Everything in WidgetKit revolves around the TimelineProvider. You give the system a sequence of TimelineEntry values—each paired with a date—and it renders the right entry at the right time. iOS 17 added interactive widgets (buttons, toggles), and iOS 18 expanded the Dynamic Island layout options and improved multi-widget coordination.

Understanding the three provider methods is essential before anything else works correctly:

import WidgetKit
import SwiftUI
 
// The data unit your widget displays at each point in time
struct TaskEntry: TimelineEntry {
    let date: Date
    let taskCount: Int
    let nextDeadline: Date?
    let completionRate: Double
    let highPriorityTitles: [String]
}
 
struct TaskTimelineProvider: TimelineProvider {
    typealias Entry = TaskEntry
 
    // Shown immediately while the real data loads — keep it fast
    func placeholder(in context: Context) -> TaskEntry {
        TaskEntry(
            date: Date(),
            taskCount: 3,
            nextDeadline: Calendar.current.date(byAdding: .hour, value: 2, to: Date()),
            completionRate: 0.6,
            highPriorityTitles: ["Write report", "Review PR"]
        )
    }
 
    // Used in the widget gallery — also needs to be fast
    // ⚠️ Slow snapshots make the gallery feel broken; reviewers see this too
    func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) {
        let entry = TaskEntry(
            date: Date(),
            taskCount: 5,
            nextDeadline: Calendar.current.date(byAdding: .hour, value: 1, to: Date()),
            completionRate: 0.8,
            highPriorityTitles: ["Write report", "Review PR", "Ship release"]
        )
        completion(entry)
    }
 
    // The real timeline — called when system needs fresh data
    func getTimeline(in context: Context, completion: @escaping (Timeline<TaskEntry>) -> Void) {
        var entries: [TaskEntry] = []
        let now = Date()
 
        // ⚠️ UserDefaults.standard is NOT accessible from a widget extension
        // Always use App Group shared storage
        let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared")
        let taskCount = sharedDefaults?.integer(forKey: "taskCount") ?? 0
        let completionRate = sharedDefaults?.double(forKey: "completionRate") ?? 0.0
        let titles = sharedDefaults?.stringArray(forKey: "highPriorityTitles") ?? []
 
        // Generate 15-minute interval entries for the next hour
        for minuteOffset in stride(from: 0, to: 60, by: 15) {
            guard let entryDate = Calendar.current.date(
                byAdding: .minute, value: minuteOffset, to: now
            ) else { continue }
 
            entries.append(TaskEntry(
                date: entryDate,
                taskCount: taskCount,
                nextDeadline: sharedDefaults?.object(forKey: "nextDeadline") as? Date,
                completionRate: completionRate,
                highPriorityTitles: titles
            ))
        }
 
        // Timeline refresh policies:
        // .atEnd     — system calls getTimeline again after the last entry (default choice)
        // .after(date) — refresh at a specific future time (good for deadline-driven content)
        // .never     — only refresh when you call WidgetCenter.shared.reloadTimelines manually
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

When I gave this to Claude Code and asked for a "debugging checklist for when the timeline doesn't update," the response systematically covered App Group misconfiguration, missing Background App Refresh entitlement, iOS's per-widget throttle (roughly 40–70 refreshes per hour depending on device state), and the easily overlooked fact that getTimeline may not fire at all in Low Power Mode.

Interactive Widgets — Making Button Taps Work

iOS 17+ lets you add Button(intent:) and Toggle(isOn:intent:) directly to widget views. The action handler is an AppIntent, not a closure—this is what enables the system to run the intent in a sandboxed process without launching the full app.

import AppIntents
import WidgetKit
 
// The intent that runs when the user taps the widget button
struct CompleteTaskIntent: AppIntent {
    static var title: LocalizedStringResource = "Complete Task"
    static var description = IntentDescription("Marks a task as done from the widget")
 
    @Parameter(title: "Task ID")
    var taskId: String
 
    func perform() async throws -> some IntentResult {
        let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared")
        var completedIds = sharedDefaults?.stringArray(forKey: "completedTaskIds") ?? []
 
        guard \!completedIds.contains(taskId) else {
            return .result() // Already completed — idempotent
        }
 
        completedIds.append(taskId)
        sharedDefaults?.set(completedIds, forKey: "completedTaskIds")
 
        // Trigger a timeline reload so the widget reflects the new state
        // ⚠️ This schedules a reload — the widget won't update synchronously mid-render
        WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
 
        return .result()
    }
}
 
// Widget view with interactive controls
struct TaskWidgetView: View {
    let entry: TaskEntry
 
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack {
                Text("Today's Tasks")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                Text("\(entry.taskCount)")
                    .font(.title2.bold())
            }
 
            ProgressView(value: entry.completionRate)
                .tint(entry.completionRate >= 0.8 ? .green : .orange)
 
            if let title = entry.highPriorityTitles.first {
                HStack {
                    Text(title)
                        .font(.caption2)
                        .lineLimit(1)
                    Spacer()
                    // Interactive button — links to AppIntent, not a closure
                    Button(intent: CompleteTaskIntent(taskId: "top-priority")) {
                        Image(systemName: "checkmark.circle")
                            .foregroundStyle(.green)
                    }
                    .buttonStyle(.plain)
                }
            }
        }
        .padding()
        // ⚠️ This modifier is required in iOS 17+ for interactive widgets to work
        // Missing it can silently disable button interactions
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

The most common question Claude Code gets about interactive widgets is: "The button tap works, but the UI doesn't update immediately." The answer involves explaining that WidgetCenter.shared.reloadTimelines is asynchronous and the system schedules the reload rather than performing it instantly. For cases where immediate visual feedback matters, an Optimistic UI pattern works well—Claude Code can generate the full implementation if you describe the desired behavior.

Live Activities — Dynamic Island and Lock Screen Updates

Defining the Activity Structure

Live Activities use ActivityKit to display real-time updates in the Dynamic Island and on the lock screen. They're ideal for anything that has a clear start, updates over time, and ends: delivery tracking, workout sessions, long builds, sports scores, cooking timers.

The key design decision is separating static data (set at start, never changes) from dynamic content state (updated throughout the activity):

import ActivityKit
import SwiftUI
 
struct BuildTaskAttributes: ActivityAttributes {
    // ContentState: changes during the activity life cycle
    public struct ContentState: Codable, Hashable {
        var progress: Double        // 0.0 to 1.0
        var currentStep: String
        var elapsedSeconds: Int
        var isComplete: Bool
        var errorMessage: String?   // nil when everything is fine
    }
 
    // Static data: fixed at the time Activity.request is called
    let taskName: String
    let startedAt: Date
    let estimatedDurationSeconds: Int
}
 
// Starting a Live Activity
func startBuildActivity(taskName: String, estimatedDuration: Int) -> Activity<BuildTaskAttributes>? {
    // Check authorization — user can disable Live Activities in Settings
    guard ActivityAuthorizationInfo().areActivitiesEnabled else {
        print("Live Activities are disabled. Ask user to enable in Settings → Notifications → Live Activities.")
        return nil
    }
 
    let attributes = BuildTaskAttributes(
        taskName: taskName,
        startedAt: Date(),
        estimatedDurationSeconds: estimatedDuration
    )
 
    let initialState = BuildTaskAttributes.ContentState(
        progress: 0.0,
        currentStep: "Initializing",
        elapsedSeconds: 0,
        isComplete: false,
        errorMessage: nil
    )
 
    // staleDate: past this time, the system may visually indicate stale data
    let content = ActivityContent(
        state: initialState,
        staleDate: Calendar.current.date(byAdding: .minute, value: 30, to: Date())
    )
 
    do {
        // pushType: .token enables remote updates via APNs (useful for server-driven activities)
        // pushType: nil for purely local updates
        let activity = try Activity.request(
            attributes: attributes,
            content: content,
            pushType: .token
        )
        print("Live Activity started with ID: \(activity.id)")
        return activity
    } catch {
        // ActivityKitError code 4 is one of the most common:
        // usually NSSupportsLiveActivities missing or wrong target
        print("Failed to start Live Activity: \(error)")
        return nil
    }
}
 
// Updating a running Live Activity
func updateBuildActivity(
    _ activity: Activity<BuildTaskAttributes>,
    step: String,
    progress: Double,
    error: String? = nil
) async {
    let elapsed = Int(Date().timeIntervalSince(activity.attributes.startedAt))
    let updatedState = BuildTaskAttributes.ContentState(
        progress: min(progress, 1.0),
        currentStep: step,
        elapsedSeconds: elapsed,
        isComplete: progress >= 1.0,
        errorMessage: error
    )
 
    await activity.update(
        ActivityContent(
            state: updatedState,
            staleDate: Calendar.current.date(byAdding: .minute, value: 5, to: Date())
        )
    )
}
 
// Ending a Live Activity
func endBuildActivity(_ activity: Activity<BuildTaskAttributes>, success: Bool) async {
    let finalState = BuildTaskAttributes.ContentState(
        progress: 1.0,
        currentStep: success ? "Complete" : "Failed",
        elapsedSeconds: Int(Date().timeIntervalSince(activity.attributes.startedAt)),
        isComplete: true,
        errorMessage: success ? nil : "Build failed — check Xcode logs"
    )
 
    // .immediate: dismiss the Live Activity right away
    // .default: keep it visible for a few seconds after ending
    // .after(date): keep it visible until a specific time (good for results users want to read)
    await activity.end(
        ActivityContent(state: finalState, staleDate: nil),
        dismissalPolicy: .after(Date().addingTimeInterval(30))
    )
}

Live Activity UI for Dynamic Island

import ActivityKit
import SwiftUI
import WidgetKit
 
struct BuildActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: BuildTaskAttributes.self) { context in
            // Lock screen / StandBy view
            LockScreenLiveActivityView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded view (user long-presses)
                DynamicIslandExpandedRegion(.leading) {
                    Label(context.attributes.taskName, systemImage: "hammer.fill")
                        .font(.caption)
                        .lineLimit(1)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(String(format: "%.0f%%", context.state.progress * 100))
                        .font(.caption.monospacedDigit())
                        .foregroundStyle(context.state.isComplete ? .green : .primary)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    VStack(spacing: 4) {
                        ProgressView(value: context.state.progress)
                            .tint(context.state.errorMessage \!= nil ? .red : .orange)
                        if let error = context.state.errorMessage {
                            Text(error)
                                .font(.caption2)
                                .foregroundStyle(.red)
                        } else {
                            Text(context.state.currentStep)
                                .font(.caption2)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            } compactLeading: {
                // Compact left — shown when another app uses the island
                Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "hammer.fill")
                    .foregroundStyle(context.state.isComplete ? .green : .orange)
            } compactTrailing: {
                // Compact right
                Text(String(format: "%.0f%%", context.state.progress * 100))
                    .font(.caption2.monospacedDigit())
            } minimal: {
                // Minimal — shown when two activities compete for the island
                ProgressView(value: context.state.progress)
                    .progressViewStyle(.circular)
                    .tint(context.state.isComplete ? .green : .orange)
            }
        }
    }
}
 
struct LockScreenLiveActivityView: View {
    let context: ActivityViewContext<BuildTaskAttributes>
 
    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(context.attributes.taskName)
                    .font(.headline)
                    .lineLimit(1)
                Text(context.state.currentStep)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            Spacer()
            VStack(alignment: .trailing) {
                Text(String(format: "%.0f%%", context.state.progress * 100))
                    .font(.title3.bold().monospacedDigit())
                Text("\(context.state.elapsedSeconds / 60)m elapsed")
                    .font(.caption2)
                    .foregroundStyle(.tertiary)
            }
        }
        .padding()
        .activityBackgroundTint(.black.opacity(0.6))
    }
}

Debugging Live Activities with Claude Code

Live Activity error messages are frustratingly vague. ActivityKitError(code: 4) is among the most common, and the code alone gives little away. Sending a structured report to Claude Code narrows the search quickly:

My Live Activity fails to start:
Error: ActivityKitError(code: 4)
- iOS 18.3.1, Xcode 16.2, deployment target iOS 17.0+
- NSSupportsLiveActivities: YES in main app Info.plist
- App Group configured and working (UserDefaults reads fine)
- Tested on physical device, not simulator

What should I check next, in order of likelihood?

From experience, the most useful responses cover: (1) the main app target vs Extension target distinction for NSSupportsLiveActivities, (2) App Group identifier mismatches that can pass casual inspection but differ by a single character, (3) the fact that certain features simply don't run in the simulator and require a device.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
How to design per-content refresh intervals so multiple widgets do not exhaust the shared timeline reload budget
An ImageIO downsampling implementation that keeps Widget Extensions under the ~30MB memory ceiling (measured: 6MB source down to ~2MB)
A safe App Group pattern that shares image references instead of binaries, wired into Crashlytics and Firebase operations
Secure payment via Stripe · Cancel anytime
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Claude Code2026-04-15
Claude Code × SwiftUI × StoreKit 2 — Complete In-App Purchase & Subscription Implementation Guide
Master in-app purchases and subscriptions with Claude Code, SwiftUI, and StoreKit 2. From transaction handling and server-side validation to paywall UI design — production-quality code that passes App Store review.
Claude Code2026-04-12
Claude Code × Swift/iOS Production Guide — From Xcode Integration to App Store Launch
A practical guide to building production-quality iOS apps with Claude Code and Xcode. Covers CLAUDE.md design, SwiftUI implementation, Clean Architecture, automated testing, Fastlane CI/CD, and App Store submission.
Claude Code2026-05-15
Eliminating SwiftUI Animation Guesswork with Claude Code — Prompt Patterns from a 50M Download Wallpaper App
How to use Claude Code for SwiftUI animation work — practical prompt patterns and Before/After code examples from the development of Beautiful HD Wallpapers, a 50M+ download app.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →