All Articles
CategoryiOS
Reading Time
10 min read
Published
2026-10-15
Word Count
1,725words

Grab a coffee — this one is a deep dive!

WidgetDevelopmentKit 2.0: keeping widget logic out of the view

Summary

Why WidgetDevelopmentKit moves widget date, storage and retry logic into tested pure functions, and what rebuilding it for 2.0.0 taught.

  • It ships ready-made SwiftUI views for Home Screen, Lock Screen and StandBy widgets, chart views sized for widgets, App Intents for widget buttons and ActivityKit attributes and views for Live Activities.
  • One target, one product, no dependencies.
  • Add the package to the widget extension with Swift Package Manager: swift dependencies: [ ], targets: [ ] A timeline provider reads what the app saved to the App Group.
  • The roadmap in the README lists snapshot tests for the view bodies and an example Live Activity.
WidgetDevelopmentKit 2.0: keeping widget logic out of the view

Table of Contents


The problem

A WidgetKit widget looks like a small SwiftUI view, but many of the bugs fixed in this package's 2.0.0 release came from the data around the view rather than from its layout: a goal of zero that turns a progress ring into NaN, a countdown whose end date is before its start date, a "next reload" computed by adding 86,400 seconds on a day that has 23 hours, or a network request that waits for connectivity inside an extension that only runs for a few seconds per timeline request.

WidgetDevelopmentKit is a Swift package for developers who build widgets for their iOS apps. It ships ready-made SwiftUI views for Home Screen, Lock Screen and StandBy widgets, chart views sized for widgets, App Intents for widget buttons and ActivityKit attributes and views for Live Activities. The part I care about most is underneath the views: helpers for timelines, for the data the app shares with its widget, and for loading data from a server. You still write the widget extension; the package gives you the views and the date, storage and retry logic that every widget needs.

Version 2.0.0 was released on 14 October 2026. It is the first release in which the whole package builds and its tests run, and the reason for that is part of this article.

Design decisions and alternatives

One target, one product, no dependencies. The manifest declares a single WidgetDevelopmentKit library target with Swift 6 tools and language mode, iOS 17 and macOS 14. The earlier v1.0.0 manifest declared several products and dependencies the code did not use, and it did not load at all. Splitting the package into Core and Views modules would let an app link less code, but it would also add public API surface and maintenance for a package of this size. A widget extension has tight memory limits, so not pulling in third-party code was the stronger argument.

Thin views, pure arithmetic. SwiftUI bodies are hard to unit test, so the arithmetic that fails with real data lives in Core as pure code: functions on the WidgetMath and WidgetFormat enums, and the MonthGrid and CountdownComponents value types, which do their calculation in init. They run under swift test on macOS without a simulator. The alternative would have been snapshot tests of the views. The package does not have them yet, and the README says so.

Calendar arithmetic, not seconds. WidgetUpdatePolicy describes when a timeline should reload and turns that into WidgetKit's TimelineReloadPolicy. Its .nextHour case uses calendar.dateInterval(of: .hour, for:) and .startOfNextDay uses calendar.date(byAdding: .day, …), because adding 3,600 or 86,400 seconds is wrong on daylight-saving days. WidgetMath.daysRemaining(until:from:calendar:) compares the start of both days instead of truncating an interval. A test pins this with the US change on 8 March 2026 in America/New_York.

Dependencies as closures, not protocols. WidgetDataLoader takes a Transport and a Sleep, both @Sendable async closures, and WidgetSnapshotStore takes its UserDefaults and a clock. For a dependency with a single method, a closure needs less ceremony than a protocol, and the tests need neither a URLProtocol stub nor real waiting.

Retry only what can succeed. WidgetNetworkError.isTransient is true for HTTP 408 and 5xx and for timedOut, networkConnectionLost, notConnectedToInternet and cannotConnectToHost. A 4xx response, a decoding error or a cancelled request is not retried. The default RetryPolicy makes two attempts with a 500 ms initial delay that doubles on each later attempt, and URLSession.widgetDefault is an ephemeral session with 10 s and 15 s timeouts and waitsForConnectivity = false. The extension should fail fast and fall back to cached data rather than spend its budget waiting.

State across processes. A widget button runs its AppIntent in the extension process, not in the app. WidgetStateStore keeps flags, counters and a queue of pending actions in the App Group named by the WidgetDevelopmentKitAppGroup key in Info.plist. App Intents need a parameterless init(), so the store cannot be injected into them; the Info.plist key is the configuration point, and init(defaults:) stays public for tests. RecordActionIntent appends to a queue that keeps the most recent 50 actions, and the app reads it with drainPendingActions(). The removed ActionIntent posted a notification inside the extension, which the app, a different process, could never receive.

Serialized Live Activity updates. LiveActivityStateMachine is an actor, and transition(to:) uses typed throws: throws(StateMachineError<State>) reports the rejected from and to states. Updates can arrive out of order from a push, a background refresh or an app launch, so the machine rejects a move such as .delivered back to .preparing. The cost is an await at every call site.

No Date() while rendering. Countdown views take a referenceDate, the timeline entry date, so each entry renders its own moment. The StandBy countdown no longer has a seconds column, because a static snapshot cannot update every second.

What went wrong, and what I learned

The repository was opened in August 2025. The early commits added documentation, CI templates and modules faster than they added code that compiled. The manifest at the v1.0.0 tag (February 2026) does not load: the test target fails with invalid custom path. A rework in mid-June 2026 made the library target build, but it held only about 240 lines in six files, and the Quick/Nimble test target still did not compile.

The rebuild was merged on 14 October 2026 as PR #28, "feat!: restore the widget components library and prepare 2.0.0". About 10,500 lines of sources that no target compiled and about 6,000 lines of Quick/Nimble tests were removed. The views that were worth keeping came back from history into the target, and eleven old workflows, none of which built the package successfully, were replaced by one build, test and docs workflow (5a917d9). The CHANGELOG states plainly that v1.0.0 cannot be built, which is also why this release is a major version.

Restoring code from history meant reading it again, and that found real bugs. One example is commit 4e54d54. TimerActivityView rendered Text(endTime, style: .timer) even when isPaused was set, so a paused timer kept counting down on the Lock Screen and in the Dynamic Island, and the pausedTimeRemaining field was never read. The fix moved the decision out of the view into a computed ContentState.pausedLabel: nil while the timer runs, a fixed m:ss label while it is paused. Three iOS-only tests cover a running timer, a paused one, and a paused one without a remaining time, which reads 0:00.

The network code had a similar story. The old NetworkDataProvider retried 4xx responses such as 404 (only 429 was exempt) and waited for connectivity inside the extension. Commit 2bdc1f4 replaced its 473 lines with a 137-line loader and a 65-line Endpoint builder. Its tests script the transport: 503, then a timeout, then 200 must make three requests and record sleeps of 100 ms and 200 ms without waiting for them; a 404 must make one request and no sleep.

The quieter lesson was about tooling. .swiftlint.yml contained an unquoted */Generated/* entry that YAML parses as an alias, so SwiftLint could not read the file and ran with its default rules without failing (56c762e). README examples had the same risk of drifting from the API. ReadmeSyncTests (9d52da6) now checks that every Swift block in README.md except the Package.swift fragment appears, line by line, in a file under Examples/ or in ReadmeExamplesTests.swift, both of which are compiled. A quality check only counts if something fails when it is broken.

Usage

Add the package to the widget extension with Swift Package Manager:

swift
1dependencies: [
2 .package(url: "https://github.com/muhittincamdali/iOS-Widget-Development-Kit.git", from: "2.0.0")
3],
4targets: [
5 .target(name: "YourApp", dependencies: [
6 .product(name: "WidgetDevelopmentKit", package: "iOS-Widget-Development-Kit")
7 ])
8]

A timeline provider reads what the app saved to the App Group. TimelineSchedule produces the entry dates, TimelineBuilder sorts the entries and turns a WidgetUpdatePolicy into a reload policy, and staleValue returns the cached value even after it expired:

swift
1struct StepsProvider: TimelineProvider {
2 let store = WidgetSnapshotStore(defaults: UserDefaults(suiteName: "group.com.example.widgets") ?? .standard)
3 
4 func placeholder(in context: Context) -> StepsEntry {
5 StepsEntry(date: .now, steps: 6_000)
6 }
7 
8 func getSnapshot(in context: Context, completion: @escaping (StepsEntry) -> Void) {
9 completion(placeholder(in: context))
10 }
11 
12 func getTimeline(in context: Context, completion: @escaping (Timeline<StepsEntry>) -> Void) {
13 completion(makeTimeline(now: .now))
14 }
15 
16 func makeTimeline(now: Date) -> Timeline<StepsEntry> {
17 // Written by the app; stale data is better than an empty widget.
18 let steps = store.staleValue(Int.self, forKey: "steps") ?? 0
19 let entries = TimelineSchedule
20 .dates(from: now, every: 15 * 60, count: 4)
21 .map { StepsEntry(date: $0, steps: steps) }
22 return TimelineBuilder(entries: entries)
23 .reloading(.every(minutes: 60), from: now)
24 .build()
25 }
26}

The app writes the value with a lifetime and asks WidgetKit to reload:

swift
1try store.save(steps, forKey: "steps", lifetime: 30 * 60)
2WidgetCenter.shared.reloadTimelines(ofKind: "steps")

A widget button runs one of the package's intents in the extension:

swift
1struct WaterButton: View {
2 var body: some View {
3 Button(intent: IncrementIntent(counterId: "water", amount: 250)) {
4 Label("+250 ml", systemImage: "drop.fill")
5 }
6 }
7}

These blocks come from Examples/WidgetKitExample, which CI builds, and from the test target. The example app has three widgets: a countdown, a steps widget and an interactive water widget.

Limits

  • SwiftUI view bodies have no snapshot or UI tests. The tests cover the logic, not how the views look. The README images are rendered with ImageRenderer outside WidgetKit, so the widget frame, Lock Screen vibrancy and StandBy night mode are approximated.
  • WidgetStateStore.increment and enqueueAction read, modify and write UserDefaults. They are not atomic across processes, so if the app and the extension write at the same moment, one update can be lost.
  • The package was built locally with Xcode 27 and in CI with the default Xcode of GitHub's macos-26 image. Older Xcode versions have not been tried.
  • There are no on-device memory or timing measurements. The network timeouts were chosen for the extension's budget but not profiled.
  • The example app has no Live Activity. The Live Activity views are checked by state tests and by compiling.
  • Lock Screen views and Live Activities are iOS only; watchOS, tvOS and visionOS are not supported.

The roadmap in the README lists snapshot tests for the view bodies and an example Live Activity. The API reference is built from the DocC comments, and the CHANGELOG records every change in 2.0.0.

Tags

#swift#widgetkit#swiftui#live-activities#app-intents#testing
Muhittin Çamdalı

Muhittin Çamdalı

Senior Mobile Engineer

Senior Mobile Engineer with 12+ years of experience. I build mobile products with iOS, Flutter and React Native, working full-stack and AI-native.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

Share